Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/agent-core/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 14 additions & 3 deletions packages/agent-core/src/session/subagent-host.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolve } from 'pathe';
import type { TokenUsage } from '@moonshot-ai/kosong';

import type { Agent } from '../agent';
Expand Down Expand Up @@ -32,6 +33,7 @@ type RunSubagentOptions = {
readonly runInBackground: boolean;
readonly origin?: PromptOrigin | undefined;
readonly signal: AbortSignal;
readonly cwd?: string | undefined;
};

type SubagentCompletion = {
Expand Down Expand Up @@ -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;
Comment on lines +79 to +81
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 Inherit the parent runtime for nested subagents

When a cwd-scoped subagent later delegates without passing cwd, this leaves runtime undefined, so Session.createAgent() falls back to the session runtime instead of the parent subagent's cwd-scoped runtime. configureChild() still copies the parent cwd into the nested child config, which calls chdir on that shared session Kaos; in that nested-subagent scenario the main agent and other agents can have their runtime cwd moved to the package directory. Use the parent runtime when no new cwd override is requested, and only wrap it with withCwd when a different cwd is provided.

Useful? React with 👍 / 👎.

const { id, agent } = await this.session.createAgent(
{ type: 'sub', generate: parent.rawGenerate },
{ type: 'sub', generate: parent.rawGenerate, runtime },
Comment on lines 82 to +83
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Persist the custom runtime for resumed subagents

When a custom-cwd subagent is saved and the session is later resumed, metadata only records the homedir/type/parent, so ensureResumeAgentInstantiated() reconstructs every agent with the shared session runtime instead of this overridden runtime. Replaying the child's recorded config.update({cwd: ...}) then calls chdir on that shared Kaos, so after kimi resume a package-scoped subagent can move the main agent's runtime cwd (and other agents' tool resolution) to the package directory even though their config.cwd still says the session root. The cwd/runtime override needs to be restored per agent on resume, not just applied at initial spawn.

Useful? React with 👍 / 👎.

Comment on lines 82 to +83
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 Run subagent hooks from the overridden cwd

For a subagent spawned with a package cwd, this still creates the child without a cwd-specific hookEngine, so it inherits the session-level engine that was constructed with the original session cwd. Tool and prompt hooks use that engine's cwd both in inputData.cwd and as the subprocess working directory, which means package-scoped hook commands (for example a PreToolUse/PostToolUse script that runs package-local checks) run from the repo root while the subagent tools run from the package directory. The custom cwd path should also be reflected in the child's hook engine.

Useful? React with 👍 / 👎.

undefined,
this.ownerAgentId,
);
Expand All @@ -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);
Expand Down Expand Up @@ -275,10 +285,11 @@ export class SessionSubagentHost {
parent: Agent,
child: Agent,
profile: ResolvedAgentProfile,
cwd?: string,
): Promise<void> {
// 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,
});
Expand Down
29 changes: 20 additions & 9 deletions packages/agent-core/src/tools/builtin/collaboration/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
),
}),
);

Expand Down Expand Up @@ -170,15 +176,19 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
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<BackgroundProcessManager['reserveSlot']> | undefined;
Expand Down Expand Up @@ -214,6 +224,7 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
description: args.description,
runInBackground,
signal: backgroundController?.signal ?? foregroundDeadline?.signal ?? signal,
cwd: args.cwd,
Comment on lines 224 to +227
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 Reject or honor cwd when resuming a subagent

If a caller sets both resume and cwd, this option is forwarded into the resume path, but SessionSubagentHost.resume() only realigns modelAlias and never updates the child's cwd/runtime or reloads AGENTS.md. In that scenario the tool accepts a package cwd while silently continuing in the old working directory, so follow-up prompts that rely on scoped paths or package-level instructions run against the wrong context. Either make cwd spawn-only with an explicit error on resume, or apply it during resume.

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.

P1 Badge Require approval before escaping the parent cwd

When cwd is an absolute path outside the parent workspace, forwarding it unchecked lets the default-approved Agent tool create a child whose own config.cwd is that outside directory. I checked default-tool-approve.ts and Agent is auto-approved, while the write guard only asks for writes outside the child cwd; in another git worktree this means a subagent can auto-approve Write/Edit operations that would have required approval if the parent tried the same outside path directly. Constrain cwd to the parent cwd or surface the requested directory as a file access that must be approved.

Useful? React with 👍 / 👎.

};

let handle: SubagentHandle;
Expand Down
170 changes: 170 additions & 0 deletions packages/agent-core/test/session/subagent-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }> = [];
Expand Down
66 changes: 66 additions & 0 deletions packages/agent-core/test/tools/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(),
Expand Down