From 0ce918a00b5981da88193d23f3ef95fb21513b0f Mon Sep 17 00:00:00 2001 From: yuchengzhen Date: Sat, 8 Aug 2026 11:38:35 +0800 Subject: [PATCH] fix(agent-core): isolate builtin profile catalogs per session --- .../isolate-session-profile-catalogs.md | 5 +++ .../src/profile/agentfile/catalog.ts | 36 +++++++++++++++++-- .../agent-core/test/profile/agentfile.test.ts | 27 ++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 .changeset/isolate-session-profile-catalogs.md diff --git a/.changeset/isolate-session-profile-catalogs.md b/.changeset/isolate-session-profile-catalogs.md new file mode 100644 index 00000000000..fa8483dacd1 --- /dev/null +++ b/.changeset/isolate-session-profile-catalogs.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Prevent one session's subagent tool projection from changing builtin profiles in later sessions. diff --git a/packages/agent-core/src/profile/agentfile/catalog.ts b/packages/agent-core/src/profile/agentfile/catalog.ts index ca81cca981a..23b107d6b25 100644 --- a/packages/agent-core/src/profile/agentfile/catalog.ts +++ b/packages/agent-core/src/profile/agentfile/catalog.ts @@ -84,13 +84,45 @@ interface FileProfileEntry { readonly override: boolean; } +/** + * Session-local copies of the builtin profiles. `DEFAULT_AGENT_PROFILES` is a + * process-wide constant; seeding `merged` with its values directly would let + * any session-scoped rewrite of a profile (e.g. a host runtime projecting + * profile tool lists onto the session's tool surface) mutate shared process + * state and leak into every later session. Clone each entry and re-link the + * delegation graph against the clones so the whole graph is session-local. + */ +function sessionLocalBuiltinProfiles(): Map { + const cloned = new Map( + Object.entries(DEFAULT_AGENT_PROFILES).map(([name, profile]) => [ + name, + { + ...profile, + tools: [...profile.tools], + disallowedTools: + profile.disallowedTools === undefined ? undefined : [...profile.disallowedTools], + }, + ]), + ); + for (const profile of cloned.values()) { + if (profile.subagents === undefined) continue; + profile.subagents = Object.fromEntries( + Object.entries(profile.subagents).map(([name, target]) => [ + name, + cloned.get(name) ?? target, + ]), + ); + } + return cloned; +} + export class SessionAgentProfileCatalog { private merged: Map; private readonly readyPromise: Promise; private snapshotValue: AgentProfileCatalogSnapshot | undefined; constructor(private readonly options: SessionAgentCatalogOptions) { - this.merged = new Map(Object.entries(DEFAULT_AGENT_PROFILES)); + this.merged = sessionLocalBuiltinProfiles(); this.readyPromise = this.load(); // Keep an un-awaited rejection from crashing the process; createMain / // spawn awaiters see the error through `ready`. @@ -166,7 +198,7 @@ export class SessionAgentProfileCatalog { readonly entries: FileProfileEntry[]; readonly systemMd: AgentFileDefinition | undefined; } { - this.merged = new Map(Object.entries(DEFAULT_AGENT_PROFILES)); + this.merged = sessionLocalBuiltinProfiles(); const builtinDefault = this.getDefault(); const systemMd = diff --git a/packages/agent-core/test/profile/agentfile.test.ts b/packages/agent-core/test/profile/agentfile.test.ts index 14b61ab2283..e7cf86bae8e 100644 --- a/packages/agent-core/test/profile/agentfile.test.ts +++ b/packages/agent-core/test/profile/agentfile.test.ts @@ -372,6 +372,33 @@ describe('SessionAgentProfileCatalog', () => { expect(c.delegatableSubagents('agent')).not.toHaveProperty('agent'); }); + it('keeps builtin profiles session-local when a caller rewrites them in place', async () => { + const { workDir, brandHome, osHome } = await makeLayout(); + + const first = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await first.ready; + const firstCoder = first.get('coder'); + expect(firstCoder).toBeDefined(); + // Host runtimes may project a session's catalog entries onto the session + // tool surface by rewriting them in place (host subagent projection). + firstCoder!.tools = ['Read']; + + // The process-wide defaults stay pristine, and a later session's catalog + // seeds from them rather than from the rewritten objects. + expect(DEFAULT_AGENT_PROFILES['coder']!.tools).toContain('Bash'); + const second = catalog({ workDir, brandHomeDir: brandHome, osHomeDir: osHome }); + await second.ready; + expect(second.get('coder')?.tools).toEqual(DEFAULT_AGENT_PROFILES['coder']!.tools); + + // The delegation graph is re-linked to the session-local copies, so an + // in-place projection reaches what useProfile and the Agent tool + // description actually read. + expect(first.delegatableSubagents('agent')['coder']).toBe(firstCoder); + expect(second.delegatableSubagents('agent')['coder']?.tools).toEqual( + DEFAULT_AGENT_PROFILES['coder']!.tools, + ); + }); + it('extends SYSTEM.md delegation with custom agents without allowing self-delegation', async () => { const { workDir, brandHome, osHome } = await makeLayout(); await writeFile(join(brandHome, 'SYSTEM.md'), 'Custom system.', 'utf-8');