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

Prevent orphaned tool calls from causing provider errors after resume, compaction, or any projected context that ends with an unclosed tool exchange.
24 changes: 23 additions & 1 deletion packages/agent-core/src/agent/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,29 @@ export class ContextMemory {
}

project(messages: readonly ContextMessage[]): Message[] {
return project(this.agent.microCompaction.compact(messages));
return trimTrailingOpenToolExchange(
project(this.agent.microCompaction.compact(messages)),
);
Comment thread
meymchen marked this conversation as resolved.
}

cleanupOrphanedToolCalls(): void {
if (this.pendingToolResultIds.size === 0) return;
const trimmed = trimTrailingOpenToolExchange(this._history);
const removed = this._history.length - trimmed.length;
if (removed > 0) {
this.agent.records.logRecord({
type: 'context.cleanup_orphan_tool_calls',
removed,
});
this._history.length = trimmed.length;
this.tokenCountCoveredMessageCount = Math.min(
this.tokenCountCoveredMessageCount,
this._history.length,
);
}
this.openSteps.clear();
this.pendingToolResultIds.clear();
this.flushDeferredMessagesIfToolExchangeClosed();
}

get messages(): Message[] {
Expand Down
5 changes: 3 additions & 2 deletions packages/agent-core/src/agent/context/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ export function trimTrailingOpenToolExchange(history: readonly Message[]): Messa
}

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

const trailingToolCallIds = new Set(
history
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export class Agent {

async resume(): Promise<{ warning?: string }> {
const result = await this.records.replay();
this.context.cleanupOrphanedToolCalls();
this.goal.normalizeAfterReplay();
await this.background.loadFromDisk();
await this.background.reconcile();
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core/src/agent/records/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void {
case 'context.undo':
agent.context.undo(input.count);
return;
case 'context.cleanup_orphan_tool_calls':
agent.context.cleanupOrphanedToolCalls();
return;
case 'tools.register_user_tool':
agent.tools.registerUserTool(input);
return;
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/agent/records/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export interface AgentRecordEvents {
'context.clear': {};
'context.apply_compaction': CompactionResult;
'context.undo': { count: number };
'context.cleanup_orphan_tool_calls': { removed: number };

'tools.update_store': ToolStoreUpdate;

Expand Down
33 changes: 31 additions & 2 deletions packages/agent-core/test/agent/compaction/full.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,8 +880,37 @@ describe('FullCompaction', () => {
user: text "old user one"
assistant: text "old assistant one"
user: text "run both tools"
assistant: [] calls call_open_one:LookupOne { "query": "one" }, call_open_two:LookupTwo { "query": "two" }
tool[call_open_one]: text "one result"
user: text <compaction-instruction>
`);
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
'assistant',
]);
await ctx.expectResumeMatches();
});

it('keeps a fully resolved tool exchange in the compaction prompt', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendToolExchange();
const compacted = ctx.once('context.apply_compaction');

ctx.mockNextResponse({ type: 'text', text: 'Compacted with tools.' });
await ctx.rpc.beginCompaction({ instruction: 'Keep stable facts.' });
await compacted;

expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: []
messages:
user: text "old user one"
assistant: text "old assistant one"
user: text "lookup something"
assistant: text "I will call Lookup." calls call_lookup:Lookup { "query": "moon" }
tool[call_lookup]: text "lookup result"
user: text <compaction-instruction>
`);
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
Expand Down
Loading