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/fix-mid-history-interrupted-tool-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix resume not realigning a tool call that was interrupted mid-history. The synthetic interrupted result is now closed in place at the next step boundary, so later turns and deferred messages keep their recorded order instead of only the trailing exchange being repaired. The `/messages` wire transcript reducer mirrors the same closure so its folded length stays aligned with live history, preventing the later turn from being duplicated/reordered. Replay also drops a tool result whose call is no longer awaiting one, so a stale interrupted result left at the log tail by an older resume of a damaged session is not re-applied as a duplicate.
23 changes: 21 additions & 2 deletions packages/agent-core/src/agent/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,20 @@ export class ContextMemory {
}

finishResume(): void {
const interruptedToolCallIds = [...this.pendingToolResultIds];
this.openSteps.clear();
if (interruptedToolCallIds.length === 0) return;
this.closePendingToolResults();
}

// Synthesize interrupted tool results for any still-open tool calls, closing
// the exchange in place. Called at every replayed step boundary (see the
// `step.begin` case) so a tool call left unresolved mid-history is closed
// exactly where it occurred — otherwise it would keep `hasOpenToolExchange`
// true and strand every later message in `deferredMessages`, so only the
// trailing exchange ends up aligned. `finishResume` runs the same routine once
// more to close a genuine trailing interruption at end of resume.
private closePendingToolResults(): void {
if (this.pendingToolResultIds.size === 0) return;
const interruptedToolCallIds = [...this.pendingToolResultIds];
for (const toolCallId of interruptedToolCallIds) {
this.appendLoopEvent({
type: 'tool.result',
Expand All @@ -251,6 +261,11 @@ export class ContextMemory {
});
switch (event.type) {
case 'step.begin': {
// A new assistant step means any tool calls still pending from an
// earlier step were interrupted (the invariant guarantees this never
// happens live, so this is a no-op outside replay). Close them in place
// before opening the new step so mid-history gaps stay aligned.
this.closePendingToolResults();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mirror mid-history closure in wire transcripts

This adds the new replay-time behavior to close a pending tool call before the next step.begin, but the daemon message reducer in packages/agent-core/src/services/message/transcript.ts still treats step.begin as a plain assistant append and never clears pendingToolResultIds. Because MessageService._getTranscriptEntries resumes first and then merges context.history.slice(transcript.foldedLength), a session matching the new mid-history interruption case will make /messages include the later assistant from the wire, then append the live tail from the wrong folded index, duplicating/reordering the later turn and omitting the synthetic tool error. Please mirror this closePendingToolResults() behavior in reduceWireRecords when reducing a new step.begin.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip previously persisted interrupted results

When a damaged session has already been resumed by the previous tail-only logic, finishResume will have appended the synthetic interrupted tool.result at the end of wire.jsonl. On the next resume this new step.begin path synthesizes an in-place result for the same toolCallId, but the old tail record is still replayed later because tool.result appends even when the id is no longer pending, leaving a duplicate/orphan tool message in live history (and the mirrored /messages reducer has the same failure mode). This affects the legacy logs this repair is meant to recover, so the replay needs to detect and skip the previously persisted interrupted result for an already-closed id.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Teach raw wire views about in-place tool repair

When this runs during AgentRecords.restore, logRecord suppresses the synthetic tool.result, so mid-history interrupted calls are repaired only in memory and never appear in wire.jsonl. The /messages reducer was updated, but I checked apps/vis/server/src/lib/context-projector.ts and it still creates tool messages only from actual ev.type === 'tool.result' records, so the visual context endpoint will show an assistant tool call with no interrupted-error result after a resumed damaged session. Please either persist/rewrite the positioned repair record or update the raw wire projector(s) to mirror this same closure.

Useful? React with 👍 / 👎.

const message: ContextMessage = {
role: 'assistant',
content: [],
Expand Down Expand Up @@ -316,6 +331,10 @@ export class ContextMemory {
return;
}
case 'tool.result': {
// Drop a result for an id that is not awaiting one: it was already
// closed in place at a step boundary (a stale duplicate from an older
// tail-only finishResume), or its call is gone.
if (!this.pendingToolResultIds.has(event.toolCallId)) return;
const message = createToolMessage(event.toolCallId, toolResultOutputForModel(event.result));
this.pushHistory({
...message,
Expand Down
29 changes: 29 additions & 0 deletions packages/agent-core/src/services/message/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const TOOL_EMPTY_STATUS = '<system>Tool output is empty.</system>';
const TOOL_EMPTY_ERROR_STATUS =
'<system>ERROR: Tool execution failed. Tool output is empty.</system>';
const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.';
const TOOL_INTERRUPTED_ON_RESUME_OUTPUT =
'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.';

export interface TranscriptEntry {
readonly message: ContextMessage;
Expand Down Expand Up @@ -116,6 +118,29 @@ export function reduceWireRecords(records: Iterable<AgentRecord>): {
push(...deferred);
deferred = [];
};
// ContextMemory closes these during replay without persisting the synthetic
// result, so the reducer must reconstruct it to keep foldedLength aligned.
const closePendingToolResults = (time: number | undefined): void => {
if (pendingToolResultIds.size === 0) return;
const interruptedToolCallIds = [...pendingToolResultIds];
for (const toolCallId of interruptedToolCallIds) {
push({
message: {
role: 'tool',
content: toolResultContent({
output: TOOL_INTERRUPTED_ON_RESUME_OUTPUT,
isError: true,
}),
toolCalls: [],
toolCallId,
isError: true,
},
time,
});
pendingToolResultIds.delete(toolCallId);
}
flushDeferredIfToolExchangeClosed();
};
const resetOpenState = (): void => {
openSteps.clear();
pendingToolResultIds.clear();
Expand All @@ -125,6 +150,7 @@ export function reduceWireRecords(records: Iterable<AgentRecord>): {
const applyLoopEvent = (event: LoopRecordedEvent, time: number | undefined): void => {
switch (event.type) {
case 'step.begin': {
closePendingToolResults(time);
const entry: MutableEntry = {
message: { role: 'assistant', content: [], toolCalls: [] },
time,
Expand Down Expand Up @@ -157,6 +183,9 @@ export function reduceWireRecords(records: Iterable<AgentRecord>): {
return;
}
case 'tool.result': {
// Drop a result for an id not awaiting one (already closed in place, or
// its call is gone) — mirrors ContextMemory.
if (!pendingToolResultIds.has(event.toolCallId)) return;
push({
message: {
role: 'tool',
Expand Down
33 changes: 33 additions & 0 deletions packages/agent-core/test/agent/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ describe('Agent context', () => {
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 'origin-step', turnId: '', step: 1 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'origin-tool',
turnId: '',
step: 1,
stepUuid: 'origin-step',
toolCallId: 'call_origin',
name: 'Run',
args: {},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.end', uuid: 'origin-step', turnId: '', step: 1 },
Expand Down Expand Up @@ -45,6 +58,25 @@ describe('Agent context', () => {
const ctx = testAgent();
ctx.configure();

ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 's1', turnId: 't', step: 1 },
});
for (const toolCallId of ['call_error', 'call_empty']) {
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: toolCallId,
turnId: 't',
step: 1,
stepUuid: 's1',
toolCallId,
name: 'Run',
args: {},
},
});
}
ctx.dispatch({
type: 'context.append_loop_event',
event: {
Expand All @@ -65,6 +97,7 @@ describe('Agent context', () => {
});

expect(ctx.agent.context.messages).toMatchObject([
{ role: 'assistant', toolCalls: [{ id: 'call_error' }, { id: 'call_empty' }] },
{
role: 'tool',
content: [
Expand Down
Loading
Loading