Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/subagent-user-tools.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/agent-core/src/agent/tool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drop inherited tools when the parent disables them

Because inheritance registers a separate active copy on the child, a subagent keeps this custom tool even after the parent later calls unregisterTool or setActiveTools to remove it. In sessions where a subagent is resumed or still running in the background after the parent disables a desktop-provided tool, the LLM can still see and call the stale child copy, so tool availability no longer matches the parent’s current active tools. Please keep inherited user tools synchronized or avoid persisting an independent active registration on the child.

Useful? React with 👍 / 👎.

name: tool.name,
description: tool.description,
parameters: tool.parameters,
});
}
}

registerMcpServer(
serverName: string,
client: MCPClient,
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/session/subagent-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export class SessionSubagentHost {

const context = await prepareSystemPromptContext(child.kaos);
child.useProfile(profile, context);
child.tools.inheritUserTools(parent.tools);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve read-only subagent tool restrictions

When the parent has an active side-effecting custom tool, this unconditional inheritance gives it to every subagent profile after child.useProfile(profile) has applied that profile's restricted tool list. That means explore and plan subagents, whose profiles intentionally omit write-capable tools and rely on read-only behavior, can still call parent user tools that mutate files or external state. Please filter inherited user tools by the child profile's allowed tools/capabilities or otherwise avoid adding arbitrary parent custom tools to read-only subagents.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply inherited custom tools when resuming subagents

This inheritance is only invoked from configureChild, which is used for new spawn calls, but resume() prepares an existing child by updating only its model. If the parent registers a desktop custom tool after the child was created, or the user resumes a pre-fix subagent from disk, that resumed subagent still won't see the parent's active custom tools even though newly spawned subagents do. Please run the same inheritance/realignment step on resume as well.

Useful? React with 👍 / 👎.

}

private async triggerSubagentStart(
Expand Down
72 changes: 72 additions & 0 deletions packages/agent-core/test/session/subagent-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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[];
Expand Down
Loading