Compare commits

...
Author SHA1 Message Date
Dominic Cooney 642824c5cc Support heterogeneous teammates. 2026-05-14 15:49:24 +09:00
11 changed files with 475 additions and 18 deletions
@@ -27,6 +27,8 @@ export type DelegatedAgentConnectionConfig = Pick<
| "providerConfig"
| "knownModels"
| "thinking"
| "reasoningEffort"
| "thinkingBudgetTokens"
>;
export interface DelegatedAgentRuntimeConfig
@@ -69,6 +71,7 @@ export interface BuildDelegatedAgentConfigOptions {
) => Promise<ToolApprovalResult> | ToolApprovalResult;
role?: string;
cwd?: string;
connectionConfig?: DelegatedAgentConnectionConfig;
}
export function createDelegatedAgentConfigProvider(
@@ -87,6 +90,8 @@ export function createDelegatedAgentConfigProvider(
providerConfig: runtimeConfig.providerConfig,
knownModels: runtimeConfig.knownModels,
thinking: runtimeConfig.thinking,
reasoningEffort: runtimeConfig.reasoningEffort,
thinkingBudgetTokens: runtimeConfig.thinkingBudgetTokens,
}),
updateConnectionDefaults: (overrides) => {
runtimeConfig = {
@@ -107,7 +112,8 @@ export function buildDelegatedAgentConfig(
: buildSubAgentSystemPrompt(options.prompt, runtimeConfig);
return {
...options.configProvider.getConnectionConfig(),
...(options.connectionConfig ??
options.configProvider.getConnectionConfig()),
systemPrompt,
tools: options.tools,
maxIterations: options.maxIterations ?? runtimeConfig.maxIterations,
@@ -0,0 +1,155 @@
import type { ProviderListItem, ProviderModel } from "@cline/shared";
import { resolveProviderConfig } from "../../../services/llms/provider-defaults";
import {
getLocalProviderModels,
listLocalProviders,
refreshProviderModelsFromSource,
} from "../../../services/providers/local-provider-service";
import type { ProviderSettingsManager } from "../../../services/storage/provider-settings-manager";
import type {
DelegatedAgentConfigProvider,
DelegatedAgentConnectionConfig,
} from "./delegated-agent";
import type {
TeamConnectionConfigRequest,
TeamModelCatalogResult,
TeamModelConfigProvider,
TeamModelListItem,
TeamProviderListItem,
} from "./team-tools";
function toTeamModel(model: ProviderModel): TeamModelListItem {
return {
id: model.id,
name: model.name,
supportsAttachments: model.supportsAttachments,
supportsVision: model.supportsVision,
supportsReasoning: model.supportsReasoning,
};
}
function toTeamProvider(provider: ProviderListItem): TeamProviderListItem {
return {
id: provider.id,
name: provider.name,
enabled: provider.enabled,
defaultModelId: provider.defaultModelId,
models: provider.models,
modelList: provider.modelList?.map(toTeamModel),
};
}
export function createLocalTeamModelConfigProvider(options: {
manager: ProviderSettingsManager;
fallbackConfigProvider: DelegatedAgentConfigProvider;
}): TeamModelConfigProvider {
return {
async listModels(input): Promise<TeamModelCatalogResult> {
const providerId = input.providerId?.trim();
if (input.refresh) {
if (providerId) {
await refreshProviderModelsFromSource(
options.manager,
providerId,
).catch(() => undefined);
} else {
const catalog = await listLocalProviders(options.manager);
await Promise.all(
catalog.providers
.filter((provider) => provider.enabled)
.map((provider) =>
refreshProviderModelsFromSource(
options.manager,
provider.id,
).catch(() => undefined),
),
);
}
}
const includeDisabled = input.includeDisabledProviders ?? true;
const catalog = await listLocalProviders(options.manager);
let providers = catalog.providers;
if (providerId) {
providers = providers.filter((provider) => provider.id === providerId);
}
if (!includeDisabled) {
providers = providers.filter((provider) => provider.enabled);
}
return {
providers: providers.map(toTeamProvider),
settingsPath: catalog.settingsPath,
};
},
async resolveConnectionConfig(
request: TeamConnectionConfigRequest,
): Promise<DelegatedAgentConnectionConfig> {
const fallback = options.fallbackConfigProvider.getConnectionConfig();
const providerId = request.providerId?.trim() || fallback.providerId;
const modelId = request.modelId?.trim() || fallback.modelId;
if (providerId === fallback.providerId) {
return {
...fallback,
modelId,
...(request.thinking !== undefined
? { thinking: request.thinking }
: {}),
...(request.reasoningEffort
? { reasoningEffort: request.reasoningEffort }
: {}),
};
}
const persistedConfig = options.manager.getProviderConfig(providerId, {
includeKnownModels: false,
});
const resolved = await resolveProviderConfig(
providerId,
{
loadLatestOnInit: false,
loadPrivateOnAuth: false,
failOnError: false,
},
persistedConfig,
);
const settings = options.manager.getProviderSettings(providerId);
const knownModels =
resolved?.knownModels ??
(await getLocalProviderModels(providerId, persistedConfig)
.then(({ models }) =>
Object.fromEntries(
models.map((model) => [
model.id,
{
id: model.id,
name: model.name,
},
]),
),
)
.catch(() => undefined));
return {
providerId,
modelId,
apiKey: persistedConfig?.apiKey ?? settings?.apiKey,
baseUrl: persistedConfig?.baseUrl ?? resolved?.baseUrl,
headers: persistedConfig?.headers ?? settings?.headers,
providerConfig: persistedConfig,
knownModels,
thinking:
request.thinking ?? persistedConfig?.thinking ?? fallback.thinking,
reasoningEffort:
request.reasoningEffort ??
persistedConfig?.reasoningEffort ??
fallback.reasoningEffort,
thinkingBudgetTokens:
persistedConfig?.thinkingBudgetTokens ??
fallback.thinkingBudgetTokens,
};
},
};
}
@@ -152,7 +152,10 @@ describe("AgentTeamsRuntime teammate lifecycle events", () => {
role: undefined,
teammate: {
rolePrompt: "Write concise Python-focused haiku",
providerId: "anthropic",
modelId: "claude-sonnet-4-5-20250929",
thinking: undefined,
reasoningEffort: undefined,
maxIterations: 7,
runtimeAgentId: "teammate-1",
conversationId: "conv-1",
@@ -889,7 +889,10 @@ export class AgentTeamsRuntime {
role: config.role,
teammate: {
rolePrompt: config.systemPrompt,
providerId: config.providerId,
modelId: config.modelId,
thinking: config.thinking,
reasoningEffort: config.reasoningEffort,
maxIterations: config.maxIterations,
runtimeAgentId: agent.getAgentId(),
conversationId: agent.getConversationId(),
@@ -7,6 +7,7 @@ export {
type DelegatedAgentKind,
type DelegatedAgentRuntimeConfig,
} from "./delegated-agent";
export { createLocalTeamModelConfigProvider } from "./model-config-provider";
// =============================================================================
// Spawn Agent Tool
@@ -50,5 +51,10 @@ export {
createAgentTeamsTools,
reviveTeamStateDates,
sanitizeTeamName,
type TeamConnectionConfigRequest,
type TeamModelCatalogResult,
type TeamModelConfigProvider,
type TeamModelListItem,
type TeamProviderListItem,
type TeamTeammateRuntimeConfig,
} from "./team-tools";
@@ -136,9 +136,12 @@ describe("createAgentTeamsTools schema surface", () => {
onLeadToolsUnlocked,
});
expect(tools.map((tool) => tool.name)).toEqual(["team_spawn_teammate"]);
expect(tools.map((tool) => tool.name)).toEqual([
"team_spawn_teammate",
"team_list_models",
]);
const spawn = tools[0];
const spawn = tools.find((tool) => tool.name === "team_spawn_teammate");
await expect(
spawn?.execute(
{
@@ -509,6 +512,112 @@ describe("createAgentTeamsTools runtime behavior", () => {
);
});
it("applies optional provider/model/reasoning overrides when spawning teammates", async () => {
const spawnTeammate = vi.fn();
const runtime = {
getMemberRole: vi.fn(() => "lead"),
isTeammateActive: vi.fn(() => false),
spawnTeammate,
} as unknown as AgentTeamsRuntime;
const modelConfigProvider = {
listModels: vi.fn(),
resolveConnectionConfig: vi.fn(async () => ({
providerId: "openai-native",
modelId: "gpt-5.3-codex",
apiKey: "openai-key",
thinking: true,
reasoningEffort: "high" as const,
})),
};
const tools = createAgentTeamsTools({
runtime,
requesterId: "lead",
teammateConfigProvider: makeTeammateConfigProvider(),
modelConfigProvider,
createBaseTools: () => [],
includeManagementTools: false,
});
const spawnTool = tools.find((tool) => tool.name === "team_spawn_teammate");
await spawnTool?.execute(
{
agentId: "coder",
rolePrompt: "Implement focused edits.",
providerId: "openai-native",
modelId: "gpt-5.3-codex",
thinking: true,
reasoningEffort: "high",
maxIterations: 9,
},
{ agentId: "lead", conversationId: "conv-1", iteration: 1 },
);
expect(modelConfigProvider.resolveConnectionConfig).toHaveBeenCalledWith({
providerId: "openai-native",
modelId: "gpt-5.3-codex",
thinking: true,
reasoningEffort: "high",
});
expect(spawnTeammate).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "coder",
config: expect.objectContaining({
providerId: "openai-native",
modelId: "gpt-5.3-codex",
apiKey: "openai-key",
thinking: true,
reasoningEffort: "high",
maxIterations: 9,
}),
}),
);
});
it("lists teammate models through the injected model config provider", async () => {
const runtime = new AgentTeamsRuntime({ teamName: "test-team" });
const modelConfigProvider = {
listModels: vi.fn(async () => ({
providers: [
{
id: "cline",
name: "Cline",
enabled: true,
defaultModelId: "anthropic/claude-sonnet-4.6",
models: 1,
modelList: [
{ id: "anthropic/claude-sonnet-4.6", supportsReasoning: true },
],
},
],
settingsPath: "/tmp/providers.json",
})),
resolveConnectionConfig: vi.fn(),
};
const tools = createAgentTeamsTools({
runtime,
requesterId: "lead",
teammateConfigProvider: makeTeammateConfigProvider(),
modelConfigProvider,
});
const listModels = tools.find((tool) => tool.name === "team_list_models");
await expect(
listModels?.execute(
{ providerId: "cline", refresh: true },
{ agentId: "lead", conversationId: "conv-1", iteration: 1 },
),
).resolves.toMatchObject({
providers: [
{ id: "cline", modelList: [{ id: "anthropic/claude-sonnet-4.6" }] },
],
});
expect(modelConfigProvider.listModels).toHaveBeenCalledWith({
providerId: "cline",
refresh: true,
});
});
it("injects workspace metadata into cline teammate system prompt", async () => {
const spawnTeammate = vi.fn();
const runtime = {
@@ -26,6 +26,8 @@ import {
type TeamFinalizeOutcomeInput,
TeamFinalizeOutcomeInputSchema,
TeamFinalizeOutcomeToolResultSchema,
type TeamListModelsInput,
TeamListModelsInputSchema,
type TeamListOutcomesInput,
TeamListOutcomesInputSchema,
type TeamListRunsInput,
@@ -74,6 +76,7 @@ import {
import {
buildDelegatedAgentConfig,
type DelegatedAgentConfigProvider,
type DelegatedAgentConnectionConfig,
type DelegatedAgentRuntimeConfig,
} from "./delegated-agent";
import type { AgentTeamsRuntime } from "./multi-agent";
@@ -163,10 +166,47 @@ function assertAwaitedRunSucceeded(run: TeamRunRecord): void {
export type TeamTeammateRuntimeConfig = DelegatedAgentRuntimeConfig;
export interface TeamModelListItem {
id: string;
name?: string;
supportsAttachments?: boolean;
supportsVision?: boolean;
supportsReasoning?: boolean;
}
export interface TeamProviderListItem {
id: string;
name: string;
enabled: boolean;
defaultModelId?: string;
models?: number | null;
modelList?: TeamModelListItem[];
}
export interface TeamModelCatalogResult {
providers: TeamProviderListItem[];
settingsPath?: string;
}
export interface TeamConnectionConfigRequest {
providerId?: string;
modelId?: string;
thinking?: boolean;
reasoningEffort?: DelegatedAgentConnectionConfig["reasoningEffort"];
}
export interface TeamModelConfigProvider {
listModels(input: TeamListModelsInput): Promise<TeamModelCatalogResult>;
resolveConnectionConfig(
input: TeamConnectionConfigRequest,
): Promise<DelegatedAgentConnectionConfig>;
}
export interface CreateAgentTeamsToolsOptions {
runtime: AgentTeamsRuntime;
requesterId: string;
teammateConfigProvider: DelegatedAgentConfigProvider;
modelConfigProvider?: TeamModelConfigProvider;
createBaseTools?: () => AgentTool[];
allowSpawn?: boolean;
includeSpawnTool?: boolean;
@@ -177,6 +217,7 @@ export interface CreateAgentTeamsToolsOptions {
export interface BootstrapAgentTeamsOptions {
runtime: AgentTeamsRuntime;
teammateConfigProvider: DelegatedAgentConfigProvider;
modelConfigProvider?: TeamModelConfigProvider;
createBaseTools?: () => AgentTool[];
leadAgentId?: string;
restoredTeammates?: TeamTeammateSpec[];
@@ -194,6 +235,7 @@ export interface BootstrapAgentTeamsResult {
export const TEAM_TOOL_NAMES = [
"team_spawn_teammate",
"team_list_models",
"team_shutdown_teammate",
"team_status",
"team_task",
@@ -218,7 +260,7 @@ function spawnTeamTeammate(
requesterId: string;
spec: TeamTeammateSpec;
},
): void {
): Promise<void> | void {
const teammateTools: AgentTool[] = [];
if (options.createBaseTools) {
teammateTools.push(...options.createBaseTools());
@@ -228,22 +270,47 @@ function spawnTeamTeammate(
runtime: options.runtime,
requesterId: options.spec.agentId,
teammateConfigProvider: options.teammateConfigProvider,
modelConfigProvider: options.modelConfigProvider,
createBaseTools: options.createBaseTools,
allowSpawn: false,
}),
);
options.runtime.spawnTeammate({
agentId: options.spec.agentId,
config: buildDelegatedAgentConfig({
kind: "teammate",
prompt: options.spec.rolePrompt,
role: options.spec.rolePrompt,
configProvider: options.teammateConfigProvider,
tools: teammateTools,
maxIterations: options.spec.maxIterations,
cwd: options.teammateConfigProvider.getRuntimeConfig().cwd,
}),
});
const spawn = async () => {
const connectionConfig = options.modelConfigProvider
? await options.modelConfigProvider.resolveConnectionConfig({
providerId: options.spec.providerId,
modelId: options.spec.modelId,
thinking: options.spec.thinking,
reasoningEffort: options.spec.reasoningEffort,
})
: {
...options.teammateConfigProvider.getConnectionConfig(),
...(options.spec.providerId
? { providerId: options.spec.providerId }
: {}),
...(options.spec.modelId ? { modelId: options.spec.modelId } : {}),
...(options.spec.thinking !== undefined
? { thinking: options.spec.thinking }
: {}),
...(options.spec.reasoningEffort
? { reasoningEffort: options.spec.reasoningEffort }
: {}),
};
options.runtime.spawnTeammate({
agentId: options.spec.agentId,
config: buildDelegatedAgentConfig({
kind: "teammate",
prompt: options.spec.rolePrompt,
role: options.spec.rolePrompt,
configProvider: options.teammateConfigProvider,
connectionConfig,
tools: teammateTools,
maxIterations: options.spec.maxIterations,
cwd: options.teammateConfigProvider.getRuntimeConfig().cwd,
}),
});
};
return spawn();
}
export function bootstrapAgentTeams(
@@ -256,6 +323,7 @@ export function bootstrapAgentTeams(
runtime: options.runtime,
requesterId: leadAgentId,
teammateConfigProvider: options.teammateConfigProvider,
modelConfigProvider: options.modelConfigProvider,
createBaseTools: options.createBaseTools,
allowSpawn: true,
includeSpawnTool: options.includeLeadSpawnTool,
@@ -268,10 +336,11 @@ export function bootstrapAgentTeams(
if (options.runtime.isTeammateActive(spec.agentId)) {
continue;
}
spawnTeamTeammate({
void spawnTeamTeammate({
runtime: options.runtime,
requesterId: leadAgentId,
teammateConfigProvider: options.teammateConfigProvider,
modelConfigProvider: options.modelConfigProvider,
createBaseTools: options.createBaseTools,
spec,
});
@@ -313,11 +382,17 @@ export function createAgentTeamsTools(
const spec: TeamTeammateSpec = {
agentId: validatedInput.agentId,
rolePrompt: validatedInput.rolePrompt,
providerId: validatedInput.providerId,
modelId: validatedInput.modelId,
thinking: validatedInput.thinking,
reasoningEffort: validatedInput.reasoningEffort,
maxIterations: validatedInput.maxIterations,
};
spawnTeamTeammate({
await spawnTeamTeammate({
runtime: options.runtime,
requesterId: options.requesterId,
teammateConfigProvider: options.teammateConfigProvider,
modelConfigProvider: options.modelConfigProvider,
createBaseTools: options.createBaseTools,
spec,
});
@@ -340,6 +415,52 @@ export function createAgentTeamsTools(
);
}
tools.push(
createTool<TeamListModelsInput, TeamModelCatalogResult>({
name: "team_list_models",
description:
"List providers and models available for spawning teammates. Use providerId to filter; refresh=true may query provider model sources.",
inputSchema: zodToJsonSchema(TeamListModelsInputSchema),
execute: async (input) => {
const validatedInput = validateWithZod(
TeamListModelsInputSchema,
input,
);
if (options.modelConfigProvider) {
return options.modelConfigProvider.listModels(validatedInput);
}
const runtimeConfig = options.teammateConfigProvider.getRuntimeConfig();
const connectionConfig =
options.teammateConfigProvider.getConnectionConfig();
const modelIds = Object.keys(connectionConfig.knownModels ?? {});
const modelList = modelIds.length
? modelIds.map((id) => ({
id,
name: connectionConfig.knownModels?.[id]?.name ?? id,
}))
: [
{
id: connectionConfig.modelId,
name: connectionConfig.modelId,
},
];
return {
providers: [
{
id: connectionConfig.providerId,
name: connectionConfig.providerId,
enabled: true,
defaultModelId: runtimeConfig.modelId,
models: modelList.length,
modelList,
},
],
};
},
}) as AgentTool,
);
if (!includeManagementTools) {
return tools;
}
+6
View File
@@ -295,6 +295,7 @@ export {
createAgentTeamsTools,
createDelegatedAgent,
createDelegatedAgentConfigProvider,
createLocalTeamModelConfigProvider,
createSpawnAgentTool,
type DelegatedAgentConfigProvider,
type DelegatedAgentConnectionConfig,
@@ -305,8 +306,13 @@ export {
type SubAgentEndContext,
type SubAgentStartContext,
type TaskResult,
type TeamConnectionConfigRequest,
type TeamEvent,
type TeamMemberConfig,
type TeamModelCatalogResult,
type TeamModelConfigProvider,
type TeamModelListItem,
type TeamProviderListItem,
type TeamTeammateRuntimeConfig,
toTeamProgressLifecycleEvent,
} from "./extensions/tools/team";
@@ -30,12 +30,14 @@ import {
AgentTeamsRuntime,
bootstrapAgentTeams,
createDelegatedAgentConfigProvider,
createLocalTeamModelConfigProvider,
type TeamEvent,
} from "../../extensions/tools/team";
import {
filterDisabledTools,
resolveDisabledToolNames,
} from "../../services/global-settings";
import { ProviderSettingsManager } from "../../services/storage/provider-settings-manager";
import { createLocalTeamStore } from "../../services/storage/team-store";
import type { CoreAgentMode, CoreSessionConfig } from "../../types/config";
import type {
@@ -423,6 +425,12 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
telemetry: input.telemetry ?? config.telemetry,
workspaceMetadata: config.workspaceMetadata,
});
const teamModelConfigProvider = normalized.enableAgentTeams
? createLocalTeamModelConfigProvider({
manager: new ProviderSettingsManager(),
fallbackConfigProvider: delegatedAgentConfigProvider,
})
: undefined;
if (!this.teamRuntimeEntries.has(registryKey)) {
this.teamRuntimeEntries.set(registryKey, {
delegatedAgentConfigProvider,
@@ -456,7 +464,10 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
const spec: TeamTeammateSpec = {
agentId: event.agentId,
rolePrompt: event.teammate.rolePrompt,
providerId: event.teammate.providerId,
modelId: event.teammate.modelId,
thinking: event.teammate.thinking,
reasoningEffort: event.teammate.reasoningEffort,
maxIterations: event.teammate.maxIterations,
};
teammateSpecs.set(spec.agentId, spec);
@@ -496,6 +507,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
restoredTeammates: restoredTeammateSpecs,
includeLeadSpawnTool: true,
includeLeadManagementTools: true,
modelConfigProvider: teamModelConfigProvider,
onLeadToolsUnlocked: (teamTools) => {
pendingLeadTeamTools = teamTools;
leadAgentInstance?.addTools(teamTools);
+33
View File
@@ -6,6 +6,8 @@
import { z } from "zod";
const TeamReasoningEffortSchema = z.enum(["low", "medium", "high", "xhigh"]);
export const DEFAULT_OUTCOME_REQUIRED_SECTIONS = [
"current_state",
"boundary_analysis",
@@ -48,7 +50,10 @@ const TeamMemberSnapshotSchema = z.object({
export const TeamTeammateSpecSchema = z.object({
agentId: z.string(),
rolePrompt: z.string(),
providerId: z.string().optional(),
modelId: z.string().optional(),
thinking: z.boolean().optional(),
reasoningEffort: TeamReasoningEffortSchema.optional(),
maxIterations: z.number().optional(),
});
@@ -66,9 +71,36 @@ export const TeamSpawnTeammateInputSchema = z
.string()
.min(1)
.describe("System prompt describing teammate role"),
providerId: nullableOptional(z.string().min(1)).describe(
"Optional provider ID for this teammate; defaults to the lead provider",
),
modelId: nullableOptional(z.string().min(1)).describe(
"Optional model ID for this teammate; defaults to the lead model",
),
thinking: nullableOptional(z.boolean()).describe(
"Optional reasoning/thinking toggle for capable models",
),
reasoningEffort: nullableOptional(TeamReasoningEffortSchema).describe(
"Optional reasoning effort for capable models",
),
maxIterations: nullableOptional(z.number().positive()).describe(
"Optional max loop iterations for this teammate",
),
})
.strict();
export const TeamListModelsInputSchema = z.object({
providerId: nullableOptional(z.string().min(1)).describe(
"Optional provider ID to filter models; omit to list all known providers",
),
includeDisabledProviders: nullableOptional(z.boolean()).describe(
"Include providers without saved settings; defaults to true",
),
refresh: nullableOptional(z.boolean()).describe(
"Try refreshing model data from provider source before listing; defaults to false",
),
});
export const TeamShutdownTeammateInputSchema = z.object({
agentId: z.string().min(1).describe("Teammate identifier"),
reason: nullableOptional(z.string().min(1)).describe(
@@ -237,6 +269,7 @@ export const TeamListOutcomesInputSchema = z.object({});
export type TeamSpawnTeammateInput = z.infer<
typeof TeamSpawnTeammateInputSchema
>;
export type TeamListModelsInput = z.infer<typeof TeamListModelsInputSchema>;
export type TeamShutdownTeammateInput = z.infer<
typeof TeamShutdownTeammateInputSchema
>;
+3
View File
@@ -71,7 +71,10 @@ export interface TeamMemberSnapshot {
export interface TeammateLifecycleSpec {
rolePrompt: string;
providerId?: string;
modelId?: string;
thinking?: boolean;
reasoningEffort?: "low" | "medium" | "high" | "xhigh";
maxIterations?: number;
runtimeAgentId?: string;
conversationId?: string;