From dad0a6c196da774ac96744021b38d2010546d466 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 2 Sep 2026 20:49:04 +0800 Subject: [PATCH 1/5] fix(agent-core-v2): rebuild persisted subagents when resuming after a restart Agent(resume=) only consulted the in-memory agent roster, so once a session was reopened in a new process every persisted subagent id failed with "does not exist" even though its wire log and metadata were intact. The resume path now reads the session metadata once, verifies the target is a subagent owned by the caller, and re-creates its agent scope from the persisted records when it is not live before running the turn. --- .changeset/resume-subagent-after-restart.md | 5 ++ .../src/agent/tools/agent/agentTool.ts | 48 ++++++++--- packages/agent-core-v2/test/tool/tool.test.ts | 86 +++++++++++++++++++ .../agent-core-v2/test/wire/resume.test.ts | 52 +++++++++++ 4 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 .changeset/resume-subagent-after-restart.md diff --git a/.changeset/resume-subagent-after-restart.md b/.changeset/resume-subagent-after-restart.md new file mode 100644 index 00000000000..d2b8ac133f9 --- /dev/null +++ b/.changeset/resume-subagent-after-restart.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Resuming a subagent by its agent id now works after the session is reopened in a new process. diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 22963cc0e9a..59462e2d5d2 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -44,8 +44,13 @@ import { ILogService } from '#/_base/log/log'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; -import { isSubagentMeta, subagentLabels, subagentParentAgentId } from '#/session/agentLifecycle/subagentMetadata'; -import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { + isSubagentMeta, + labelsFromAgentMeta, + subagentLabels, + subagentParentAgentId, +} from '#/session/agentLifecycle/subagentMetadata'; +import { type AgentMeta, ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { emitAgentRunSpawned, mirrorAgentRun, SubagentStarted } from '#/session/subagent/mirrorAgentRun'; import { IEventDispatcher } from '#/state/eventDispatcher'; @@ -282,13 +287,7 @@ export class SubagentTool implements ISubagentTool { let displayModelSource: SubagentModelSource | undefined; let promptText = args.prompt; if (isResume) { - const target = this.agentLifecycle.handleOf(resumeAgentId); - if (target === undefined) { - throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${resumeAgentId}" does not exist`, { - details: { agentId: resumeAgentId }, - }); - } - await this.ensureOwnedIdleSubagent(resumeAgentId, target); + const target = await this.resolveResumeTarget(resumeAgentId); agentId = target.id; const resumed = target.accessor.get(IAgentProfileService).data(); profileName = resumed.profileName ?? RESUMED_LABEL; @@ -346,12 +345,15 @@ export class SubagentTool implements ISubagentTool { }; } - private async ensureOwnedIdleSubagent( - agentId: string, - target: IAgentScopeHandle, - ): Promise { + private async resolveResumeTarget(agentId: string): Promise { + const live = this.agentLifecycle.handleOf(agentId); const meta = (await this.sessionMetadata.read()).agents?.[agentId]; - if (!isSubagentMeta(meta)) { + if (meta === undefined && live === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${agentId}" does not exist`, { + details: { agentId }, + }); + } + if (meta === undefined || !isSubagentMeta(meta)) { throw new Error2(ErrorCodes.AGENT_NOT_A_SUBAGENT, `Agent instance "${agentId}" is not a subagent`, { details: { agentId }, }); @@ -363,6 +365,7 @@ export class SubagentTool implements ISubagentTool { { details: { agentId, callerAgentId: this.callerAgentId } }, ); } + const target = live ?? (await this.rebuildSubagent(agentId, meta)); if (target.accessor.get(IAgentLoopService).status().state === 'running') { throw new Error2( ErrorCodes.AGENT_ALREADY_RUNNING, @@ -370,6 +373,23 @@ export class SubagentTool implements ISubagentTool { { details: { agentId } }, ); } + return target; + } + + private async rebuildSubagent(agentId: string, meta: AgentMeta): Promise { + await this.agentLifecycle.create({ + agentId, + labels: labelsFromAgentMeta(meta), + forkedFrom: meta.forkedFrom, + }); + const rebuilt = this.agentLifecycle.handleOf(agentId); + if (rebuilt === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${agentId}" does not exist`, { + details: { agentId }, + }); + } + this.log.info('subagent rebuilt for resume', { agentId, callerAgentId: this.callerAgentId }); + return rebuilt; } private async execution( diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index f85ea918c2d..3b14fff3d6b 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -2322,6 +2322,92 @@ describe('Agent tool execution contract', () => { expect(result.output).toContain('resumed result'); }); + it('rebuilds a persisted subagent that is not live before resuming it', async () => { + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'resumed after restart' }), + }); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ + 'agent-existing': { + type: 'sub', + parentAgentId: 'main', + forkedFrom: 'main', + labels: { parentAgentId: 'main' }, + }, + }), + ), + ); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(lifecycle.create).toHaveBeenCalledTimes(1); + expect(lifecycle.create).toHaveBeenCalledWith({ + agentId: 'agent-existing', + labels: { parentAgentId: 'main' }, + forkedFrom: 'main', + }); + expect(lifecycle.run).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-existing' }), + { kind: 'prompt', prompt: 'Continue' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(result.isError).not.toBe(true); + expect(result.output).toContain('agent_id: agent-existing'); + expect(result.output).toContain('resumed after restart'); + }); + + it('keeps rejecting resume of an agent id that was never persisted', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext( + lifecycle, + sessionService(ISessionMetadata, sessionMetadataStub({})), + ); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-missing', + }); + + expect(result).toMatchObject({ + isError: true, + output: 'subagent error: Agent instance "agent-missing" does not exist', + }); + expect(lifecycle.create).not.toHaveBeenCalled(); + expect(lifecycle.run).not.toHaveBeenCalled(); + }); + + it('does not rebuild a persisted subagent owned by another parent', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ 'agent-existing': subagentMeta('other') }), + ), + ); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(result).toMatchObject({ + isError: true, + output: 'subagent error: Agent instance "agent-existing" does not belong to this parent agent', + }); + expect(lifecycle.create).not.toHaveBeenCalled(); + expect(lifecycle.run).not.toHaveBeenCalled(); + }); + it('rejects direct resume of a non-subagent', async () => { const lifecycle = createAgentLifecycleStub(); const context = createAgentToolContext( diff --git a/packages/agent-core-v2/test/wire/resume.test.ts b/packages/agent-core-v2/test/wire/resume.test.ts index c239c73766d..4770955d66d 100644 --- a/packages/agent-core-v2/test/wire/resume.test.ts +++ b/packages/agent-core-v2/test/wire/resume.test.ts @@ -164,6 +164,58 @@ describe('Agent resume', () => { } }); + it('closes a restored open tool call with an interrupted result before the next prompt', async () => { + const persistence = new RecordingAgentPersistence([ + resumeConfigRecord(), + contextAppendRecord(0, [{ role: 'user', text: 'Run lookup', origin: { kind: 'user' } }]), + turnPromptRecord(0, { kind: 'user' }), + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'step-0', turnId: '0', step: 1 }, + }, + { + type: 'context.append_loop_event', + event: { + type: 'tool.call', + uuid: 'call-0', + turnId: '0', + step: 1, + stepUuid: 'step-0', + toolCallId: 'call_open', + name: 'Lookup', + args: { query: 'moon' }, + }, + }, + ] as unknown as WireRecord[]); + const ctx = testAgent({ persistence, autoConfigure: false }); + + try { + await ctx.restorePersisted(); + + expect(ctx.llmCalls).toHaveLength(0); + + ctx.mockNextResponse({ type: 'text', text: 'Fresh response after resume.' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Fresh prompt after resume' }] }); + await ctx.untilTurnEnd(); + + expect(findRpcEvent(ctx.allEvents, 'error')).toBeUndefined(); + expect(ctx.llmInputs()).toMatchInlineSnapshot(` + call 1: + system: + tools: Agent, AgentSwarm, AskUserQuestion, Bash, CreateGoal, CronCreate, CronDelete, CronList, Edit, EnterPlanMode, ExitPlanMode, FetchURL, GetGoal, Glob, Grep, Read, SetGoalBudget, Skill, TaskList, TaskOutput, TaskStop, TodoList, UpdateGoal, WaitFor, Write + messages: + user: text "Run lookup" + assistant: [] calls call_open:Lookup { "query": "moon" } + tool[call_open]: text "ERROR: Tool execution failed.\\nTool execution was interrupted before its result was recorded. Do not assume the tool completed successfully." + user: text "Fresh prompt after resume" + user: text + `); + await ctx.expectResumeMatches(); + } finally { + await ctx.dispose(); + } + }); + it('does not reconcile a legacy interruption whose delivery was recorded', async () => { const persistence = new RecordingAgentPersistence([ resumeConfigRecord(), From 81c2559a124e34ffffb2bc281ff4678705228afa Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 2 Sep 2026 21:22:48 +0800 Subject: [PATCH 2/5] fix(agent-core-v2): sync rebuilt subagents to the caller mode and match rules by their persisted profile A subagent rebuilt for resume replayed the permission mode it was spawned with, so a child persisted in yolo kept running in yolo after the parent had switched back to manual; the rebuild now copies the caller's current mode the way a fresh spawn does. Permission rules with a profile subject such as Agent(coder) were matched against the fallback label while the target was offline, because only live agents exposed their profile during execution resolution. Every spawn site now records the profile name in the subagent labels and the resume path reads it back for display and rule matching when the agent is not live. Adds a kap-server e2e case that spawns a subagent, restarts the server on the same home directory, resumes it through the Agent tool, and checks that its prior context reaches the next model request. --- .changeset/resumed-subagent-permissions.md | 5 + .../src/agent/tools/agent/agentTool.ts | 14 ++- .../swarm/session/sessionSwarmService.ts | 5 +- .../features/tower/tools/spawn/spawnTool.ts | 2 +- .../agentLifecycle/subagentMetadata.ts | 10 +- .../test/features/swarm/sessionSwarm.test.ts | 2 +- .../features/tower/tools/spawnTool.test.ts | 16 +-- packages/agent-core-v2/test/tool/tool.test.ts | 99 +++++++++++++++++++ .../test/transcriptContract.e2e.test.ts | 85 ++++++++++++++++ 9 files changed, 221 insertions(+), 17 deletions(-) create mode 100644 .changeset/resumed-subagent-permissions.md diff --git a/.changeset/resumed-subagent-permissions.md b/.changeset/resumed-subagent-permissions.md new file mode 100644 index 00000000000..ed7a0112504 --- /dev/null +++ b/.changeset/resumed-subagent-permissions.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Subagents resumed after a restart now follow the current permission mode and are matched by their own profile in permission rules. diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 59462e2d5d2..fac46e996e8 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -22,6 +22,7 @@ import { import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { ToolAccesses, type ExecutableToolContext, @@ -49,6 +50,7 @@ import { labelsFromAgentMeta, subagentLabels, subagentParentAgentId, + subagentProfileName, } from '#/session/agentLifecycle/subagentMetadata'; import { type AgentMeta, ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; @@ -114,6 +116,7 @@ export class SubagentTool implements ISubagentTool { @IAgentProfileService private readonly profile: IAgentProfileService, @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, + @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, @ISessionMetadata private readonly sessionMetadata: ISessionMetadata, @ILogService private readonly log: ILogService, @IConfigService private readonly config: IConfigService, @@ -237,7 +240,7 @@ export class SubagentTool implements ISubagentTool { const profileNameForDisplay = resumeAgentId !== undefined && resumeAgentId.length > 0 - ? this.resumeProfileName(resumeAgentId) ?? RESUMED_LABEL + ? (await this.resumeProfileName(resumeAgentId)) ?? RESUMED_LABEL : (requestedProfileName ?? (args.fork === true ? (this.profile.data().profileName ?? DEFAULT_PROFILE_NAME) @@ -258,10 +261,10 @@ export class SubagentTool implements ISubagentTool { }; } - private resumeProfileName(agentId: string): string | undefined { + private async resumeProfileName(agentId: string): Promise { const target = this.agentLifecycle.handleOf(agentId); - if (target === undefined) return undefined; - return target.accessor.get(IAgentProfileService).data().profileName; + if (target !== undefined) return target.accessor.get(IAgentProfileService).data().profileName; + return subagentProfileName((await this.sessionMetadata.read()).agents?.[agentId]); } private async launch( @@ -302,7 +305,7 @@ export class SubagentTool implements ISubagentTool { const spawned = await this.subagents.spawn({ callerAgentId: this.callerAgentId, plan, - labels: subagentLabels(this.callerAgentId), + labels: subagentLabels(this.callerAgentId, { profileName: plan.profileName }), prompt: args.prompt, }); agentId = spawned.agentId; @@ -388,6 +391,7 @@ export class SubagentTool implements ISubagentTool { details: { agentId }, }); } + rebuilt.accessor.get(IAgentPermissionModeService).setMode(this.permissionMode.mode); this.log.info('subagent rebuilt for resume', { agentId, callerAgentId: this.callerAgentId }); return rebuilt; } diff --git a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts index 9bc8b30d317..1b55ce05ed4 100644 --- a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts @@ -114,7 +114,10 @@ export class SessionSwarmService implements ISessionSwarmService { const spawned = await this.subagents.spawn({ callerAgentId, plan, - labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }), + labels: subagentLabels(callerAgentId, { + swarmItem: options.swarmItem, + profileName: plan.profileName, + }), prompt: options.prompt, }); emitAgentRunSpawned(caller, spawned.agentId, { diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts index 7ab16708351..ce44ea88773 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts @@ -306,7 +306,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { model: binding?.model, thinking: resolveSubagentThinking(this.config, model, binding?.thinking), }, - labels: subagentLabels(this.callerAgentId), + labels: subagentLabels(this.callerAgentId, { profileName: TOWER_WORKER_PROFILE }), }); } catch (error) { throw binding === undefined diff --git a/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts b/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts index aa7b6000d7c..a8fb240b1ec 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts @@ -2,12 +2,15 @@ import type { AgentMeta } from '#/session/sessionMetadata/sessionMetadata'; export function subagentLabels( parentAgentId: string, - options: { readonly swarmItem?: string } = {}, + options: { readonly swarmItem?: string; readonly profileName?: string } = {}, ): Readonly> { const labels: Record = { parentAgentId }; if (options.swarmItem !== undefined) { labels['swarmItem'] = options.swarmItem; } + if (options.profileName !== undefined) { + labels['profileName'] = options.profileName; + } return labels; } @@ -42,6 +45,11 @@ export function subagentSwarmItem(meta: AgentMeta | undefined): string | undefin return firstNonEmpty(meta.labels?.['swarmItem'], meta.swarmItem); } +export function subagentProfileName(meta: AgentMeta | undefined): string | undefined { + if (meta === undefined) return undefined; + return firstNonEmpty(meta.labels?.['profileName']); +} + function firstNonEmpty(...values: readonly (string | undefined)[]): string | undefined { return values.find((value) => value !== undefined && value.length > 0); } diff --git a/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts index 98d39fcd95f..a9ce533684e 100644 --- a/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts @@ -1000,7 +1000,7 @@ describe('SessionSwarmService metadata compatibility', () => { expect(spawnAgent).toHaveBeenCalledWith({ callerAgentId: 'main', plan: { profileName: 'coder', model: 'kimi-test', thinking: 'medium', fork: false }, - labels: { parentAgentId: 'main', swarmItem: 'src/a.ts' }, + labels: { parentAgentId: 'main', swarmItem: 'src/a.ts', profileName: 'coder' }, prompt: 'Review the file', }); }); diff --git a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts index f4cdf4958f8..b9e1e90e1ac 100644 --- a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts +++ b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts @@ -16,7 +16,7 @@ import type { AgentContext } from '#/agent/agentContext/agentContext'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentTaskService } from '#/agent/task/task'; import { TowerStore } from '#/features/tower/protocol/index'; -import { IAgentTowerService } from '#/features/tower/tower'; +import { IAgentTowerService, TOWER_WORKER_PROFILE } from '#/features/tower/tower'; import { ITowerRateLimitService } from '#/features/tower/towerRateLimit'; import { SubagentTask } from '#/agent/tools/agent/subagent-task'; import { ITowerSpawnTool, type TowerSpawnToolInput } from '#/features/tower/tools/spawn/spawn'; @@ -302,7 +302,7 @@ describe('TowerSpawnTool', () => { expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'kimi-code', thinking: 'off' }, - labels: { parentAgentId: 'main' }, + labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, }); expect(runAgent).toHaveBeenCalledWith( expect.objectContaining({ agentId: 'agent-7' }), @@ -352,7 +352,7 @@ describe('TowerSpawnTool', () => { expect(result.output).toContain('model: cheap/fast'); expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: undefined }, - labels: { parentAgentId: 'main' }, + labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, }); const activityLog = await readFile(join(repo, '.tower/comms/log/activity.log'), 'utf8'); expect(activityLog).toMatch(/spawn .*model=cheap\/fast/); @@ -366,7 +366,7 @@ describe('TowerSpawnTool', () => { expect(result.isError).toBeUndefined(); expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: 'low' }, - labels: { parentAgentId: 'main' }, + labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, }); }); @@ -383,7 +383,7 @@ describe('TowerSpawnTool', () => { expect(result.isError).toBeUndefined(); expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: 'max' }, - labels: { parentAgentId: 'main' }, + labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, }); }); @@ -401,7 +401,7 @@ describe('TowerSpawnTool', () => { expect(result.isError).toBeUndefined(); expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: undefined }, - labels: { parentAgentId: 'main' }, + labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, }); }); @@ -427,7 +427,7 @@ describe('TowerSpawnTool', () => { expect(result.output).toContain('model: kimi-code'); expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'kimi-code', thinking: 'off' }, - labels: { parentAgentId: 'main' }, + labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, }); }); @@ -444,7 +444,7 @@ describe('TowerSpawnTool', () => { expect(result.output).toContain('model: cheap/fast'); expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: undefined }, - labels: { parentAgentId: 'main' }, + labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, }); }); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 3b14fff3d6b..b82383f6e21 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -1602,6 +1602,69 @@ describe('Agent tool execution contract', () => { expect(lifecycle.list).toHaveBeenCalled(); }); + it('records the spawned profile in the subagent labels', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => ({ summary: 'child result' }), + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + }); + + expect(result.isError).not.toBe(true); + expect(lifecycle.create).toHaveBeenCalledWith( + expect.objectContaining({ + labels: { parentAgentId: 'main', profileName: 'explore' }, + }), + ); + }); + + it('uses the persisted profile of an offline subagent for display and approval rules', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ + 'agent-existing': { labels: { parentAgentId: 'main', profileName: 'explore' } }, + }), + ), + ); + + const execution = await agentTool(context).resolveExecution({ + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + if (execution.isError === true) throw new Error('expected runnable execution'); + expect(execution.description).toBe('Launching explore agent: Continue work'); + expect(execution.matchesRule?.('explore')).toBe(true); + expect(execution.matchesRule?.('coder')).toBe(false); + expect(lifecycle.create).not.toHaveBeenCalled(); + }); + + it('falls back to the generic label when an offline subagent has no persisted profile', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext( + lifecycle, + sessionService(ISessionMetadata, sessionMetadataStub({ 'agent-existing': subagentMeta() })), + ); + + const execution = await agentTool(context).resolveExecution({ + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + if (execution.isError === true) throw new Error('expected runnable execution'); + expect(execution.description).toBe('Launching subagent agent: Continue work'); + }); + it('labels fork launches with the caller profile for display and approval rules', async () => { const lifecycle = createAgentLifecycleStub(); const context = createAgentToolContext(lifecycle, forkFlags()); @@ -2408,6 +2471,42 @@ describe('Agent tool execution contract', () => { expect(lifecycle.run).not.toHaveBeenCalled(); }); + it('syncs a rebuilt subagent to the caller permission mode before resuming it', async () => { + const setMode = vi.fn(); + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'resumed after restart' }), + handleServices: new Map>([ + [ + 'agent-existing', + new Map([ + [ + IAgentPermissionModeService, + { _serviceBrand: undefined, mode: 'yolo', setMode, onDidChangeMode: Event.None }, + ], + ]), + ], + ]), + }); + const context = createAgentToolContext( + lifecycle, + sessionService(ISessionMetadata, sessionMetadataStub({ 'agent-existing': subagentMeta() })), + ); + context.get(IAgentPermissionModeService).setMode('auto'); + expect(context.get(IAgentPermissionModeService).mode).toBe('auto'); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(result.isError).not.toBe(true); + expect(setMode).toHaveBeenCalledWith('auto'); + expect(setMode.mock.invocationCallOrder[0]).toBeLessThan( + lifecycle.run.mock.invocationCallOrder[0]!, + ); + }); + it('rejects direct resume of a non-subagent', async () => { const lifecycle = createAgentLifecycleStub(); const context = createAgentToolContext( diff --git a/packages/kap-server/test/transcriptContract.e2e.test.ts b/packages/kap-server/test/transcriptContract.e2e.test.ts index ba94a4e1255..1e5af24d4b7 100644 --- a/packages/kap-server/test/transcriptContract.e2e.test.ts +++ b/packages/kap-server/test/transcriptContract.e2e.test.ts @@ -6,6 +6,12 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket, type RawData } from 'ws'; +import { + IAgentLifecycleService, + MAIN_AGENT_ID, + getLiveSessionById, + resumeSessionById, +} from '@moonshot-ai/agent-core-v2'; import { type RunningServer, startServer } from '../src/start'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; @@ -487,4 +493,83 @@ describe('transcript contract e2e', () => { ); channel.close(); }); + + it('S7: a foreground subagent resumes with its prior context after a server restart', async () => { + let childAgentId: string | undefined; + let resumedChildRequest: string | undefined; + await boot([ + { + match: (body) => body.includes('spawn-child') && !body.includes('"role":"tool"'), + respond: () => + sseToolCall( + 'call_spawn', + 'Agent', + JSON.stringify({ prompt: 'remember the token quartz-7731 and reply with ok', description: 'child' }), + ), + }, + { + match: (body) => + body.includes('remember the token') && !body.includes('spawn-child') && !body.includes('recall the token'), + respond: () => sseText('ok, remembered'), + }, + { + match: (body) => body.includes('resume-child') && !body.includes('recall the token'), + respond: () => + sseToolCall( + 'call_resume', + 'Agent', + JSON.stringify({ prompt: 'recall the token', description: 'child again', resume: childAgentId }), + ), + }, + { + match: (body) => { + const hit = body.includes('recall the token') && !body.includes('resume-child'); + if (hit) resumedChildRequest = body; + return hit; + }, + respond: () => sseText('the token is quartz-7731'), + }, + { match: () => true, respond: () => sseText('noted') }, + ]); + const sid = await createSession(server!, base); + await submitPrompt(server!, base, sid, 'spawn-child now'); + await idle(server!, base, sid); + + const liveBefore = getLiveSessionById(server!.core.accessor, sid); + expect(liveBefore).toBeDefined(); + const childIds = liveBefore!.accessor + .get(IAgentLifecycleService) + .list() + .map((agent) => agent.agentId) + .filter((id) => id !== MAIN_AGENT_ID); + expect(childIds).toHaveLength(1); + childAgentId = childIds[0]; + + await server!.close(); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home!, logLevel: 'silent' }); + base = `http://127.0.0.1:${server.port}`; + + const resumed = await resumeSessionById(server.core.accessor, sid); + expect(resumed).toBeDefined(); + const agents = resumed!.accessor.get(IAgentLifecycleService); + expect(agents.handleOf(childAgentId!)).toBeUndefined(); + + await submitPrompt(server, base, sid, 'resume-child now'); + await idle(server, base, sid); + + expect(resumedChildRequest).toBeDefined(); + expect(resumedChildRequest).toContain('quartz-7731'); + expect(resumedChildRequest).toContain('ok, remembered'); + expect(agents.handleOf(childAgentId!)).toBeDefined(); + + const end = await getTranscript(server, base, sid); + const agentFrames = end.items + .filter((i) => i.kind === 'turn') + .flatMap((t: any) => t.steps) + .flatMap((s: any) => s.frames) + .filter((f: any) => f.kind === 'tool' && f.name === 'Agent'); + expect(agentFrames).toHaveLength(2); + expect(String(agentFrames[1].output)).toContain(`agent_id: ${childAgentId}`); + expect(String(agentFrames[1].output)).toContain('the token is quartz-7731'); + }, 60000); }, 90000); From 5d093634d21cdbc5028ae694ae0343d14da09d13 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 2 Sep 2026 22:26:59 +0800 Subject: [PATCH 3/5] fix(agent-core-v2): keep pinned permission modes when rebuilding a subagent for resume Tower workers are pinned to auto mode at spawn and skipped by the permission-mode broadcast, but the rebuild path synced every restored subagent to the caller's mode, so a dead worker recovered through Agent(resume=...) after a restart lost its pinned mode. The exception is now a single predicate next to the worker profile, shared by the broadcast and the rebuild. --- .../src/agent/tools/agent/agentTool.ts | 5 +- .../agent-core-v2/src/features/tower/tower.ts | 4 ++ .../agentLifecycle/agentLifecycleService.ts | 7 +-- packages/agent-core-v2/test/tool/tool.test.ts | 51 +++++++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index fac46e996e8..f6d86d02028 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -42,6 +42,7 @@ import { withoutDelegatingTargets, } from '#/app/agentProfileCatalog/profile-shared'; import { ILogService } from '#/_base/log/log'; +import { hasPinnedPermissionMode } from '#/features/tower/tower'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -391,7 +392,9 @@ export class SubagentTool implements ISubagentTool { details: { agentId }, }); } - rebuilt.accessor.get(IAgentPermissionModeService).setMode(this.permissionMode.mode); + if (!hasPinnedPermissionMode(rebuilt.accessor.get(IAgentProfileService).data().profileName)) { + rebuilt.accessor.get(IAgentPermissionModeService).setMode(this.permissionMode.mode); + } this.log.info('subagent rebuilt for resume', { agentId, callerAgentId: this.callerAgentId }); return rebuilt; } diff --git a/packages/agent-core-v2/src/features/tower/tower.ts b/packages/agent-core-v2/src/features/tower/tower.ts index 1e7e50f90d0..8c3dee0ba63 100644 --- a/packages/agent-core-v2/src/features/tower/tower.ts +++ b/packages/agent-core-v2/src/features/tower/tower.ts @@ -15,6 +15,10 @@ export const TOWER_TOOL_NAMES = [ export const TOWER_WORKER_PROFILE = 'tower-worker'; +export function hasPinnedPermissionMode(profileName: string | undefined): boolean { + return profileName === TOWER_WORKER_PROFILE; +} + export const TOWER_FLAG_ID = 'tower'; export type TowerEnterFailure = diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index a2aa18c8794..1c9698d73d9 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -19,7 +19,7 @@ import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSe import { permissionModeConfiguredKey } from '#/agent/permissionMode/permissionModeOps'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { profileKey } from '#/agent/profile/profileOps'; -import { TOWER_WORKER_PROFILE } from '#/features/tower/tower'; +import { hasPinnedPermissionMode } from '#/features/tower/tower'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; @@ -314,10 +314,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle for (const managed of this.roster.values()) { if (managed.closing || !managed.active) continue; const handle = managed.handle; - if ( - handle.accessor.get(IAgentStateService).get(profileKey).profileName === - TOWER_WORKER_PROFILE - ) { + if (hasPinnedPermissionMode(handle.accessor.get(IAgentStateService).get(profileKey).profileName)) { continue; } handle.accessor.get(IAgentPermissionModeService).setMode(mode); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index b82383f6e21..572261320c1 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -101,6 +101,7 @@ import { import { executeTool } from '../tools/fixtures/execute-tool'; import { stubAgentContext } from '../agent/agentContext/stubs'; import { agentContextOf } from '#/agent/scopeContext/scopeContext'; +import { TOWER_WORKER_PROFILE } from '#/features/tower/tower'; const signal = new AbortController().signal; @@ -2507,6 +2508,56 @@ describe('Agent tool execution contract', () => { ); }); + it('keeps a rebuilt tower worker on its pinned permission mode', async () => { + const setMode = vi.fn(); + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'worker resumed' }), + handleServices: new Map>([ + [ + 'agent-existing', + new Map([ + [ + IAgentProfileService, + { + _serviceBrand: undefined, + data: () => ({ profileName: TOWER_WORKER_PROFILE }), + update: () => {}, + republishStatus: () => {}, + getEffectiveThinkingLevel: () => 'off', + getActiveToolNames: () => [], + isToolActive: () => false, + }, + ], + [ + IAgentPermissionModeService, + { _serviceBrand: undefined, mode: 'auto', setMode, onDidChangeMode: Event.None }, + ], + ]), + ], + ]), + }); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ + 'agent-existing': { labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE } }, + }), + ), + ); + context.get(IAgentPermissionModeService).setMode('manual'); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(result).toEqual({ output: expect.stringContaining(`actual_subagent_type: ${TOWER_WORKER_PROFILE}`) }); + expect(setMode).not.toHaveBeenCalled(); + expect(lifecycle.run).toHaveBeenCalledOnce(); + }); + it('rejects direct resume of a non-subagent', async () => { const lifecycle = createAgentLifecycleStub(); const context = createAgentToolContext( From 0748bff8717f9b11e98374d4b0778854482a52ff Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 2 Sep 2026 22:56:25 +0800 Subject: [PATCH 4/5] fix(agent-core-v2): record subagent profiles in the lifecycle instead of at each spawn site The persisted profile name that offline resumes use for display and permission-rule matching was written by three spawn sites, and the session-init child was still missing it. The lifecycle now records it centrally: create() takes the profile from the binding it is given and fork() takes the override or the source agent's profile, so every subagent spawn is covered and the spawn sites no longer pass it. --- .../src/agent/tools/agent/agentTool.ts | 2 +- .../swarm/session/sessionSwarmService.ts | 5 +- .../features/tower/tools/spawn/spawnTool.ts | 2 +- .../agentLifecycle/agentLifecycleService.ts | 12 +++-- .../agentLifecycle/subagentMetadata.ts | 13 +++-- .../test/features/swarm/sessionSwarm.test.ts | 2 +- .../features/tower/tools/spawnTool.test.ts | 16 +++--- .../agentLifecycle/agentLifecycle.test.ts | 50 +++++++++++++++++++ packages/agent-core-v2/test/tool/tool.test.ts | 21 -------- 9 files changed, 79 insertions(+), 44 deletions(-) diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index f6d86d02028..159dbf48a9c 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -306,7 +306,7 @@ export class SubagentTool implements ISubagentTool { const spawned = await this.subagents.spawn({ callerAgentId: this.callerAgentId, plan, - labels: subagentLabels(this.callerAgentId, { profileName: plan.profileName }), + labels: subagentLabels(this.callerAgentId), prompt: args.prompt, }); agentId = spawned.agentId; diff --git a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts index 1b55ce05ed4..9bc8b30d317 100644 --- a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts @@ -114,10 +114,7 @@ export class SessionSwarmService implements ISessionSwarmService { const spawned = await this.subagents.spawn({ callerAgentId, plan, - labels: subagentLabels(callerAgentId, { - swarmItem: options.swarmItem, - profileName: plan.profileName, - }), + labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }), prompt: options.prompt, }); emitAgentRunSpawned(caller, spawned.agentId, { diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts index ce44ea88773..7ab16708351 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts @@ -306,7 +306,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { model: binding?.model, thinking: resolveSubagentThinking(this.config, model, binding?.thinking), }, - labels: subagentLabels(this.callerAgentId, { profileName: TOWER_WORKER_PROFILE }), + labels: subagentLabels(this.callerAgentId), }); } catch (error) { throw binding === undefined diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 1c9698d73d9..6afd6746215 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -23,6 +23,7 @@ import { hasPinnedPermissionMode } from '#/features/tower/tower'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { withSubagentProfile } from '#/session/agentLifecycle/subagentMetadata'; import { agentContextOf, IAgentScopeContext, @@ -193,7 +194,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle type: agentId === 'main' ? 'main' : 'sub', parentAgentId: agentId === 'main' ? undefined : 'main', forkedFrom: opts.forkedFrom, - labels: opts.labels, + labels: withSubagentProfile( + opts.labels, + agentId === 'main' ? undefined : opts.binding?.profile, + ), }); this.onDidCreateEmitter.fire(agent); didCreate = true; @@ -263,17 +267,17 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle }); } const source = sourceManaged.handle; + const sourceData = source.accessor.get(IAgentProfileService).data(); + const override = opts?.binding; const childContext = await this.create({ agentId: opts?.agentId, runtimeId: source.accessor.get(IAgentRuntimeBindingService).current.runtimeId, forkedFrom: source.id, - labels: opts?.labels, + labels: withSubagentProfile(opts?.labels, override?.profile ?? sourceData.profileName), }); const child = this.requireManaged(childContext).handle; - const sourceData = source.accessor.get(IAgentProfileService).data(); const childProfile = child.accessor.get(IAgentProfileService); - const override = opts?.binding; if (override?.profile !== undefined) { await childProfile.bind({ profile: override.profile, diff --git a/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts b/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts index a8fb240b1ec..65908c6c383 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts @@ -2,18 +2,23 @@ import type { AgentMeta } from '#/session/sessionMetadata/sessionMetadata'; export function subagentLabels( parentAgentId: string, - options: { readonly swarmItem?: string; readonly profileName?: string } = {}, + options: { readonly swarmItem?: string } = {}, ): Readonly> { const labels: Record = { parentAgentId }; if (options.swarmItem !== undefined) { labels['swarmItem'] = options.swarmItem; } - if (options.profileName !== undefined) { - labels['profileName'] = options.profileName; - } return labels; } +export function withSubagentProfile( + labels: Readonly> | undefined, + profileName: string | undefined, +): Readonly> | undefined { + if (profileName === undefined || profileName.length === 0) return labels; + return { ...labels, profileName }; +} + export function labelsFromAgentMeta( meta: AgentMeta, ): Readonly> | undefined { diff --git a/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts index a9ce533684e..98d39fcd95f 100644 --- a/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/features/swarm/sessionSwarm.test.ts @@ -1000,7 +1000,7 @@ describe('SessionSwarmService metadata compatibility', () => { expect(spawnAgent).toHaveBeenCalledWith({ callerAgentId: 'main', plan: { profileName: 'coder', model: 'kimi-test', thinking: 'medium', fork: false }, - labels: { parentAgentId: 'main', swarmItem: 'src/a.ts', profileName: 'coder' }, + labels: { parentAgentId: 'main', swarmItem: 'src/a.ts' }, prompt: 'Review the file', }); }); diff --git a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts index 6741f68c7fc..c6c56dd2dbd 100644 --- a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts +++ b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts @@ -16,7 +16,7 @@ import type { AgentContext } from '#/agent/agentContext/agentContext'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentTaskService } from '#/agent/task/task'; import { TowerStore } from '#/features/tower/protocol/index'; -import { IAgentTowerService, TOWER_WORKER_PROFILE } from '#/features/tower/tower'; +import { IAgentTowerService } from '#/features/tower/tower'; import { ITowerRateLimitService } from '#/features/tower/towerRateLimit'; import { SubagentTask } from '#/agent/tools/agent/subagent-task'; import { ITowerSpawnTool, type TowerSpawnToolInput } from '#/features/tower/tools/spawn/spawn'; @@ -302,7 +302,7 @@ describe('TowerSpawnTool', () => { expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'kimi-code', thinking: 'off' }, - labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, + labels: { parentAgentId: 'main' }, }); expect(runAgent).toHaveBeenCalledWith( expect.objectContaining({ agentId: 'agent-7' }), @@ -352,7 +352,7 @@ describe('TowerSpawnTool', () => { expect(result.output).toContain('model: cheap/fast'); expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: undefined }, - labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, + labels: { parentAgentId: 'main' }, }); const activityLog = await readFile(join(repo, '.tower/comms/log/activity.log'), 'utf8'); expect(activityLog).toMatch(/spawn .*model=cheap\/fast/); @@ -366,7 +366,7 @@ describe('TowerSpawnTool', () => { expect(result.isError).toBeUndefined(); expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: 'low' }, - labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, + labels: { parentAgentId: 'main' }, }); }); @@ -383,7 +383,7 @@ describe('TowerSpawnTool', () => { expect(result.isError).toBeUndefined(); expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: 'max' }, - labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, + labels: { parentAgentId: 'main' }, }); }); @@ -401,7 +401,7 @@ describe('TowerSpawnTool', () => { expect(result.isError).toBeUndefined(); expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: undefined }, - labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, + labels: { parentAgentId: 'main' }, }); }); @@ -427,7 +427,7 @@ describe('TowerSpawnTool', () => { expect(result.output).toContain('model: kimi-code'); expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'kimi-code', thinking: 'off' }, - labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, + labels: { parentAgentId: 'main' }, }); }); @@ -444,7 +444,7 @@ describe('TowerSpawnTool', () => { expect(result.output).toContain('model: cheap/fast'); expect(createAgent).toHaveBeenCalledWith({ binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: undefined }, - labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE }, + labels: { parentAgentId: 'main' }, }); }); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index e7ae4327524..f8895d79ce0 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -1257,6 +1257,56 @@ describe('AgentLifecycleService', () => { ); }); + it('records the bound profile in the subagent metadata at registration', async () => { + const svc = ix.get(IAgentLifecycleService); + + await svc + .create({ + agentId: 'child', + binding: { profile: 'coder', model: 'kimi-test' }, + labels: { parentAgentId: 'main' }, + }) + .catch(() => undefined); + + expect(registerAgent).toHaveBeenCalledWith( + 'child', + expect.objectContaining({ + type: 'sub', + labels: { parentAgentId: 'main', profileName: 'coder' }, + }), + ); + }); + + it('fork records the inherited profile in the subagent metadata', async () => { + const svc = ix.get(IAgentLifecycleService); + const source = await svc.create({ agentId: 'main' }); + svc.handleOf('main')!.accessor.get(IAgentProfileService).applyBindingSnapshot({ + profileName: 'coder', + thinkingLevel: 'off', + systemPrompt: 'coder prompt', + activeToolNames: ['Read'], + disallowedTools: [], + subagents: undefined, + }); + + await svc.fork(agentContextOf(svc.handleOf(source.agentId)!), { + agentId: 'forked', + labels: { parentAgentId: 'main' }, + }); + + expect(registerAgent).toHaveBeenCalledWith( + 'forked', + expect.objectContaining({ + forkedFrom: 'main', + labels: { parentAgentId: 'main', profileName: 'coder' }, + }), + ); + expect(registerAgent).toHaveBeenCalledWith( + 'main', + expect.objectContaining({ type: 'main', labels: undefined }), + ); + }); + it('run throws when the agent does not exist', () => { ix.set(ISessionSubagentService, new SyncDescriptor(SessionSubagentService)); const svc = ix.get(ISessionSubagentService); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 572261320c1..78a555f9a62 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -1603,27 +1603,6 @@ describe('Agent tool execution contract', () => { expect(lifecycle.list).toHaveBeenCalled(); }); - it('records the spawned profile in the subagent labels', async () => { - const lifecycle = createAgentLifecycleStub({ - createAgentIds: ['agent-child'], - runCompletion: async () => ({ summary: 'child result' }), - }); - const context = createAgentToolContext(lifecycle); - - const result = await executeAgentTool(context, { - prompt: 'Investigate', - description: 'Find cause', - subagent_type: 'explore', - }); - - expect(result.isError).not.toBe(true); - expect(lifecycle.create).toHaveBeenCalledWith( - expect.objectContaining({ - labels: { parentAgentId: 'main', profileName: 'explore' }, - }), - ); - }); - it('uses the persisted profile of an offline subagent for display and approval rules', async () => { const lifecycle = createAgentLifecycleStub(); const context = createAgentToolContext( From 832cfb8b50865b922395ac027b5d69772e479f4d Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 2 Sep 2026 23:17:41 +0800 Subject: [PATCH 5/5] chore: merge the subagent-resume changesets into one --- .changeset/resume-subagent-after-restart.md | 2 +- .changeset/resumed-subagent-permissions.md | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/resumed-subagent-permissions.md diff --git a/.changeset/resume-subagent-after-restart.md b/.changeset/resume-subagent-after-restart.md index d2b8ac133f9..89e84dca36f 100644 --- a/.changeset/resume-subagent-after-restart.md +++ b/.changeset/resume-subagent-after-restart.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Resuming a subagent by its agent id now works after the session is reopened in a new process. +Resuming a subagent by its agent id now works after the session is reopened in a new process; the resumed subagent follows the current permission mode and is matched by its own profile in permission rules. diff --git a/.changeset/resumed-subagent-permissions.md b/.changeset/resumed-subagent-permissions.md deleted file mode 100644 index ed7a0112504..00000000000 --- a/.changeset/resumed-subagent-permissions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Subagents resumed after a restart now follow the current permission mode and are matched by their own profile in permission rules.