Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/fix-session-resume-incomplete-tool-calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch

---

Fix session resume failing with 400 error when previous turn was interrupted mid-tool-call.
22 changes: 22 additions & 0 deletions packages/agent-core/src/agent/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,28 @@ export class ContextMemory {
return this.pendingToolResultIds.size > 0;
}

/**
* Remove stale entries from `pendingToolResultIds` that have no matching
* tool result in the history. This happens when a session is killed
* mid-tool-call and later resumed — the tool.call events are replayed
* but the tool.result events never arrived. Without this cleanup,
* `hasOpenToolExchange()` would remain true, silently deferring all
* new user messages.
*/
cleanupOrphanedToolCalls(): void {
const answeredIds = new Set<string>();
for (const message of this._history) {
if (message.role === 'tool' && typeof message.toolCallId === 'string') {
answeredIds.add(message.toolCallId);
}
}
for (const id of this.pendingToolResultIds) {
if (!answeredIds.has(id)) {
this.pendingToolResultIds.delete(id);
}
Comment on lines +316 to +319

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 Drop orphaned assistant messages before accepting prompts

When resuming a session killed after a tool.call, this cleanup clears pendingToolResultIds but leaves the assistant message with unanswered toolCalls in _history; the next user prompt is then appended after that assistant, so project() no longer treats the open tool exchange as trailing and sends [assistant tool_calls, user] to the provider, reproducing the 400 this change is meant to avoid. In the resume-after-interrupt path, clear or trim the orphaned assistant/tool exchange from history before allowing new messages through.

Useful? React with 👍 / 👎.

}
}

private pushHistory(...messages: ContextMessage[]): void {
this._history.push(...messages);
for (const message of messages) {
Expand Down
11 changes: 9 additions & 2 deletions packages/agent-core/src/agent/context/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ export function project(history: readonly ContextMessage[]): Message[] {
!(message.role === 'assistant' && message.content.length === 0 && message.toolCalls.length === 0)
);
});
return mergeAdjacentUserMessages(usable);
// Trim any trailing assistant message whose tool_calls were never answered
// (e.g. the session was killed mid-tool-call). Sending an assistant
// message with open tool_calls violates the API contract and causes a
// 400 error on resume.
return trimTrailingOpenToolExchange(mergeAdjacentUserMessages(usable));
}

function mergeAdjacentUserMessages(history: readonly ContextMessage[]): Message[] {
Expand Down Expand Up @@ -77,8 +81,11 @@ export function trimTrailingOpenToolExchange(history: readonly Message[]): Messa
lastNonToolIndex -= 1;
}

// No assistant message found — nothing to trim.
if (lastNonToolIndex < 0) return [...history];

const assistant = history[lastNonToolIndex];
if (assistant === undefined) return [];
if (assistant === undefined) return [...history];
if (assistant.role !== 'assistant' || assistant.toolCalls.length === 0) return [...history];

const trailingToolCallIds = new Set(
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ export class Agent {
await this.background.loadFromDisk();
await this.background.reconcile();
await this.cron?.loadFromDisk();
// Clean up any tool_call IDs that were never answered (session killed
// mid-tool-call). Without this, new user messages would be silently
// deferred because `hasOpenToolExchange()` would remain true.
this.context.cleanupOrphanedToolCalls();
this.turn.finishResume();
return result;
}
Expand Down
80 changes: 80 additions & 0 deletions packages/agent-core/test/agent/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@
variant: 'host',
});

expect(ctx.agent.context.messages.map((message) => message.role)).toEqual([

Check failure on line 360 in packages/agent-core/test/agent/context.test.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/agent/context.test.ts > Agent context > preserves deferred reminders when compaction keeps a pending tool exchange

AssertionError: expected [ 'assistant', 'user' ] to deeply equal [ 'assistant', 'user', …(2) ] - Expected + Received [ "assistant", "user", - "assistant", - "tool", ] ❯ test/agent/context.test.ts:360:71
'assistant',
'user',
'assistant',
Expand Down Expand Up @@ -776,6 +776,86 @@
expect(textOf(messages[1]!)).toBe('No origin prompt');
expect(textOf(messages[2]!)).toBe('Third real prompt');
});

it('project() trims trailing assistant message with unanswered tool_calls', () => {
const history: ContextMessage[] = [
userMessage('hello'),
{
role: 'assistant',
content: [{ type: 'text', text: 'I will run a tool' }],
toolCalls: [{ type: 'function', id: 'call_1', name: 'Bash', arguments: '{}' }],
},
// No tool result for call_1 — session was killed.
];
const messages = project(history);
// The assistant message with open tool_calls should be trimmed.
expect(messages).toHaveLength(1);
expect(messages[0]!.role).toBe('user');
});

it('project() keeps assistant message when all tool_calls are answered', () => {
const history: ContextMessage[] = [
userMessage('hello'),
{
role: 'assistant',
content: [{ type: 'text', text: 'I will run a tool' }],
toolCalls: [{ type: 'function', id: 'call_1', name: 'Bash', arguments: '{}' }],
},
{
role: 'tool',
content: [{ type: 'text', text: 'tool output' }],
toolCalls: [],
toolCallId: 'call_1',
},
];
const messages = project(history);
// All three messages should be present.
expect(messages).toHaveLength(3);
expect(messages[1]!.role).toBe('assistant');
expect(messages[2]!.role).toBe('tool');
});

it('cleanupOrphanedToolCalls clears stale pendingToolResultIds after resume', () => {
const ctx = testAgent();
ctx.configure();

// Simulate a tool.call event that never got a tool.result (session killed).
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 'step-orphan', turnId: '', step: 1 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'tool-orphan',
stepUuid: 'step-orphan',
turnId: '',
step: 1,
toolCallId: 'orphan_call',
name: 'Bash',
args: {},
},
});

// The orphaned tool call should block new messages.
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'follow up' }]);
// The message should be deferred, not in history.
const historyBefore = ctx.agent.context.history.filter(
(m) => m.role === 'user' && m.content.some((p) => p.type === 'text' && 'text' in p && p.text === 'follow up'),
);
expect(historyBefore).toHaveLength(0);

// Now cleanup the orphaned tool calls.
ctx.agent.context.cleanupOrphanedToolCalls();

// After cleanup, new messages should go through.
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'after cleanup' }]);
const historyAfter = ctx.agent.context.history.filter(
(m) => m.role === 'user' && m.content.some((p) => p.type === 'text' && 'text' in p && p.text === 'after cleanup'),
);
expect(historyAfter).toHaveLength(1);
});
});

function userMessage(text: string, origin?: ContextMessage['origin']): ContextMessage {
Expand Down
Loading