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
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 on lines +201 to +203

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 Clear pending tool state when compacting it away

When a full/manual compaction runs while the tail contains an unresolved or partially resolved tool exchange, this projection now removes that exchange from the compaction prompt, so applyCompaction can replace the whole prefix with a summary. However applyCompaction only clears openSteps and leaves pendingToolResultIds set; after this live compaction completes, any later appendUserMessage still sees an open tool exchange and goes into deferredMessages, so the next model request is built without the user's new prompt until a restart happens to run resume cleanup. Please clear the pending tool state when the open exchange is compacted away.

Useful? React with 👍 / 👎.

}

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