From 7f3229a32d061011edfe4ea580755553dc5aee3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BA=84=E7=A8=BC?= Date: Thu, 28 May 2026 16:43:13 +0100 Subject: [PATCH] fix(agent-core): allow subagent cwd override for package-level AGENTS.md discovery Subagents previously inherited the parent's Kaos instance and cwd, so package-level AGENTS.md files were never loaded for subagents. This broke monorepo workflows where subagents should operate with package-specific context. Changes: - Add optional "cwd" parameter to AgentToolInputSchema - Pass cwd through RunSubagentOptions to SessionSubagentHost.spawn() - Resolve relative cwd against parent.config.cwd before passing to withCwd(), so callers can use relative package paths - Create an isolated Kaos via withCwd() for the subagent when cwd is provided, so AGENTS.md discovery and path resolution use the target directory without mutating the parent agent's shared Kaos state - Allow config.runtime override in Session.instantiateAgent so the subagent can receive its isolated runtime - Update configureChild to use the custom cwd when provided - Reject cwd when combined with resume (silently ignored before) - Add unit and integration tests for cwd propagation, relative resolution, and resume rejection --- packages/agent-core/src/session/index.ts | 2 +- .../agent-core/src/session/subagent-host.ts | 17 +- .../src/tools/builtin/collaboration/agent.ts | 29 ++- .../test/session/subagent-host.test.ts | 170 ++++++++++++++++++ packages/agent-core/test/tools/agent.test.ts | 66 +++++++ 5 files changed, 271 insertions(+), 13 deletions(-) diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 222872f2..8716b110 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -408,7 +408,7 @@ export class Session { return new Agent({ ...config, type, - runtime: this.options.runtime, + runtime: config.runtime ?? this.options.runtime, config: this.options.config, homedir, skills: this.skills, diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 97c425ad..05567f17 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -1,3 +1,4 @@ +import { resolve } from 'pathe'; import type { TokenUsage } from '@moonshot-ai/kosong'; import type { Agent } from '../agent'; @@ -32,6 +33,7 @@ type RunSubagentOptions = { readonly runInBackground: boolean; readonly origin?: PromptOrigin | undefined; readonly signal: AbortSignal; + readonly cwd?: string | undefined; }; type SubagentCompletion = { @@ -69,8 +71,16 @@ export class SessionSubagentHost { } const profile = this.resolveProfile(parent, profileName); + const resolvedCwd = + options.cwd !== undefined + ? parent.runtime.kaos.normpath(resolve(parent.config.cwd, options.cwd)) + : undefined; + const runtime = + resolvedCwd !== undefined + ? { ...parent.runtime, kaos: parent.runtime.kaos.withCwd(resolvedCwd) } + : undefined; const { id, agent } = await this.session.createAgent( - { type: 'sub', generate: parent.rawGenerate }, + { type: 'sub', generate: parent.rawGenerate, runtime }, undefined, this.ownerAgentId, ); @@ -90,7 +100,7 @@ export class SessionSubagentHost { ...options, signal: controller.signal, }, - () => this.configureChild(parent, agent, profile), + () => this.configureChild(parent, agent, profile, resolvedCwd), ).finally(() => { unlinkAbortSignal(); this.activeChildren.delete(id); @@ -275,10 +285,11 @@ export class SessionSubagentHost { parent: Agent, child: Agent, profile: ResolvedAgentProfile, + cwd?: string, ): Promise { // A subagent always inherits the parent agent's model. child.config.update({ - cwd: parent.config.cwd, + cwd: cwd ?? parent.config.cwd, modelAlias: parent.config.modelAlias, thinkingLevel: parent.config.thinkingLevel, }); diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index a65ed248..5fc2b3ca 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -80,6 +80,12 @@ export const AgentToolInputSchema = z.preprocess( .describe( 'Timeout in seconds for the agent task (min 30s, max 3600s / 1hr). When omitted, a foreground task runs until completion with no timeout. The agent is stopped if it exceeds this limit.', ), + cwd: z + .string() + .optional() + .describe( + "The working directory for the subagent. When provided, the subagent discovers AGENTS.md and resolves paths relative to this directory. When omitted, the subagent inherits the parent agent's working directory.", + ), }), ); @@ -170,15 +176,19 @@ export class AgentTool implements BuiltinTool { const runInBackground = args.run_in_background === true; const requestedProfileName = args.subagent_type?.length ? args.subagent_type : undefined; const resumeAgentId = args.resume?.trim(); - if ( - resumeAgentId !== undefined && - resumeAgentId.length > 0 && - requestedProfileName !== undefined - ) { - return { - output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.', - isError: true, - }; + if (resumeAgentId !== undefined && resumeAgentId.length > 0) { + if (requestedProfileName !== undefined) { + return { + output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.', + isError: true, + }; + } + if (args.cwd !== undefined && args.cwd.length > 0) { + return { + output: 'Cannot set cwd when resuming an existing agent. Resume by agent id only.', + isError: true, + }; + } } let reservation: ReturnType | undefined; @@ -214,6 +224,7 @@ export class AgentTool implements BuiltinTool { description: args.description, runInBackground, signal: backgroundController?.signal ?? foregroundDeadline?.signal ?? signal, + cwd: args.cwd, }; let handle: SubagentHandle; diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index f1eefa2f..d896a2be 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -13,7 +13,9 @@ import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; import { collectGitContext } from '../../src/session/git-context'; import { SessionSubagentHost } from '../../src/session/subagent-host'; +import { ProviderManager } from '../../src/session/provider-manager'; import { testAgent } from '../agent/harness/agent'; +import { createScriptedGenerate } from '../agent/harness/scripted-generate'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; // Git context collection is exercised in git-context.test.ts; here it is @@ -33,6 +35,174 @@ afterEach(async () => { }); describe('SessionSubagentHost', () => { + it('spawns a subagent with a custom cwd and isolated kaos', async () => { + const parentWorkDir = '/repo'; + const childWorkDir = '/repo/packages/domain'; + + const scripted = createScriptedGenerate(); + + const parentKaos = createFakeKaos({ + getcwd: () => parentWorkDir, + mkdir: vi.fn().mockResolvedValue(undefined), + writeText: vi.fn().mockResolvedValue(0), + stat: vi.fn(async (path: string) => { + if ( + [parentWorkDir, `${parentWorkDir}/.git`, childWorkDir, `${childWorkDir}/.git`].includes( + path, + ) + ) { + return stat('dir'); + } + if (path === `${childWorkDir}/AGENTS.md`) { + return stat('file'); + } + throw new Error(`ENOENT ${path}`); + }), + iterdir: async function* (path: string) { + if (path === parentWorkDir) { + yield `${parentWorkDir}/packages`; + return; + } + if (path === childWorkDir) { + yield `${childWorkDir}/package.json`; + return; + } + throw new Error(`ENOENT ${path}`); + }, + readText: vi.fn(async (path: string) => { + if (path === `${childWorkDir}/AGENTS.md`) return 'domain rules'; + throw new Error(`ENOENT ${path}`); + }), + }); + + const parent = testAgent({ + kaos: parentKaos, + generate: scripted.generate, + }); + parent.configure(); + parent.newEvents(); + + scripted.mockNextResponse({ + type: 'text', + text: + 'Domain investigation complete. Examined the module structure, traced all call sites, and verified the fix against the existing test suite. The relevant files were located under the domain package and the changes applied cleanly without touching unrelated code paths.', + }); + + const session = new Session({ + id: 'test-subagent-cwd', + runtime: { kaos: parentKaos }, + homedir: '/tmp/kimi-session', + rpc: createSessionRpc(), + initializeMainAgent: false, + providerManager: parent.agent.modelProvider as ProviderManager, + }); + session.agents.set('main', parent.agent); + + const host = new SessionSubagentHost(session, 'main'); + + const parentCwdBefore = parent.agent.config.cwd; + + const handle = await host.spawn('coder', { + parentToolCallId: 'call_agent', + prompt: 'Investigate domain', + description: 'Investigate domain', + runInBackground: false, + signal, + cwd: childWorkDir, + }); + + await handle.completion; + + const child = session.agents.get('agent-0'); + expect(child).toBeDefined(); + expect(child!.config.cwd).toBe(childWorkDir); + expect(child!.runtime.kaos.getcwd()).toBe(childWorkDir); + expect(child!.config.systemPrompt).toContain('domain rules'); + expect(parent.agent.config.cwd).toBe(parentCwdBefore); + }); + + it('resolves a relative cwd against the parent agent cwd', async () => { + const parentWorkDir = '/repo'; + const childWorkDir = '/repo/packages/domain'; + + const scripted = createScriptedGenerate(); + + const parentKaos = createFakeKaos({ + getcwd: () => parentWorkDir, + mkdir: vi.fn().mockResolvedValue(undefined), + writeText: vi.fn().mockResolvedValue(0), + stat: vi.fn(async (path: string) => { + if ( + [parentWorkDir, `${parentWorkDir}/.git`, childWorkDir, `${childWorkDir}/.git`].includes( + path, + ) + ) { + return stat('dir'); + } + if (path === `${childWorkDir}/AGENTS.md`) { + return stat('file'); + } + throw new Error(`ENOENT ${path}`); + }), + iterdir: async function* (path: string) { + if (path === childWorkDir) { + yield `${childWorkDir}/package.json`; + return; + } + throw new Error(`ENOENT ${path}`); + }, + readText: vi.fn(async (path: string) => { + if (path === `${childWorkDir}/AGENTS.md`) return 'domain rules'; + throw new Error(`ENOENT ${path}`); + }), + }); + + const parent = testAgent({ + kaos: parentKaos, + generate: scripted.generate, + }); + parent.configure(); + // Override the parent cwd to the repo root so relative resolution is + // deterministic and matches the real monorepo scenario. + parent.agent.config.update({ cwd: parentWorkDir }); + parent.newEvents(); + + scripted.mockNextResponse({ + type: 'text', + text: + 'Domain investigation complete. Examined the module structure, traced all call sites, and verified the fix against the existing test suite. The relevant files were located under the domain package and the changes applied cleanly without touching unrelated code paths.', + }); + + const session = new Session({ + id: 'test-subagent-relative-cwd', + runtime: { kaos: parentKaos }, + homedir: '/tmp/kimi-session', + rpc: createSessionRpc(), + initializeMainAgent: false, + providerManager: parent.agent.modelProvider as ProviderManager, + }); + session.agents.set('main', parent.agent); + + const host = new SessionSubagentHost(session, 'main'); + + const handle = await host.spawn('coder', { + parentToolCallId: 'call_agent', + prompt: 'Investigate domain', + description: 'Investigate domain', + runInBackground: false, + signal, + cwd: 'packages/domain', + }); + + await handle.completion; + + const child = session.agents.get('agent-0'); + expect(child).toBeDefined(); + expect(child!.config.cwd).toBe(childWorkDir); + expect(child!.runtime.kaos.getcwd()).toBe(childWorkDir); + expect(child!.config.systemPrompt).toContain('domain rules'); + }); + it('fires subagent lifecycle hooks around the child turn', async () => { const child = testAgent(); const calls: Array<{ readonly event: string; readonly childLlmCallCount: number }> = []; diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index 9a4e4837..6dcb43a7 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -46,6 +46,20 @@ function captureLogs(): { logger: Logger; entries: CapturedLogEntry[] } { } describe('AgentTool', () => { + it('accepts the cwd parameter', () => { + const parsed = AgentToolInputSchema.parse({ + prompt: 'Investigate', + description: 'Find cause', + cwd: '/repo/packages/domain', + }); + + expect(parsed).toMatchObject({ + prompt: 'Investigate', + description: 'Find cause', + cwd: '/repo/packages/domain', + }); + }); + it('accepts the snake_case background parameter', () => { const parsed = AgentToolInputSchema.parse({ prompt: 'Investigate', @@ -198,6 +212,35 @@ describe('AgentTool', () => { expect(tool.description).toContain('- coder: General coding.'); }); + it('passes cwd to the subagent host when spawning', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const tool = new AgentTool(host); + + await executeTool(tool, + context({ + prompt: 'Investigate', + description: 'Find cause', + cwd: '/repo/packages/domain', + }), + ); + + expect(host.spawn).toHaveBeenCalledWith('coder', { + parentToolCallId: 'call_agent', + prompt: 'Investigate', + description: 'Find cause', + runInBackground: false, + signal, + cwd: '/repo/packages/domain', + }); + }); + it('spawns a foreground subagent and returns its summary', async () => { const host = mockSubagentHost({ spawn: vi.fn().mockResolvedValue({ @@ -289,6 +332,29 @@ describe('AgentTool', () => { expect(result.output).toContain('resumed result'); }); + it('returns an error when resuming with cwd', async () => { + const host = mockSubagentHost({ + spawn: vi.fn(), + resume: vi.fn(), + }); + const tool = new AgentTool(host); + + const result = await executeTool(tool, + context({ + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + cwd: '/some/path', + }), + ); + + expect(result).toMatchObject({ + isError: true, + output: 'Cannot set cwd when resuming an existing agent. Resume by agent id only.', + }); + expect(host.resume).not.toHaveBeenCalled(); + }); + it('returns an error when resuming with a subagent type', async () => { const host = mockSubagentHost({ spawn: vi.fn(),