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
5 changes: 5 additions & 0 deletions .changeset/isolate-session-profile-catalogs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Prevent one session's subagent tool projection from changing builtin profiles in later sessions.
36 changes: 34 additions & 2 deletions packages/agent-core/src/profile/agentfile/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ResolvedAgentProfile> {
const cloned = new Map<string, ResolvedAgentProfile>(
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<string, ResolvedAgentProfile>;
private readonly readyPromise: Promise<void>;
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`.
Expand Down Expand Up @@ -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 =
Expand Down
27 changes: 27 additions & 0 deletions packages/agent-core/test/profile/agentfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading