From 9d7eae979668f68cb8146cf9edfbb9f6b7c2a9da Mon Sep 17 00:00:00 2001 From: haosenwang1018 <1293965075@qq.com> Date: Mon, 1 Jun 2026 18:19:01 +0800 Subject: [PATCH] fix(agent-core): recover interrupted tool exchanges --- .../recover-interrupted-tool-exchange.md | 6 ++ .../agent-core/src/agent/context/index.ts | 23 ++++++ .../agent-core/src/agent/records/index.ts | 4 + .../test/agent/records/index.test.ts | 81 +++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 .changeset/recover-interrupted-tool-exchange.md diff --git a/.changeset/recover-interrupted-tool-exchange.md b/.changeset/recover-interrupted-tool-exchange.md new file mode 100644 index 00000000..d86ba76a --- /dev/null +++ b/.changeset/recover-interrupted-tool-exchange.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Recover resumed sessions that were interrupted after recording a tool call but before recording its tool result. diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 4b3810f9..344dd0a2 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -19,6 +19,8 @@ const TOOL_EMPTY_STATUS = 'Tool output is empty.'; const TOOL_EMPTY_ERROR_STATUS = 'ERROR: Tool execution failed. Tool output is empty.'; const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.'; +const INTERRUPTED_TOOL_RESULT = + 'Kimi Code was interrupted before this tool call could record a result. Treat this tool call as failed and continue from the latest user instruction.'; export class ContextMemory { private _history: ContextMessage[] = []; @@ -206,6 +208,27 @@ export class ContextMemory { this.pushHistory(message); } + recoverInterruptedToolExchanges(): number { + // A sealed step can intentionally keep waiting for async tool output across + // context operations. An open step at replay EOF means the process stopped + // before the loop could finish pairing recorded tool calls. + const missingToolResultIds = this.openSteps.size > 0 ? [...this.pendingToolResultIds] : []; + for (const toolCallId of missingToolResultIds) { + this.appendLoopEvent({ + type: 'tool.result', + parentUuid: toolCallId, + toolCallId, + result: { + output: INTERRUPTED_TOOL_RESULT, + isError: true, + }, + }); + } + this.openSteps.clear(); + this.flushDeferredMessagesIfToolExchangeClosed(); + return missingToolResultIds.length; + } + private flushDeferredMessagesIfToolExchangeClosed(): void { if (this.pendingToolResultIds.size > 0 || this.deferredMessages.length === 0) { return; diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index cf79270f..0df1fb46 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -185,6 +185,10 @@ export class AgentRecords { this.persistence.rewrite(replayedRecords); await this.persistence.flush(); } + const recoveredToolResults = this.agent.context.recoverInterruptedToolExchanges(); + if (recoveredToolResults > 0) { + await this.persistence.flush(); + } if (this.agent.blobStore !== undefined) { for (const msg of this.agent.context.history) { await this.agent.blobStore.rehydrateParts(msg.content); diff --git a/packages/agent-core/test/agent/records/index.test.ts b/packages/agent-core/test/agent/records/index.test.ts index a35e0a8d..bbd04fe3 100644 --- a/packages/agent-core/test/agent/records/index.test.ts +++ b/packages/agent-core/test/agent/records/index.test.ts @@ -91,6 +91,87 @@ describe('AgentRecords persistence metadata', () => { expect(persistence.records.filter((record) => record.type === 'metadata')).toHaveLength(1); }); + it('repairs orphan tool calls after replaying an interrupted session', async () => { + const stepUuid = 'interrupted-step'; + const persistence = new InMemoryAgentRecordPersistence([ + { + type: 'metadata', + protocol_version: AGENT_WIRE_PROTOCOL_VERSION, + created_at: 1, + }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'wait for task output' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: stepUuid, turnId: '0', step: 1 }, + }, + { + type: 'context.append_loop_event', + event: { + type: 'tool.call', + uuid: 'call_task_output', + turnId: '0', + step: 1, + stepUuid, + toolCallId: 'call_task_output', + name: 'TaskOutput', + args: { block: true }, + }, + }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'continue' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + ]); + const ctx = testAgent({ persistence }); + + await ctx.agent.records.replay(); + + expect(ctx.agent.context.history.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'user', + ]); + expect(ctx.agent.context.history[2]).toMatchObject({ + role: 'tool', + toolCallId: 'call_task_output', + isError: true, + }); + expect(ctx.agent.context.messages[2]?.content).toEqual([ + { + type: 'text', + text: expect.stringContaining( + 'Kimi Code was interrupted before this tool call could record a result.', + ), + }, + ]); + expect(persistence.records.at(-1)).toMatchObject({ + type: 'context.append_loop_event', + event: { + type: 'tool.result', + parentUuid: 'call_task_output', + toolCallId: 'call_task_output', + result: { + output: expect.stringContaining('interrupted before this tool call'), + isError: true, + }, + }, + }); + }); + it('does not rewrite records that already use the current wire version', async () => { const persistence = new RecordingInMemoryAgentRecordPersistence([ {