diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts index 87e48f573a..50011159d8 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts @@ -12,8 +12,11 @@ * `ISessionAgentProfileCatalog`, and the caller's `IAgentProfileService`) and * threads it through the swarm tasks; otherwise binding is left to the * service, which keeps its own "no model bound" check and inherit-caller - * fallback. Swarm mode is entered through `IAgentSwarmService`; the caller's - * agent id comes from `IAgentScopeContext`. Pure tool — owns no scoped state. + * fallback. The advertised `model` parameter lists the secondary/primary + * pair via `buildSubagentModelDescriptions`, suffixing each line with the + * entry's capability flags resolved through `IModelCatalog`. Swarm mode is + * entered through `IAgentSwarmService`; the caller's agent id comes from + * `IAgentScopeContext`. Pure tool — owns no scoped state. * * Registered via the module-level `registerAgentToolService(IAgentSwarmTool, * AgentSwarmTool)` at the bottom of this file — the same "import = register" @@ -31,6 +34,7 @@ import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution' import { toInputJsonSchema } from '#/tool/input-schema'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; +import { IModelCatalog } from '#/kosong/model/catalog'; import { ISessionSwarmService, type SessionSwarmTask } from '#/session/swarm/sessionSwarm'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { IAgentProfileService } from '#/agent/profile/profile'; @@ -112,6 +116,7 @@ export class AgentSwarmTool implements IAgentSwarmTool { @IFlagService private readonly flags: IFlagService, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @IAgentProfileService private readonly profile: IAgentProfileService, + @IModelCatalog private readonly modelCatalog: IModelCatalog, ) { this.callerAgentId = scopeContext.agentId; } @@ -121,6 +126,7 @@ export class AgentSwarmTool implements IAgentSwarmTool { this.config, this.flags, this.profile.data().modelAlias, + this.modelCatalog, ); return modelLines === undefined ? AGENT_SWARM_DESCRIPTION diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 64d8aa9dde..e617d3a38d 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -175,6 +175,7 @@ export class SubagentTool implements ISubagentTool { this.config, this.flags, this.profile.data().modelAlias, + this.modelCatalog, ); if (modelLines !== undefined) { description += `\n\n${modelLines}`; diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index 9688cc14c7..89ae21f9af 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -23,7 +23,10 @@ * naturally (global thinking config → the bound model's default effort) * rather than inheriting the caller's level. Both tools resolve spawn * bindings through `resolveSubagentBinding`, advertise the pair via - * `buildSubagentModelDescriptions`, and wrap spawn failures with + * `buildSubagentModelDescriptions` (each line suffixed with the entry's + * resolved capability flags, so the parent can route multimodal or + * thinking-heavy subagent tasks instead of guessing from the model id), + * and wrap spawn failures with * `wrapSubagentModelError`; while the experiment is off they also strip the * no-op `model` parameter from their advertised schemas via * `stripSubagentModelParameter`. Self-registered at module load via @@ -52,6 +55,8 @@ import { type IConfigService, } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; +import type { ModelCapability } from '#/kosong/contract/capability'; +import type { IModelCatalog } from '#/kosong/model/catalog'; import { SECONDARY_MODEL_FLAG_ID } from './flag'; @@ -127,16 +132,46 @@ export function buildSubagentModelDescriptions( config: IConfigService, flags: IFlagService, callerModelAlias: string | undefined, + modelCatalog: IModelCatalog, ): string | undefined { - const secondaryModel = resolveSecondaryModel(config, flags)?.model; + const secondary = resolveSecondaryModel(config, flags); + const secondaryModel = secondary?.model; if (secondaryModel === undefined || callerModelAlias === undefined) return undefined; + const boundSecondary = + secondaryModelPatch(secondary) === undefined ? secondaryModel : SECONDARY_DERIVED_MODEL_ID; return [ 'Available models (pass via model):', - `- secondary: ${secondaryModel} (default) — the configured secondary model; prefer it for routine subagent tasks`, - `- primary: ${callerModelAlias} — the main model you are running on; use it for hard, quality-sensitive subagent tasks`, + `- secondary: ${secondaryModel} (default) — the configured secondary model; prefer it for routine subagent tasks${capabilitiesSuffix(resolvedCapabilities(modelCatalog, boundSecondary))}`, + `- primary: ${callerModelAlias} — the main model you are running on; use it for hard, quality-sensitive subagent tasks${capabilitiesSuffix(resolvedCapabilities(modelCatalog, callerModelAlias))}`, ].join('\n'); } +const ADVERTISED_CAPABILITY_FLAGS = [ + 'image_in', + 'video_in', + 'audio_in', + 'thinking', + 'tool_use', + 'dynamically_loaded_tools', +] as const satisfies readonly (keyof ModelCapability)[]; + +function capabilitiesSuffix(capability: ModelCapability | undefined): string { + if (capability === undefined) return ''; + const names = ADVERTISED_CAPABILITY_FLAGS.filter((flag) => capability[flag] === true); + return `; capabilities: ${names.length === 0 ? 'none' : names.join(', ')}`; +} + +function resolvedCapabilities( + modelCatalog: IModelCatalog, + model: string, +): ModelCapability | undefined { + try { + return modelCatalog.get(model).capabilities; + } catch { + return undefined; + } +} + /** * Strip the `model` property from a subagent collaboration tool's advertised * JSON schema. While the `secondary-model` experiment is off the parameter is diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts index 3c209b4ea2..a3638aab28 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts @@ -23,6 +23,8 @@ import type { ResolvedToolExecutionHookContext, } from '#/agent/toolExecutor/toolHooks'; import type { ToolCall } from '#/kosong/contract/message'; +import type { ModelCapability } from '#/kosong/contract/capability'; +import { IModelCatalog } from '#/kosong/model/catalog'; import type { ExecutableToolContext } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; @@ -146,6 +148,19 @@ function stubCallerProfile( } as unknown as IAgentProfileService; } +function stubModelCatalog( + capabilities: Readonly> = {}, +): IModelCatalog { + return { + _serviceBrand: undefined, + get: (id: string) => { + const capability = capabilities[id]; + if (capability === undefined) throw new Error(`Model "${id}" is not configured.`); + return { capabilities: capability }; + }, + } as unknown as IModelCatalog; +} + describe('AgentSwarmService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; @@ -342,7 +357,7 @@ describe('AgentSwarmTool', () => { ]), }); const swarmMode = mockSwarmMode(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), swarmMode, stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), swarmMode, stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); const input = { description: 'Review files', prompt_template: 'Review {{item}}', @@ -439,7 +454,7 @@ describe('AgentSwarmTool', () => { it('does not expose permission rule argument matching', () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); const execution = tool.resolveExecution({ description: 'Review files', prompt_template: 'Review {{item}}', @@ -454,7 +469,7 @@ describe('AgentSwarmTool', () => { it('description states the enforced input requirements', () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); expect(tool.description).toContain('at least 2'); expect(tool.description).toContain('{{item}}'); expect(tool.description.toLowerCase()).toContain('distinct'); @@ -476,6 +491,7 @@ describe('AgentSwarmTool', () => { stubFlag(true), stubSwarmCatalog(caller), stubCallerProfile({ profileName: 'deleted-profile', subagents: ['explore'] }), + stubModelCatalog(), ); const result = await executeTool( @@ -539,7 +555,7 @@ describe('AgentSwarmTool', () => { for (const testCase of cases) { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); const result = await executeTool(tool, context(testCase.input)); @@ -572,7 +588,7 @@ describe('AgentSwarmTool', () => { async ({ agentId }: { readonly agentId: string }) => persistedItems[agentId], ); const host = mockSwarmHost({ run, getSwarmItem }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); const input = { description: 'Finish review', subagent_type: 'explore', @@ -692,7 +708,7 @@ describe('AgentSwarmTool', () => { ); const getSwarmItem = vi.fn(async () => 'src/old-a.ts'); const host = mockSwarmHost({ run, getSwarmItem }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); const input = { description: 'Resume review', resume_agent_ids: { @@ -755,7 +771,7 @@ describe('AgentSwarmTool', () => { }, ]), }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); const result = await executeTool( tool, @@ -781,7 +797,7 @@ describe('AgentSwarmTool', () => { it('passes the configured subagent timeout to swarm tasks', async () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ timeoutMs: 5_000 }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ timeoutMs: 5_000 }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); await executeTool( tool, @@ -804,7 +820,7 @@ describe('AgentSwarmTool', () => { it('resolves spawn task bindings from the configured secondary model', async () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ model: 'provider/secondary', defaultEffort: 'low' }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' })); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ model: 'provider/secondary', defaultEffort: 'low' }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' }), stubModelCatalog()); await executeTool( tool, @@ -833,7 +849,7 @@ describe('AgentSwarmTool', () => { modelPreference: 'secondary', systemPrompt: () => 'coder', }; - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ model: 'provider/secondary', defaultEffort: 'low' }), stubFlag(true), stubSwarmCatalog(DEFAULT_CALLER_PROFILE, [secondaryCoder]), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' })); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ model: 'provider/secondary', defaultEffort: 'low' }), stubFlag(true), stubSwarmCatalog(DEFAULT_CALLER_PROFILE, [secondaryCoder]), stubCallerProfile({ modelAlias: 'main-model', thinkingLevel: 'high' }), stubModelCatalog()); await executeTool( tool, @@ -857,17 +873,46 @@ describe('AgentSwarmTool', () => { it('advertises both selectable models in the description only when configured', async () => { const host = mockSwarmHost(); - const configured = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ model: 'provider/secondary' }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model' })); + const configured = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ model: 'provider/secondary' }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model' }), stubModelCatalog({ + 'provider/secondary': { image_in: true, video_in: false, audio_in: false, thinking: true, tool_use: true, max_context_tokens: 262_144 }, + 'main-model': { image_in: false, video_in: false, audio_in: false, thinking: false, tool_use: true, max_context_tokens: 262_144 }, + })); expect(configured.description).toContain('Available models (pass via model):'); - expect(configured.description).toContain('- secondary: provider/secondary (default)'); - expect(configured.description).toContain('- primary: main-model'); + expect(configured.description).toContain( + '- secondary: provider/secondary (default) — the configured secondary model; prefer it for routine subagent tasks; capabilities: image_in, thinking, tool_use', + ); + expect(configured.description).toContain( + '- primary: main-model — the main model you are running on; use it for hard, quality-sensitive subagent tasks; capabilities: tool_use', + ); - const unconfigured = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model' })); + const unconfigured = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model' }), stubModelCatalog()); expect(unconfigured.description).not.toContain('Available models'); }); + it('reads secondary capabilities from the derived entry when the recipe carries patch fields', async () => { + const host = mockSwarmHost(); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ model: 'provider/secondary', defaultEffort: 'low' }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model' }), stubModelCatalog({ + [SECONDARY_DERIVED_MODEL_ID]: { image_in: false, video_in: false, audio_in: false, thinking: true, tool_use: true, max_context_tokens: 131_072 }, + 'main-model': { image_in: true, video_in: false, audio_in: false, thinking: false, tool_use: true, max_context_tokens: 262_144 }, + })); + + expect(tool.description).toContain( + '- secondary: provider/secondary (default) — the configured secondary model; prefer it for routine subagent tasks; capabilities: thinking, tool_use', + ); + expect(tool.description).toContain('capabilities: image_in, tool_use'); + }); + + it('omits the capabilities suffix for models the catalog cannot resolve', async () => { + const host = mockSwarmHost(); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig({ model: 'provider/secondary' }), stubFlag(true), stubSwarmCatalog(), stubCallerProfile({ modelAlias: 'main-model' }), stubModelCatalog()); + + expect(tool.description).toContain('- secondary: provider/secondary (default)'); + expect(tool.description).toContain('- primary: main-model'); + expect(tool.description).not.toContain('capabilities:'); + }); + it('omits resume hint when incomplete subagents have no agent ids', async () => { const host = mockSwarmHost({ run: vi.fn().mockImplementation(async ({ tasks }) => [ @@ -883,7 +928,7 @@ describe('AgentSwarmTool', () => { }, ]), }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); const result = await executeTool( tool, @@ -930,7 +975,7 @@ describe('AgentSwarmTool', () => { }, ]), }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubFlag(true), stubSwarmCatalog(), stubCallerProfile(), stubModelCatalog()); const result = await executeTool( tool, diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 2dc357637f..2e52f0c610 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -746,6 +746,31 @@ describe('Agent tool description', () => { expect(description).toContain('- primary: mock-model'); }); + it('advertises the resolved capability flags for each selectable model', () => { + ctx = createTestAgent(secondaryModelFlags(), { + initialConfig: { + secondaryModel: { model: 'secondary-model' }, + models: { + 'secondary-model': { + provider: 'test-provider', + model: 'secondary-model', + maxContextSize: 262_144, + capabilities: ['image_in', 'thinking'], + }, + }, + }, + }); + + const description = agentDescription(); + + expect(description).toContain( + '- secondary: secondary-model (default) — the configured secondary model; prefer it for routine subagent tasks; capabilities: image_in, thinking', + ); + expect(description).toContain( + '- primary: mock-model — the main model you are running on; use it for hard, quality-sensitive subagent tasks; capabilities: none', + ); + }); + it('omits the models section when configured but the experiment is disabled', () => { ctx = createTestAgent(secondaryModelFlags(false), { initialConfig: { secondaryModel: { model: 'provider/secondary' } },