Skip to content

Commit 1d681f1

Browse files
committed
fix(agent-core): handle incomplete tool_calls on session resume
When a session is killed mid-tool-call and later resumed, the history contains an assistant message with tool_calls but no matching tool results. This caused a 400 API error and silently deferred all new user messages. Fixes #660. Changes: - project() now applies trimTrailingOpenToolExchange() to strip trailing assistant messages with unanswered tool_calls before sending to the LLM - Added cleanupOrphanedToolCalls() to ContextMemory that clears stale pendingToolResultIds after resume, so new user messages are not silently deferred - Fixed trimTrailingOpenToolExchange edge case where history with no assistant message returned [] instead of the full history Tests: - project() trims trailing assistant with unanswered tool_calls - project() keeps assistant when all tool_calls are answered - cleanupOrphanedToolCalls clears stale pendingToolResultIds
1 parent 143c504 commit 1d681f1

4 files changed

Lines changed: 113 additions & 2 deletions

File tree

packages/agent-core/src/agent/context/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,28 @@ export class ContextMemory {
298298
return this.pendingToolResultIds.size > 0;
299299
}
300300

301+
/**
302+
* Remove stale entries from `pendingToolResultIds` that have no matching
303+
* tool result in the history. This happens when a session is killed
304+
* mid-tool-call and later resumed — the tool.call events are replayed
305+
* but the tool.result events never arrived. Without this cleanup,
306+
* `hasOpenToolExchange()` would remain true, silently deferring all
307+
* new user messages.
308+
*/
309+
cleanupOrphanedToolCalls(): void {
310+
const answeredIds = new Set<string>();
311+
for (const message of this._history) {
312+
if (message.role === 'tool' && typeof message.toolCallId === 'string') {
313+
answeredIds.add(message.toolCallId);
314+
}
315+
}
316+
for (const id of this.pendingToolResultIds) {
317+
if (!answeredIds.has(id)) {
318+
this.pendingToolResultIds.delete(id);
319+
}
320+
}
321+
}
322+
301323
private pushHistory(...messages: ContextMessage[]): void {
302324
this._history.push(...messages);
303325
for (const message of messages) {

packages/agent-core/src/agent/context/projector.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ export function project(history: readonly ContextMessage[]): Message[] {
1212
!(message.role === 'assistant' && message.content.length === 0 && message.toolCalls.length === 0)
1313
);
1414
});
15-
return mergeAdjacentUserMessages(usable);
15+
// Trim any trailing assistant message whose tool_calls were never answered
16+
// (e.g. the session was killed mid-tool-call). Sending an assistant
17+
// message with open tool_calls violates the API contract and causes a
18+
// 400 error on resume.
19+
return trimTrailingOpenToolExchange(mergeAdjacentUserMessages(usable));
1620
}
1721

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

84+
// No assistant message found — nothing to trim.
85+
if (lastNonToolIndex < 0) return [...history];
86+
8087
const assistant = history[lastNonToolIndex];
81-
if (assistant === undefined) return [];
88+
if (assistant === undefined) return [...history];
8289
if (assistant.role !== 'assistant' || assistant.toolCalls.length === 0) return [...history];
8390

8491
const trailingToolCallIds = new Set(

packages/agent-core/src/agent/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,10 @@ export class Agent {
300300
await this.background.loadFromDisk();
301301
await this.background.reconcile();
302302
await this.cron?.loadFromDisk();
303+
// Clean up any tool_call IDs that were never answered (session killed
304+
// mid-tool-call). Without this, new user messages would be silently
305+
// deferred because `hasOpenToolExchange()` would remain true.
306+
this.context.cleanupOrphanedToolCalls();
303307
this.turn.finishResume();
304308
return result;
305309
}

packages/agent-core/test/agent/context.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,84 @@ describe('Agent context notification projection', () => {
776776
expect(textOf(messages[1]!)).toBe('No origin prompt');
777777
expect(textOf(messages[2]!)).toBe('Third real prompt');
778778
});
779+
780+
it('project() trims trailing assistant message with unanswered tool_calls', () => {
781+
const history: ContextMessage[] = [
782+
userMessage('hello'),
783+
{
784+
role: 'assistant',
785+
content: [{ type: 'text', text: 'I will run a tool' }],
786+
toolCalls: [{ id: 'call_1', name: 'Bash', arguments: '{}' }],
787+
},
788+
// No tool result for call_1 — session was killed.
789+
];
790+
const messages = project(history);
791+
// The assistant message with open tool_calls should be trimmed.
792+
expect(messages).toHaveLength(1);
793+
expect(messages[0]!.role).toBe('user');
794+
});
795+
796+
it('project() keeps assistant message when all tool_calls are answered', () => {
797+
const history: ContextMessage[] = [
798+
userMessage('hello'),
799+
{
800+
role: 'assistant',
801+
content: [{ type: 'text', text: 'I will run a tool' }],
802+
toolCalls: [{ id: 'call_1', name: 'Bash', arguments: '{}' }],
803+
},
804+
{
805+
role: 'tool',
806+
content: [{ type: 'text', text: 'tool output' }],
807+
toolCalls: [],
808+
toolCallId: 'call_1',
809+
},
810+
];
811+
const messages = project(history);
812+
// All three messages should be present.
813+
expect(messages).toHaveLength(3);
814+
expect(messages[1]!.role).toBe('assistant');
815+
expect(messages[2]!.role).toBe('tool');
816+
});
817+
818+
it('cleanupOrphanedToolCalls clears stale pendingToolResultIds after resume', () => {
819+
const ctx = testAgent();
820+
ctx.configure();
821+
822+
// Simulate a tool.call event that never got a tool.result (session killed).
823+
ctx.dispatch({
824+
type: 'context.append_loop_event',
825+
event: { type: 'step.begin', uuid: 'step-orphan', turnId: '', step: 1 },
826+
});
827+
ctx.dispatch({
828+
type: 'context.append_loop_event',
829+
event: {
830+
type: 'tool.call',
831+
parentUuid: 'step-orphan',
832+
stepUuid: 'step-orphan',
833+
toolCallId: 'orphan_call',
834+
name: 'Bash',
835+
arguments: '{}',
836+
},
837+
});
838+
839+
// The orphaned tool call should block new messages.
840+
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'follow up' }]);
841+
// The message should be deferred, not in history.
842+
const historyBefore = ctx.agent.context.history.filter(
843+
(m) => m.role === 'user' && m.content.some((p) => p.type === 'text' && 'text' in p && p.text === 'follow up'),
844+
);
845+
expect(historyBefore).toHaveLength(0);
846+
847+
// Now cleanup the orphaned tool calls.
848+
ctx.agent.context.cleanupOrphanedToolCalls();
849+
850+
// After cleanup, new messages should go through.
851+
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'after cleanup' }]);
852+
const historyAfter = ctx.agent.context.history.filter(
853+
(m) => m.role === 'user' && m.content.some((p) => p.type === 'text' && 'text' in p && p.text === 'after cleanup'),
854+
);
855+
expect(historyAfter).toHaveLength(1);
856+
});
779857
});
780858

781859
function userMessage(text: string, origin?: ContextMessage['origin']): ContextMessage {

0 commit comments

Comments
 (0)