Skip to content
5 changes: 5 additions & 0 deletions .changeset/resume-subagent-after-restart.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 34 additions & 14 deletions packages/agent-core-v2/src/agent/tools/agent/agentTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -346,12 +345,15 @@ export class SubagentTool implements ISubagentTool {
};
}

private async ensureOwnedIdleSubagent(
agentId: string,
target: IAgentScopeHandle,
): Promise<void> {
private async resolveResumeTarget(agentId: string): Promise<IAgentScopeHandle> {
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 },
});
Expand All @@ -363,13 +365,31 @@ export class SubagentTool implements ISubagentTool {
{ details: { agentId, callerAgentId: this.callerAgentId } },
);
}
const target = live ?? (await this.rebuildSubagent(agentId, meta));
Comment thread
RealKai42 marked this conversation as resolved.
if (target.accessor.get(IAgentLoopService).status().state === 'running') {
throw new Error2(
ErrorCodes.AGENT_ALREADY_RUNNING,
`Agent instance "${agentId}" is already running and cannot run concurrently`,
{ details: { agentId } },
);
}
return target;
}

private async rebuildSubagent(agentId: string, meta: AgentMeta): Promise<IAgentScopeHandle> {
await this.agentLifecycle.create({
agentId,
labels: labelsFromAgentMeta(meta),
forkedFrom: meta.forkedFrom,
});
Comment thread
RealKai42 marked this conversation as resolved.
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(
Expand Down
86 changes: 86 additions & 0 deletions packages/agent-core-v2/test/tool/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
});
Comment thread
RealKai42 marked this conversation as resolved.
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(
Expand Down
52 changes: 52 additions & 0 deletions packages/agent-core-v2/test/wire/resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <system-prompt>
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 "<system>ERROR: Tool execution failed.</system>\\nTool execution was interrupted before its result was recorded. Do not assume the tool completed successfully."
user: text "Fresh prompt after resume"
user: text <date-reminder>
`);
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});

it('does not reconcile a legacy interruption whose delivery was recorded', async () => {
const persistence = new RecordingAgentPersistence([
resumeConfigRecord(),
Expand Down
Loading