diff --git a/.changeset/subagent-user-tools.md b/.changeset/subagent-user-tools.md new file mode 100644 index 0000000000..510d7f8b83 --- /dev/null +++ b/.changeset/subagent-user-tools.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Allow subagents to use custom tools registered on their parent agent. diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index f95de83767..1239177dfe 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -121,6 +121,17 @@ export class ToolManager { this.enabledTools.delete(name); } + inheritUserTools(parent: ToolManager): void { + for (const tool of parent.userTools.values()) { + if (!parent.enabledTools.has(tool.name)) continue; + this.registerUserTool({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }); + } + } + registerMcpServer( serverName: string, client: MCPClient, diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 458e291e23..cabc8e101a 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -287,6 +287,7 @@ export class SessionSubagentHost { const context = await prepareSystemPromptContext(child.kaos); child.useProfile(profile, context); + child.tools.inheritUserTools(parent.tools); } private async triggerSubagentStart( diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 558af30926..2098dc7edc 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -16,6 +16,7 @@ import { SessionSubagentHost } from '../../src/session/subagent-host'; import { abortError, userCancellationReason } from '../../src/utils/abort'; import { testAgent, type AgentTestContext } from '../agent/harness/agent'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; +import { executeTool } from '../tools/fixtures/execute-tool'; // Git context collection is exercised in git-context.test.ts; here it is // mocked so subagent-host tests stay deterministic and assert only the @@ -223,6 +224,62 @@ describe('SessionSubagentHost', () => { ]); }); + it('inherits active parent user tools when spawning a subagent', async () => { + const parent = testAgent(); + parent.configure(); + await parent.rpc.registerTool(lookupToolRegistration()); + parent.newEvents(); + + const summary = + 'Investigated the delegated task thoroughly, used the inherited custom lookup surface where appropriate, and returned a detailed summary that lets the parent agent continue without repeating the work. '.repeat( + 2, + ); + const child = testAgent(); + child.mockNextResponse({ + type: 'text', + text: summary, + }); + const session = fakeSession(parent.agent, child.agent); + const host = new SessionSubagentHost(session, 'main'); + + const handle = await host.spawn('coder', { + parentToolCallId: 'call_agent', + prompt: 'Use the available lookup tool', + description: 'Use lookup', + runInBackground: false, + signal, + }); + + await expect(handle.completion).resolves.toMatchObject({ + result: summary.trim(), + }); + expect(child.llmCalls[0]?.tools.map((tool) => tool.name)).toContain('Lookup'); + expect(child.agent.tools.data()).toContainEqual({ + name: 'Lookup', + description: 'Look up a short test value.', + active: true, + source: 'user', + }); + + const lookupTool = child.agent.tools.loopTools.find((tool) => tool.name === 'Lookup'); + expect(lookupTool).toBeDefined(); + + const execution = executeTool(lookupTool!, { + turnId: '0', + toolCallId: 'call_lookup', + args: { query: 'moon' }, + signal, + }); + const routedTo = await Promise.race([ + child.untilToolCall({ output: 'moon-result' }).then(() => 'child'), + parent.untilToolCall({ output: 'moon-result' }).then(() => 'parent'), + new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 50)), + ]); + + expect(routedTo).toBe('child'); + await expect(execution).resolves.toMatchObject({ output: 'moon-result' }); + }); + it('falls back to bundled subagent profiles when the parent profile is missing', async () => { const parent = testAgent(); parent.configure(); @@ -1084,6 +1141,21 @@ function contextProfile(): ResolvedAgentProfile { }; } +function lookupToolRegistration() { + return { + name: 'Lookup', + description: 'Look up a short test value.', + parameters: { + type: 'object', + properties: { + query: { type: 'string' }, + }, + required: ['query'], + additionalProperties: false, + }, + }; +} + function profile(input: { readonly name: string; readonly tools: readonly string[];