fix(agent-core): realign mid-history interrupted tool calls on resume (#1027)

This commit is contained in:
_Kerman 2026-06-23 22:39:19 +08:00 committed by GitHub
parent 9d197e0f67
commit c240bfab7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 614 additions and 2 deletions

View file

@ -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.

View file

@ -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',
@ -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();
const message: ContextMessage = {
role: 'assistant',
content: [],
@ -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,

View file

@ -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;
@ -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();
@ -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,
@ -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',

View file

@ -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 },
@ -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: {
@ -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: [

View file

@ -686,6 +686,400 @@ describe('Agent resume', () => {
expect(textContent(resumedAgain.agent.context.history[4])).toBe('continue after resume');
});
it('closes an interrupted tool call mid-history so later turns stay aligned', async () => {
// An interrupted tool call (`call_interrupted`) sits in the MIDDLE of the
// recorded stream: a later user prompt and a fully-run assistant turn follow
// it. Without in-place reconciliation the unresolved exchange keeps
// `hasOpenToolExchange` true, stranding the later user prompt in
// `deferredMessages` and only aligning the trailing turn.
const persistence = new RecordingAgentPersistence([
{
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text: 'Run the lookup' }],
toolCalls: [],
origin: { kind: 'user' },
},
},
{
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 'interrupted-step', turnId: '0', step: 1 },
},
{
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call-interrupted',
turnId: '0',
step: 1,
stepUuid: 'interrupted-step',
toolCallId: 'call_interrupted',
name: 'Lookup',
args: { query: 'one' },
},
},
// Recorded while the interrupted exchange was still open, so live deferral
// captured it after the unresolved tool call.
{
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text: 'keep going' }],
toolCalls: [],
origin: { kind: 'user' },
},
},
...loopEventsForTurn('1', 'All done.'),
]);
const ctx = testAgent({ persistence });
await ctx.agent.resume();
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
'user',
'assistant',
'tool',
'user',
'assistant',
]);
// The synthetic result is spliced in place (index 2), directly after the
// interrupted assistant step — not flushed to the tail.
const synthetic = ctx.agent.context.history[2];
expect(synthetic).toMatchObject({
role: 'tool',
toolCallId: 'call_interrupted',
isError: true,
});
expect(textContent(synthetic)).toContain(
'Tool execution was interrupted before its result was recorded',
);
// The deferred user prompt is restored in its recorded position, between the
// closed exchange and the following turn.
expect(textContent(ctx.agent.context.history[3])).toBe('keep going');
expect(textContent(ctx.agent.context.history[4])).toBe('All done.');
expect(ctx.agent.context.messages.map((message) => message.role)).toEqual([
'user',
'assistant',
'tool',
'user',
'assistant',
]);
// Option A: the mid-history result is re-derived on every resume and is not
// persisted as a positioned record (replay logging is suppressed).
expect(
persistence.appended.filter(
(record) =>
record.type === 'context.append_loop_event' && record.event.type === 'tool.result',
),
).toEqual([]);
await ctx.expectResumeMatches();
});
it('drops a stale tail interrupted result already closed in place on resume', async () => {
// Legacy log: an older tail-only finishResume appended the synthetic result
// for `call_interrupted` at the END of the stream (after the later turn from
// the deferral avalanche). The new in-place closure handles it at step.begin,
// so the trailing persisted copy must be dropped rather than duplicated.
const persistence = new RecordingAgentPersistence([
{
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text: 'Run the lookup' }],
toolCalls: [],
origin: { kind: 'user' },
},
},
{
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 'interrupted-step', turnId: '0', step: 1 },
},
{
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call-interrupted',
turnId: '0',
step: 1,
stepUuid: 'interrupted-step',
toolCallId: 'call_interrupted',
name: 'Lookup',
args: { query: 'one' },
},
},
{
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text: 'keep going' }],
toolCalls: [],
origin: { kind: 'user' },
},
},
...loopEventsForTurn('1', 'All done.'),
// The stale synthetic result an older resume appended at the tail.
{
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_interrupted',
toolCallId: 'call_interrupted',
result: {
output:
'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.',
isError: true,
},
},
},
]);
const ctx = testAgent({ persistence });
await ctx.agent.resume();
// The trailing duplicate is dropped: exactly one synthetic result, in place.
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
'user',
'assistant',
'tool',
'user',
'assistant',
]);
expect(ctx.agent.context.history[2]).toMatchObject({
role: 'tool',
toolCallId: 'call_interrupted',
isError: true,
});
expect(textContent(ctx.agent.context.history[4])).toBe('All done.');
await ctx.expectResumeMatches();
});
it('closes every open call of a multi-call interrupted step in order', async () => {
const persistence = new RecordingAgentPersistence([
{
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text: 'Run both' }],
toolCalls: [],
origin: { kind: 'user' },
},
},
{
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 'interrupted-step', turnId: '0', step: 1 },
},
...['call_a', 'call_b'].map((toolCallId) => ({
type: 'context.append_loop_event' as const,
event: {
type: 'tool.call' as const,
uuid: toolCallId,
turnId: '0',
step: 1,
stepUuid: 'interrupted-step',
toolCallId,
name: 'Lookup',
args: {},
},
})),
...loopEventsForTurn('1', 'All done.'),
]);
const ctx = testAgent({ persistence });
await ctx.agent.resume();
// Both open calls get a synthetic result, in tool-call order, before the
// next turn.
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
'user',
'assistant',
'tool',
'tool',
'assistant',
]);
expect(ctx.agent.context.history[2]).toMatchObject({
role: 'tool',
toolCallId: 'call_a',
isError: true,
});
expect(ctx.agent.context.history[3]).toMatchObject({
role: 'tool',
toolCallId: 'call_b',
isError: true,
});
await ctx.expectResumeMatches();
});
it('synthesizes only the unresolved call when a step is partially resolved', async () => {
const persistence = new RecordingAgentPersistence([
{
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text: 'Run both' }],
toolCalls: [],
origin: { kind: 'user' },
},
},
{
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 'interrupted-step', turnId: '0', step: 1 },
},
...['call_done', 'call_open'].map((toolCallId) => ({
type: 'context.append_loop_event' as const,
event: {
type: 'tool.call' as const,
uuid: toolCallId,
turnId: '0',
step: 1,
stepUuid: 'interrupted-step',
toolCallId,
name: 'Lookup',
args: {},
},
})),
{
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_done',
toolCallId: 'call_done',
result: { output: 'real result' },
},
},
...loopEventsForTurn('1', 'All done.'),
]);
const ctx = testAgent({ persistence });
await ctx.agent.resume();
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
'user',
'assistant',
'tool',
'tool',
'assistant',
]);
// The recorded result is kept verbatim; only the open call is synthesized.
expect(ctx.agent.context.history[2]).toMatchObject({ toolCallId: 'call_done' });
expect(textContent(ctx.agent.context.history[2])).toBe('real result');
expect(ctx.agent.context.history[3]).toMatchObject({
toolCallId: 'call_open',
isError: true,
});
expect(textContent(ctx.agent.context.history[3])).toContain(
'Tool execution was interrupted before its result was recorded',
);
await ctx.expectResumeMatches();
});
it('closes consecutive interrupted steps each at their own boundary', async () => {
const persistence = new RecordingAgentPersistence([
{
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text: 'Go' }],
toolCalls: [],
origin: { kind: 'user' },
},
},
// First interrupted step.
{
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 'step-1', turnId: '0', step: 1 },
},
{
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_one',
turnId: '0',
step: 1,
stepUuid: 'step-1',
toolCallId: 'call_one',
name: 'Lookup',
args: {},
},
},
// Second interrupted step (closes the first in place at its step.begin).
{
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 'step-2', turnId: '1', step: 1 },
},
{
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_two',
turnId: '1',
step: 1,
stepUuid: 'step-2',
toolCallId: 'call_two',
name: 'Lookup',
args: {},
},
},
// Final fully-run turn (closes the second in place).
...loopEventsForTurn('2', 'Done.'),
]);
const ctx = testAgent({ persistence });
await ctx.agent.resume();
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
'user',
'assistant',
'tool',
'assistant',
'tool',
'assistant',
]);
expect(ctx.agent.context.history[2]).toMatchObject({ toolCallId: 'call_one', isError: true });
expect(ctx.agent.context.history[4]).toMatchObject({ toolCallId: 'call_two', isError: true });
await ctx.expectResumeMatches();
});
it('drops an orphan tool result whose call was never recorded', async () => {
const persistence = new RecordingAgentPersistence([
{
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text: 'Hi' }],
toolCalls: [],
origin: { kind: 'user' },
},
},
...loopEventsForTurn('0', 'Hello.'),
// A result with no matching tool.call (e.g. its call was compacted away).
{
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'ghost',
toolCallId: 'call_ghost',
result: { output: 'orphaned' },
},
},
]);
const ctx = testAgent({ persistence });
await ctx.agent.resume();
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
'user',
'assistant',
]);
expect(
ctx.agent.context.history.some((message) => message.role === 'tool'),
).toBe(false);
await ctx.expectResumeMatches();
});
it('rebuilds goal completion replay cards without adding model-visible context', async () => {
const persistence = new RecordingAgentPersistence([
{

View file

@ -192,9 +192,141 @@ describe('reduceWireRecords', () => {
expect(textOf(entries[1]!.message)).toBe('ok');
});
it('closes a tool call interrupted mid-history at the next step.begin', () => {
const { entries, foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }),
loopEvent({
type: 'tool.call',
uuid: 'c1',
turnId: 't',
step: 0,
stepUuid: 's1',
toolCallId: 'call_interrupted',
name: 'Lookup',
args: { query: 'one' },
}),
// Recorded while the exchange was open, so it was deferred live.
appendMessage(userMessage('keep going')),
...assistantStep('s2', 'a2'),
]);
expect(entries.map((e) => e.message.role)).toEqual([
'user',
'assistant',
'tool',
'user',
'assistant',
]);
// Synthetic result spliced in place (index 2), before the deferred prompt.
expect(entries[2]!.message.toolCallId).toBe('call_interrupted');
expect(entries[2]!.message.isError).toBe(true);
expect(textOf(entries[2]!.message)).toBe(
'<system>ERROR: Tool execution failed.</system>\n' +
'Tool execution was interrupted before its result was recorded. ' +
'Do not assume the tool completed successfully.',
);
expect(textOf(entries[3]!.message)).toBe('keep going');
expect(textOf(entries[4]!.message)).toBe('a2');
expect(foldedLength).toBe(5);
});
it('drops a stale tail interrupted result already closed in place', () => {
const { entries, foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }),
loopEvent({
type: 'tool.call',
uuid: 'c1',
turnId: 't',
step: 0,
stepUuid: 's1',
toolCallId: 'call_interrupted',
name: 'Lookup',
args: { query: 'one' },
}),
appendMessage(userMessage('keep going')),
...assistantStep('s2', 'a2'),
// The stale synthetic result an older tail-only resume appended.
loopEvent({
type: 'tool.result',
parentUuid: 'call_interrupted',
toolCallId: 'call_interrupted',
result: {
output:
'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.',
isError: true,
},
}),
]);
expect(entries.map((e) => e.message.role)).toEqual([
'user',
'assistant',
'tool',
'user',
'assistant',
]);
expect(entries[2]!.message.toolCallId).toBe('call_interrupted');
expect(foldedLength).toBe(5);
});
it('closes every open call of a multi-call interrupted step, keeping foldedLength aligned', () => {
const { entries, foldedLength } = reduceWireRecords([
loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }),
...['call_a', 'call_b'].map((toolCallId) =>
loopEvent({
type: 'tool.call',
uuid: toolCallId,
turnId: 't',
step: 0,
stepUuid: 's1',
toolCallId,
name: 'Run',
args: {},
}),
),
...assistantStep('s2', 'a2'),
]);
expect(entries.map((e) => e.message.role)).toEqual([
'assistant',
'tool',
'tool',
'assistant',
]);
expect(entries[1]!.message.toolCallId).toBe('call_a');
expect(entries[2]!.message.toolCallId).toBe('call_b');
expect(foldedLength).toBe(4);
});
it('drops an orphan tool result whose call was never recorded', () => {
const { entries, foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
...assistantStep('s1', 'a1'),
loopEvent({
type: 'tool.result',
parentUuid: 'ghost',
toolCallId: 'call_ghost',
result: { output: 'orphaned' },
}),
]);
expect(entries.map((e) => e.message.role)).toEqual(['user', 'assistant']);
expect(foldedLength).toBe(2);
});
it('wraps tool errors and empty outputs with <system> statuses like agent-core', () => {
const { entries } = reduceWireRecords([
loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }),
...['call_err', 'call_empty'].map((toolCallId) =>
loopEvent({
type: 'tool.call',
uuid: toolCallId,
turnId: 't',
step: 0,
stepUuid: 's1',
toolCallId,
name: 'Run',
args: {},
}),
),
loopEvent({
type: 'tool.result',
parentUuid: 's1',