Skip to content

fix(core): dedupe duplicate tool_call_id before lowering to provider - #40240

Open
C0d3N1nja97342 wants to merge 1 commit into
anomalyco:devfrom
C0d3N1nja97342:fix/compaction-duplicate-tool-call-id
Open

fix(core): dedupe duplicate tool_call_id before lowering to provider#40240
C0d3N1nja97342 wants to merge 1 commit into
anomalyco:devfrom
C0d3N1nja97342:fix/compaction-duplicate-tool-call-id

Conversation

@C0d3N1nja97342

Copy link
Copy Markdown

Issue for this PR

Closes #40235

Type of change

  • Bug fix

What does this PR do?

An interrupted tool run can leave a second tool part that reuses an existing callID in the assistant message — a completed part plus an error/"Tool execution aborted" part with metadata.interrupted. When toLLMMessages serializes both to the provider, it emits two ToolCallParts with the same id, and every provider rejects that with 400 Duplicate value for 'tool_call_id'.

Because compaction re-sends the full history head to summarize, the duplicate is always present, so compaction can never complete — the summary is never written, the token counter never resets, and every subsequent auto-compaction fails the same way. The session is effectively poisoned until /new.

Fix: in packages/core/src/session/runner/to-llm-message.ts, the assistant() lowering now deduplicates tool parts by callID before building provider messages, keeping the most informative state (completed > error > running > pending) and preserving first-occurrence position so tool/text/reasoning order stays stable. Both the content (tool calls) and the trailing results (tool results) paths consume the deduped list, so a duplicate can no longer reach the provider.

I verified the root cause against the reporter's sqlite evidence: two parts sharing callID functions.todowrite:1, one completed (tool=todowrite) and one error (tool=unknown, error="Tool execution aborted", metadata.interrupted=true) — exactly the shape the dedup now collapses.

How did you verify your code works?

Added a regression test in packages/core/test/session-runner-message.test.ts that builds an assistant message with two tool parts sharing functions.todowrite:1 (a completed todowrite + an error/"Tool execution aborted" interrupted part, matching the issue's sqlite evidence) and asserts toLLMMessages emits exactly one tool-call and one tool-result, both carrying the single callID with no duplicate.

$ bun test ./test/session-runner-message.test.ts
 7 pass, 0 fail

The pre-existing 6 tests in that file (empty turns, message-type mapping, durable media replay, OpenAI encrypted reasoning, provider continuation metadata on failed turns and after model switch) all still pass.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

An interrupted tool run can leave a second tool part that reuses an
existing callID (a "completed" part plus an "error"/"Tool execution
aborted" part with metadata.interrupted). Serializing both to the
provider yields a duplicate tool_call_id, which every provider rejects
with a 400. Because compaction re-sends the full history head, the
duplicate is always present, so the session can never compact again -
every subsequent auto-compaction fails the same way and the token counter
keeps growing.

Deduplicate tool parts by callID in the assistant() lowering in
to-llm-message.ts before building provider messages, keeping the most
informative state (completed > error > running > pending) and preserving
first-occurrence position so tool/text/reasoning order stays stable.

Fixes anomalyco#40235
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

No duplicate PRs found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compaction permanently fails with 'Duplicate value for tool_call_id' after an interrupted tool run

1 participant