From ba7a980edd7fbec96c99023f1d99dad9fcb13b60 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 16 Jul 2026 16:36:33 +0800 Subject: [PATCH 1/4] feat(agent-core): align coder subagent tools with v2 and drain background tasks - align the bundled coder subagent profile tools with agent-core-v2 CODER_TOOLS (Skill, Agent, AgentSwarm, Task* trio, plan-mode tools, TodoList); cron tools stay declared-but-undelivered on sub agents, matching v2 - rebuild builtin tools in setActiveTools: profile-gated capabilities (Bash/Agent allowBackground via the Task* trio) were baked at construction with an empty enabled set, so profiles applied later (every subagent) silently lost them - hold subagent completion until the child agent's background tasks settle (print-mode drain semantics) and suppress their terminal notifications, so no unobserved follow-up turn runs on a finished subagent; the run's timeout/cancel signal bounds the drain - cover with real-Session e2e (tool execution, nested Agent/AgentSwarm, drain blocking and cancel) and update profile/subagent-host tests --- packages/agent-core/src/agent/tool/index.ts | 9 + .../agent-core/src/profile/default/coder.yaml | 20 +- .../agent-core/src/session/subagent-host.ts | 29 ++ .../test/harness/coder-subagent-tools.test.ts | 443 ++++++++++++++++++ .../test/profile/agent-profile-loader.test.ts | 27 +- .../profile/default-agent-profiles.test.ts | 20 +- .../test/session/subagent-host.test.ts | 9 + 7 files changed, 542 insertions(+), 15 deletions(-) create mode 100644 packages/agent-core/test/harness/coder-subagent-tools.test.ts diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index bf8a54a590..13a26eeba5 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -521,6 +521,15 @@ export class ToolManager { // builtin/user tool names. The split keeps every caller on one string[]. this.enabledTools = new Set(names.filter((name) => !isMcpToolName(name))); this.mcpAccessPatterns = names.filter((name) => isMcpToolName(name)); + // Builtin construction reads the enabled set (Bash/Agent bake + // `allowBackground` from the Task* trio), and the constructor may already + // have built the map while the enabled set was still empty. The lazy + // re-init in `get tools()` only fires on an empty map, so rebuild here — + // otherwise a profile applied after construction (every subagent) keeps + // the construction-time capabilities. + if (this.agent.config.hasProvider) { + this.initializeBuiltinTools(); + } } copyLoopToolsFrom(source: ToolManager): void { diff --git a/packages/agent-core/src/profile/default/coder.yaml b/packages/agent-core/src/profile/default/coder.yaml index 90584f3110..b439ce8ec8 100644 --- a/packages/agent-core/src/profile/default/coder.yaml +++ b/packages/agent-core/src/profile/default/coder.yaml @@ -8,13 +8,25 @@ promptVars: whenToUse: | Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent. tools: + - Agent + - AgentSwarm - Bash - - Read - - ReadMediaFile + - CronCreate + - CronDelete + - CronList + - Edit + - EnterPlanMode + - ExitPlanMode - Glob - Grep - - Write - - Edit + - Read + - ReadMediaFile + - Skill + - TaskList + - TaskOutput + - TaskStop + - TodoList - WebSearch - FetchURL + - Write - mcp__* diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index a014c248a2..c77dd060aa 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -369,6 +369,7 @@ export class SessionSubagentHost { options: RunSubagentOptions, ): Promise { await runChildTurnToCompletion(child, options.signal); + await this.drainChildBackgroundTasks(child, options.signal); // A subagent that returns an overly terse summary leaves the parent // agent under-informed. Give it a bounded number of chances to expand @@ -416,6 +417,34 @@ export class SessionSubagentHost { child.tools.inheritUserTools(parent.tools); } + /** + * Hold the run open until the child agent's background tasks (background + * Bash, nested background agents) settle — the print-mode (`kimi -p`) + * drain semantics applied to subagent completion. Drained tasks get their + * terminal notifications suppressed: without that, a task outliving the + * child's final turn steers a fresh turn on the finished subagent + * (`steer` degrades to `launch`), which runs unobserved and whose output + * never reaches the parent. Bounded by the run's signal — the Agent + * tool's per-run timeout / user-cancel envelope covers the drain too. + */ + private async drainChildBackgroundTasks(child: Agent, signal: AbortSignal): Promise { + for (;;) { + signal.throwIfAborted(); + for (const task of child.background.list(true)) { + await child.background.suppressTerminalNotification(task.taskId); + } + await child.background.waitForActiveTasks(() => true, { signal }); + // A terminal effect that slipped past the suppression race may have + // steered a follow-up turn onto the child; let it finish (it can fan + // out new tasks) before declaring the child drained. + if (child.turn.hasActiveTurn) { + await runChildTurnToCompletion(child, signal); + continue; + } + if (child.background.list(true).length === 0) return; + } + } + private async triggerSubagentStart( parent: Agent, profileName: string, diff --git a/packages/agent-core/test/harness/coder-subagent-tools.test.ts b/packages/agent-core/test/harness/coder-subagent-tools.test.ts new file mode 100644 index 0000000000..957c66999a --- /dev/null +++ b/packages/agent-core/test/harness/coder-subagent-tools.test.ts @@ -0,0 +1,443 @@ +/** + * Real-Session e2e for the bundled `coder` subagent profile (aligned with + * agent-core-v2's CODER_TOOLS). Proves the tools newly added to the profile + * don't just pass the profile allowlist but actually execute inside a real + * subagent: Skill / TodoList / background Bash + the Task* trio / plan mode + * enter+write+exit / a nested Agent (explore) call / a nested AgentSwarm batch. + * Also pins the declared-but-not-delivered contract for cron tools on sub + * agents (same as v2). + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { setTimeout as delay } from 'node:timers/promises'; +import { join } from 'pathe'; + +import { + isContentPart, + isToolCall, + type FinishReason, + type Message, + type ProviderConfig, + type StreamedMessagePart, +} from '@moonshot-ai/kosong'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { Agent, AgentOptions } from '../../src/agent'; +import { isBackgroundTaskTerminal } from '../../src/agent/background'; +import type { ResolvedAgentProfile } from '../../src/profile'; +import type { SDKSessionRPC } from '../../src/rpc'; +import { Session } from '../../src/session'; +import { ProviderManager } from '../../src/session/provider-manager'; +import { createScriptedGenerate } from '../agent/harness/scripted-generate'; +import { testKaos } from '../fixtures/test-kaos'; + +const MOCK_PROVIDER = { type: 'kimi', apiKey: 'test-key', model: 'mock-model' } as const satisfies ProviderConfig; + +const tempDirs: string[] = []; +const openSessions: Session[] = []; + +afterEach(async () => { + await Promise.allSettled(openSessions.splice(0).map((s) => s.close())); + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +type GenerateFn = NonNullable; + +interface StepRecord { + readonly label: string; + readonly wireTools: readonly string[]; + readonly prevToolOutput: string | null; + readonly prevToolIsError: boolean; +} + +function toolText(message: Message | undefined): string | null { + if (message === undefined) return null; + const parts = message.content; + return parts + .map((part) => ('text' in part && typeof part.text === 'string' ? part.text : JSON.stringify(part))) + .join(''); +} + +function lastToolMessage(history: readonly Message[]): Message | undefined { + for (let i = history.length - 1; i >= 0; i--) { + const message = history[i]!; + if (message.role === 'tool') return message; + } + return undefined; +} + +function callTool(name: string, args: unknown): StreamedMessagePart[] { + return [{ type: 'function', id: `call_${name}`, name, arguments: JSON.stringify(args) }]; +} + +const FINAL_TEXT = + 'E2E-CODER-DONE: exercised Skill, TodoList, background Bash with the TaskList/TaskOutput/TaskStop ' + + 'trio, plan mode enter/write-plan/exit, and a nested explore Agent call. Every newly aligned tool ' + + 'executed successfully on a real Session with a real home directory, skill registry, background ' + + 'manager, and subagent host wiring. No tool call returned an error.'; + +function createVerifyGenerate(steps: StepRecord[]): GenerateFn { + let taskId = ''; + let planPath = ''; + + const generate: GenerateFn = async (_chat, systemPrompt, tools, history, callbacks, options) => { + options?.signal?.throwIfAborted(); + options?.onRequestStart?.(); + + const wireTools = tools.filter((tool) => tool.deferred !== true).map((tool) => tool.name); + const firstUserText = toolText(history.find((message) => message.role === 'user')) ?? ''; + const isExploreChild = firstUserText.includes('Probe nested subagent support'); + const isSwarmChild = firstUserText.includes('SWARM-PROBE'); + + let parts: StreamedMessagePart[]; + if (isExploreChild) { + parts = [ + { + type: 'text', + text: 'NESTED-EXPLORE-OK: the nested explore subagent ran and reported back to the coder.', + }, + ]; + } else if (isSwarmChild) { + // Long enough (>200 chars) to skip the subagent summary-continuation retry, + // keeping the scripted call count deterministic across the swarm batch. + parts = [ + { + type: 'text', + text: + 'SWARM-CHILD-OK: this swarm child subagent ran to completion inside the coder subagent, ' + + 'executed its assigned slice of the batch, and is reporting the outcome back to its ' + + 'parent so the swarm results can be aggregated.', + }, + ]; + } else { + const last = lastToolMessage(history); + const prevToolOutput = toolText(last); + const prevToolIsError = (last as { isError?: boolean } | undefined)?.isError === true; + const step = history.filter((message) => message.role === 'assistant').length + 1; + steps.push({ label: `step-${String(step)}`, wireTools, prevToolOutput, prevToolIsError }); + + switch (step) { + case 1: + parts = callTool('Skill', { skill: 'demo-skill', args: 'e2e' }); + break; + case 2: + parts = callTool('TodoList', { todos: [{ title: 'verify-tools-e2e', status: 'in_progress' }] }); + break; + case 3: + parts = callTool('Bash', { command: 'sleep 15', description: 'e2e bg sleep', run_in_background: true }); + break; + case 4: { + const match = /task_id: (\S+)/.exec(prevToolOutput ?? ''); + taskId = match?.[1] ?? ''; + parts = callTool('TaskList', {}); + break; + } + case 5: + parts = callTool('TaskOutput', { task_id: taskId, block: false }); + break; + case 6: + parts = callTool('TaskStop', { task_id: taskId }); + break; + case 7: + parts = callTool('EnterPlanMode', {}); + break; + case 8: { + const match = /Plan file: (\S+)/.exec(prevToolOutput ?? ''); + planPath = match?.[1] ?? ''; + parts = callTool('Write', { path: planPath, content: '# e2e plan\n\nDo the thing.\n' }); + break; + } + case 9: + parts = callTool('ExitPlanMode', {}); + break; + case 10: + parts = callTool('Agent', { + description: 'nested explore check', + prompt: 'Probe nested subagent support: confirm you can run and report back.', + subagent_type: 'explore', + }); + break; + case 11: + parts = callTool('AgentSwarm', { + description: 'swarm probe', + prompt_template: 'SWARM-PROBE {{item}}', + items: ['alpha', 'beta'], + subagent_type: 'explore', + }); + break; + default: + parts = [{ type: 'text', text: FINAL_TEXT }]; + break; + } + } + + for (const part of parts) { + await callbacks?.onMessagePart?.(structuredClone(part)); + options?.signal?.throwIfAborted(); + } + options?.onStreamEnd?.(); + + const content = parts.filter((part) => isContentPart(part)); + const toolCalls = parts.filter((part) => isToolCall(part)); + const message: Message = { + role: 'assistant', + content: structuredClone(content), + toolCalls: structuredClone(toolCalls), + }; + const finishReason: FinishReason = toolCalls.length > 0 ? 'tool_calls' : 'completed'; + options?.onTraceId?.(null); + return { + id: 'mock-generate', + message, + usage: { inputOther: 1, output: 1, inputCacheRead: 0, inputCacheCreation: 0 }, + finishReason, + rawFinishReason: finishReason === 'completed' ? 'stop' : finishReason, + traceId: null, + }; + }; + return generate; +} + +const DRAIN_FINAL_TEXT = + 'DRAIN-CHECK-DONE: started a background sleep with Bash and finished my main work. ' + + 'The subagent run must not complete until that background task settles — this final ' + + 'message should reach the parent only after the background task has terminated, and ' + + 'no orphan notification turn may run on me afterwards.'; + +async function createCoderSession( + generate: AgentOptions['generate'], +): Promise<{ session: Session; mainAgent: Agent }> { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-coder-tools-e2e-')); + tempDirs.push(sessionDir); + const rpc: SDKSessionRPC = { + emitEvent: vi.fn(async () => {}), + requestApproval: vi.fn(async () => ({ decision: 'approved', selectedLabel: 'approve' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '', isError: true })), + } as unknown as SDKSessionRPC; + + const session = new Session({ + id: 'coder-tools-drain-e2e', + kaos: testKaos.withCwd(sessionDir), + homedir: sessionDir, + rpc, + skills: { explicitDirs: [join(sessionDir, 'no-such-skills-dir')] }, + providerManager: new ProviderManager({ + config: { + providers: { test: { type: MOCK_PROVIDER.type, apiKey: MOCK_PROVIDER.apiKey } }, + models: { + [MOCK_PROVIDER.model]: { provider: 'test', model: MOCK_PROVIDER.model, maxContextSize: 1_000_000 }, + }, + }, + }), + }); + openSessions.push(session); + + const mainProfile: ResolvedAgentProfile = { + name: 'agent', + systemPrompt: () => '', + tools: [], + }; + const { agent: mainAgent } = await session.createAgent( + { type: 'main', generate }, + { profile: mainProfile }, + ); + mainAgent.config.update({ modelAlias: MOCK_PROVIDER.model, thinkingEffort: 'off' }); + mainAgent.permission.setMode('auto'); + return { session, mainAgent }; +} + +describe('coder subagent aligned tools (real Session e2e)', () => { + it('runs every newly aligned tool to success inside a real coder subagent', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-coder-tools-e2e-')); + tempDirs.push(sessionDir); + + const rpc: SDKSessionRPC = { + emitEvent: vi.fn(async () => {}), + requestApproval: vi.fn(async () => ({ decision: 'approved', selectedLabel: 'approve' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '', isError: true })), + } as unknown as SDKSessionRPC; + + const session = new Session({ + id: 'coder-tools-e2e', + kaos: testKaos.withCwd(sessionDir), + homedir: sessionDir, + rpc, + skills: { explicitDirs: [join(sessionDir, 'no-such-skills-dir')] }, + providerManager: new ProviderManager({ + config: { + providers: { test: { type: MOCK_PROVIDER.type, apiKey: MOCK_PROVIDER.apiKey } }, + models: { + [MOCK_PROVIDER.model]: { provider: 'test', model: MOCK_PROVIDER.model, maxContextSize: 1_000_000 }, + }, + }, + }), + }); + openSessions.push(session); + + // Register an invocable skill BEFORE the child agent initializes its + // builtin tools (the Skill tool only registers when the shared session + // skill registry has model-invocable skills). + session.skills.registerBuiltinSkill({ + name: 'demo-skill', + description: 'Demo skill for e2e', + path: '/skills/demo-skill/SKILL.md', + dir: '/skills/demo-skill', + content: 'Demo skill body for the e2e check.', + metadata: {}, + source: 'builtin', + }); + + const steps: StepRecord[] = []; + const mainProfile: ResolvedAgentProfile = { + name: 'agent', + systemPrompt: () => '', + tools: [], + }; + const { agent: mainAgent } = await session.createAgent( + { type: 'main', generate: createVerifyGenerate(steps) }, + { profile: mainProfile }, + ); + mainAgent.config.update({ modelAlias: MOCK_PROVIDER.model, thinkingEffort: 'off' }); + mainAgent.permission.setMode('auto'); + + const handle = await mainAgent.subagentHost!.spawn({ + profileName: 'coder', + parentToolCallId: 'e2e-agent-call', + prompt: 'Verify the newly aligned coder tools work end to end.', + description: 'verify coder tools', + runInBackground: false, + signal: new AbortController().signal, + }); + + const completion = await handle.completion; + expect(completion.result).toContain('E2E-CODER-DONE'); + + // The child's first request must carry the new tools on the wire. + const firstCallTools = steps[0]?.wireTools ?? []; + for (const expected of [ + 'Agent', + 'AgentSwarm', + 'Bash', + 'Edit', + 'EnterPlanMode', + 'ExitPlanMode', + 'Glob', + 'Grep', + 'Read', + 'Skill', + 'TaskList', + 'TaskOutput', + 'TaskStop', + 'TodoList', + 'Write', + ]) { + expect(firstCallTools, `wire tools should include ${expected}`).toContain(expected); + } + // Cron tools stay declared-but-not-delivered for sub agents (v2 parity). + expect(firstCallTools).not.toContain('CronCreate'); + expect(firstCallTools).not.toContain('CreateGoal'); + + const byStep = (n: number) => steps.find((record) => record.label === `step-${String(n)}`); + const taskId = /task_id: (\S+)/.exec(byStep(4)?.prevToolOutput ?? '')?.[1] ?? ''; + + expect(byStep(2)?.prevToolOutput).toContain('Skill "demo-skill" loaded inline'); + expect(byStep(3)?.prevToolOutput).toContain('verify-tools-e2e'); + expect(byStep(4)?.prevToolOutput).toContain('task_id:'); + expect(taskId).not.toBe(''); + expect(byStep(5)?.prevToolOutput).toContain(taskId); + expect(byStep(6)?.prevToolIsError).toBe(false); + expect(byStep(7)?.prevToolIsError).toBe(false); + expect(byStep(8)?.prevToolOutput).toContain('Plan file:'); + expect(byStep(9)?.prevToolIsError).toBe(false); + expect(byStep(10)?.prevToolOutput).toContain('Exited plan mode'); + expect(byStep(11)?.prevToolOutput).toContain('NESTED-EXPLORE-OK'); + expect(byStep(12)?.prevToolOutput).toContain('SWARM-CHILD-OK'); + + for (const record of steps) { + expect(record.prevToolIsError, `${record.label} saw an error tool result`).toBe(false); + } + }, 30_000); + + it('blocks completion until the child background bash task settles', async () => { + const scripted = createScriptedGenerate(); + scripted.mockNextResponse({ + type: 'function', + id: 'c1', + name: 'Bash', + arguments: JSON.stringify({ command: 'sleep 2', description: 'drain probe', run_in_background: true }), + }); + scripted.mockNextResponse({ type: 'text', text: DRAIN_FINAL_TEXT }); + const { session, mainAgent } = await createCoderSession(scripted.generate); + + const handle = await mainAgent.subagentHost!.spawn({ + profileName: 'coder', + parentToolCallId: 'e2e-drain-call', + prompt: 'Start a short background sleep, then finish without waiting for it.', + description: 'drain check', + runInBackground: false, + signal: new AbortController().signal, + }); + + // The model turn ends almost immediately; completion must still be held + // by the background-task drain until the sleep exits (~2s). + const early = await Promise.race([ + handle.completion.then(() => 'resolved' as const), + delay(1_200).then(() => 'pending' as const), + ]); + expect(early).toBe('pending'); + + const completion = await handle.completion; + expect(completion.result).toContain('DRAIN-CHECK-DONE'); + + const child = await session.ensureAgentResumed(handle.agentId); + // The task settled before completion, and the drain suppressed its + // terminal notification — no orphan turn, no in context. + expect(child.background.list(true)).toHaveLength(0); + const settled = child.background.list(false); + expect(settled.length).toBeGreaterThan(0); + for (const task of settled) { + expect(isBackgroundTaskTerminal(task.status)).toBe(true); + } + expect(child.turn.hasActiveTurn).toBe(false); + expect(JSON.stringify(child.context.history)).not.toContain(' { + const scripted = createScriptedGenerate(); + scripted.mockNextResponse({ + type: 'function', + id: 'c1', + name: 'Bash', + arguments: JSON.stringify({ command: 'sleep 30', description: 'drain cancel probe', run_in_background: true }), + }); + scripted.mockNextResponse({ type: 'text', text: DRAIN_FINAL_TEXT }); + const { mainAgent } = await createCoderSession(scripted.generate); + + const controller = new AbortController(); + const handle = await mainAgent.subagentHost!.spawn({ + profileName: 'coder', + parentToolCallId: 'e2e-drain-cancel-call', + prompt: 'Start a long background sleep, then finish without waiting for it.', + description: 'drain cancel check', + runInBackground: false, + signal: controller.signal, + }); + + // Both scripted LLM calls consumed ⇒ the turn has finished and the run + // is now held open only by the background-task drain. + const deadline = Date.now() + 5_000; + while (scripted.calls.length < 2 && Date.now() < deadline) { + await delay(25); + } + expect(scripted.calls.length).toBe(2); + await delay(300); + controller.abort(); + + await expect(handle.completion).rejects.toThrow(/abort/i); + }, 30_000); +}); diff --git a/packages/agent-core/test/profile/agent-profile-loader.test.ts b/packages/agent-core/test/profile/agent-profile-loader.test.ts index bf744bacd7..c7ef6bdb0e 100644 --- a/packages/agent-core/test/profile/agent-profile-loader.test.ts +++ b/packages/agent-core/test/profile/agent-profile-loader.test.ts @@ -179,9 +179,30 @@ describe('default agent profiles', () => { 'TaskStop', ]), ); - expect(DEFAULT_AGENT_PROFILES['coder']?.tools).toEqual( - expect.arrayContaining(['Read', 'Write', 'Edit', 'Bash']), - ); + expect(DEFAULT_AGENT_PROFILES['coder']?.tools).toEqual([ + 'Agent', + 'AgentSwarm', + 'Bash', + 'CronCreate', + 'CronDelete', + 'CronList', + 'Edit', + 'EnterPlanMode', + 'ExitPlanMode', + 'Glob', + 'Grep', + 'Read', + 'ReadMediaFile', + 'Skill', + 'TaskList', + 'TaskOutput', + 'TaskStop', + 'TodoList', + 'WebSearch', + 'FetchURL', + 'Write', + 'mcp__*', + ]); expect(DEFAULT_AGENT_PROFILES['explore']?.tools).not.toContain('Write'); expect(DEFAULT_AGENT_PROFILES['plan']?.tools).not.toContain('Bash'); }); diff --git a/packages/agent-core/test/profile/default-agent-profiles.test.ts b/packages/agent-core/test/profile/default-agent-profiles.test.ts index 17dc6f6814..09b4d1cea9 100644 --- a/packages/agent-core/test/profile/default-agent-profiles.test.ts +++ b/packages/agent-core/test/profile/default-agent-profiles.test.ts @@ -62,15 +62,19 @@ describe('default agent profiles', () => { ).toThrow(/Embedded agent profile source missing: profile\/default\/missing\.md/); }); - it('omits the Skills section for subagent profiles that lack the Skill tool', () => { - // The root agent has the Skill tool, so the Skills section and listing render. - const agentPrompt = DEFAULT_AGENT_PROFILES['agent']?.systemPrompt(promptContext) ?? ''; - expect(agentPrompt).toContain('# Skills'); - expect(agentPrompt).toContain('- test-skill: does things'); + it('omits the Skills section only for profiles that lack the Skill tool', () => { + // The root agent and coder have the Skill tool, so the Skills section and + // listing render in their prompts. + for (const name of ['agent', 'coder']) { + expect(DEFAULT_AGENT_PROFILES[name]?.tools).toContain('Skill'); + const prompt = DEFAULT_AGENT_PROFILES[name]?.systemPrompt(promptContext) ?? ''; + expect(prompt).toContain('# Skills'); + expect(prompt).toContain('- test-skill: does things'); + } - // Subagents (coder/explore/plan) lack the Skill tool, so neither the section - // heading nor the skill listing should appear in their prompt. - for (const name of ['coder', 'explore', 'plan']) { + // explore/plan lack the Skill tool, so neither the section heading nor the + // skill listing should appear in their prompts. + for (const name of ['explore', 'plan']) { const tools = DEFAULT_AGENT_PROFILES[name]?.tools ?? []; expect(tools).not.toContain('Skill'); const prompt = DEFAULT_AGENT_PROFILES[name]?.systemPrompt(promptContext) ?? ''; diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 5aa36b9f73..778bea661d 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -456,10 +456,19 @@ describe('SessionSubagentHost', () => { expect(child.llmCalls[0]?.systemPrompt).toContain('You are now running as a subagent.'); expect(child.llmCalls[0]?.tools.map((tool) => tool.name).toSorted()).toEqual([ 'Bash', + 'CronCreate', + 'CronDelete', + 'CronList', 'Edit', + 'EnterPlanMode', + 'ExitPlanMode', 'Glob', 'Grep', 'Read', + 'TaskList', + 'TaskOutput', + 'TaskStop', + 'TodoList', 'Write', ]); expect(child.llmCalls[0]?.history).toMatchObject([ From 838b434c97fdf6abcf7a13a989515e1b9432cc2b Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 16 Jul 2026 16:44:27 +0800 Subject: [PATCH 2/4] chore: add changeset for coder subagent tool alignment --- .changeset/coder-subagent-tool-alignment.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/coder-subagent-tool-alignment.md diff --git a/.changeset/coder-subagent-tool-alignment.md b/.changeset/coder-subagent-tool-alignment.md new file mode 100644 index 0000000000..728e17310c --- /dev/null +++ b/.changeset/coder-subagent-tool-alignment.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Expand the coder subagent tool set to include background tasks, todo lists, plan mode, skill invocation, and nested agents, mirroring the main agent's capabilities; a subagent run also waits for its background tasks to settle before reporting completion. Applies automatically to coder subagents launched through the Agent tool. From 0a7541f09d27dc4f8fe0b0ea6f8b8656d83b759b Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 16 Jul 2026 16:51:38 +0800 Subject: [PATCH 3/4] docs(agents): sync sub-agent capabilities with expanded coder tool set - coder sub-agents can now dispatch nested sub-agents and use background tasks, todo lists, Plan mode, and skills; drop the stale claim that sub-agents cannot schedule nested sub-agents - note that a sub-agent run reports completion only after its background tasks settle --- docs/en/customization/agents.md | 4 +++- docs/zh/customization/agents.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 4eb0b753bd..909848a24e 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -12,6 +12,8 @@ Kimi Code CLI includes three built-in sub-agents, ready to use out of the box, e - **`explore`**: Dedicated to codebase exploration; performs read-only operations only and does not modify any files. Ideal for quickly searching, reading, and summarizing a repository without touching files. - **`plan`**: Dedicated to implementation planning and architecture design; even shell commands are not available, keeping the focus on "figuring out how to do something" rather than "actually doing it." +A `coder` sub-agent shares most of the main Agent's tool set: it can run shell commands in the background, maintain todo lists, enter Plan mode, invoke Agent Skills, and dispatch its own nested sub-agents when a task decomposes naturally. If it finishes its turn while background tasks are still running, its run only reports completion after those tasks settle, so the parent receives the result after the underlying work has actually finished. + ## How to Invoke Sub-agents are scheduled automatically by the main Agent — based on task complexity, context consumption, and sub-task independence, they are dispatched at the right moment without the user having to specify one. @@ -29,7 +31,7 @@ This isolation provides two benefits: - **The main Agent's context stays lean** and is not filled with large volumes of exploratory logs during long sessions. - **Multiple sub-agents can run in parallel** without interfering with each other. -Note that each sub-agent independently consumes model tokens. For simple tasks, there is no need to dispatch a sub-agent — the main Agent handles them more economically. Sub-agents also cannot themselves schedule further nested sub-agents. +Note that each sub-agent independently consumes model tokens. For simple tasks, there is no need to dispatch a sub-agent — the main Agent handles them more economically. ## Permission Inheritance diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 5b754ada1b..10d0bb9edb 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -12,6 +12,8 @@ Kimi Code CLI 内置三种子 Agent,开箱即用,分别面向不同任务形 - **`explore`**:代码库探索专用,只做只读操作,不修改任何文件。适合在不改动文件的前提下快速搜索、阅读和总结仓库。 - **`plan`**:实现规划与架构设计专用,连 Shell 命令都不提供,专注于"想清楚怎么做"而不是"动手做"。 +`coder` 子 Agent 与主 Agent 共享大部分工具集:可以在后台执行 Shell 命令、维护待办列表、进入 Plan 模式、调用 Agent Skills,也可以在任务自然拆解时继续派发自己的嵌套子 Agent。如果它结束自己的轮次时仍有后台任务在运行,那么只有在这些后台任务全部落定后,这次运行才会回报完成——主 Agent 拿到结果时,背后的工作也已经真正完成。 + ## 调用方式 子 Agent 由主 Agent 自动调度——根据任务复杂度、上下文消耗和子任务的独立性,在适当时机派发,无需用户手动指定。 @@ -29,7 +31,7 @@ Kimi Code CLI 内置三种子 Agent,开箱即用,分别面向不同任务形 - **主 Agent 上下文保持精炼**,长会话中不会被大量探索性日志撑满。 - **多个子 Agent 可以并行运行**,互不干扰。 -需要注意的是,每个子 Agent 都会独立消耗模型 token。简单任务没有必要派发子 Agent,主 Agent 直接处理更经济。子 Agent 也不支持继续嵌套调度。 +需要注意的是,每个子 Agent 都会独立消耗模型 token。简单任务没有必要派发子 Agent,主 Agent 直接处理更经济。 ## 权限继承 From 07c5dfb32d33b91716e8de5ea3402a728bb0a4e7 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 16 Jul 2026 17:08:21 +0800 Subject: [PATCH 4/4] fix(agent-core): close drain race for tasks settled before completion A background task that terminated before the drain's suppression pass was excluded from the active-only list, so its terminal notification escaped suppression and could still steer an orphan turn onto the finished subagent. Suppress every child task (including settled ones whose notification may still be in flight) and run the pass both before and after the settle wait; notification delivery re-checks suppression after its async output snapshot, which makes the block deterministic. Cover the mid-turn delivery path with an e2e case. --- .../agent-core/src/session/subagent-host.ts | 21 ++++++++-- .../test/harness/coder-subagent-tools.test.ts | 41 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index c77dd060aa..51a6a99f07 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -430,10 +430,12 @@ export class SessionSubagentHost { private async drainChildBackgroundTasks(child: Agent, signal: AbortSignal): Promise { for (;;) { signal.throwIfAborted(); - for (const task of child.background.list(true)) { - await child.background.suppressTerminalNotification(task.taskId); - } + await this.suppressChildTaskNotifications(child); await child.background.waitForActiveTasks(() => true, { signal }); + // Suppress again after the wait: notification delivery re-checks + // suppression after its async output snapshot, so this pass still + // blocks notifications for tasks that settled during the wait. + await this.suppressChildTaskNotifications(child); // A terminal effect that slipped past the suppression race may have // steered a follow-up turn onto the child; let it finish (it can fan // out new tasks) before declaring the child drained. @@ -445,6 +447,19 @@ export class SessionSubagentHost { } } + /** + * Suppress terminal notifications for every child background task — + * including already-settled ones whose notification may still be in + * flight. `list(false)` is required: the active-only list drops a task + * the moment it terminates, which is exactly when an unsuppressed + * notification can still steer an orphan turn onto the finished child. + */ + private async suppressChildTaskNotifications(child: Agent): Promise { + for (const task of child.background.list(false)) { + await child.background.suppressTerminalNotification(task.taskId); + } + } + private async triggerSubagentStart( parent: Agent, profileName: string, diff --git a/packages/agent-core/test/harness/coder-subagent-tools.test.ts b/packages/agent-core/test/harness/coder-subagent-tools.test.ts index 957c66999a..8478a03d38 100644 --- a/packages/agent-core/test/harness/coder-subagent-tools.test.ts +++ b/packages/agent-core/test/harness/coder-subagent-tools.test.ts @@ -440,4 +440,45 @@ describe('coder subagent aligned tools (real Session e2e)', () => { await expect(handle.completion).rejects.toThrow(/abort/i); }, 30_000); + + it('completes cleanly when the background task settles before the final turn ends', async () => { + const scripted = createScriptedGenerate(); + // The background task settles while the turn is still running: its + // terminal notification is delivered into the active turn (the + // legitimate path), so the drain afterwards has nothing to wait for + // and must not launch or leak a follow-up turn either. + scripted.mockNextResponse({ + type: 'function', + id: 'c1', + name: 'Bash', + arguments: JSON.stringify({ command: 'sleep 0.2', description: 'early settle probe', run_in_background: true }), + }); + scripted.mockNextResponse({ + type: 'function', + id: 'c2', + name: 'Bash', + arguments: JSON.stringify({ command: 'sleep 1' }), + }); + scripted.mockNextResponse({ type: 'text', text: DRAIN_FINAL_TEXT }); + const { session, mainAgent } = await createCoderSession(scripted.generate); + + const handle = await mainAgent.subagentHost!.spawn({ + profileName: 'coder', + parentToolCallId: 'e2e-early-settle-call', + prompt: 'Start a quick background sleep, keep working briefly, then finish.', + description: 'early settle check', + runInBackground: false, + signal: new AbortController().signal, + }); + + const completion = await handle.completion; + expect(completion.result).toContain('DRAIN-CHECK-DONE'); + + const child = await session.ensureAgentResumed(handle.agentId); + expect(child.background.list(true)).toHaveLength(0); + expect(child.turn.hasActiveTurn).toBe(false); + // The task settled mid-turn, so its notification was delivered into the + // turn (visible in context) rather than orphaned after completion. + expect(JSON.stringify(child.context.history)).toContain('