fix(agent): uniquify duplicate tool_call ids and never send an empty assistant turn - #72766
Closed
jonnyallum wants to merge 1 commit into
Closed
Conversation
…assistant turn
Some providers reuse tool_call ids across turns rather than emitting globally
unique ones. Kimi K3 (Moonshot, via OpenRouter) emits ids shaped
`<tool_name>_<n>`; one observed session emitted `terminal_46` eight separate
times, and after a compression pass three of them were live in the same
context window at once.
Duplicate ids break result pairing. The results attach to the first assistant
carrying that id, the later assistant turns lose their `tool_calls`, and one is
left with neither content nor tool_calls. Moonshot rejects that shape with a
non-retryable 400:
Invalid request: the message at position 30 with role 'assistant'
must not be empty
Because the failed turn is persisted, every later request rebuilds the same
illegal transcript and fails in about a second with a single API call. The
agent never recovers on its own, so it presents as a hang rather than an
error.
`sanitize_tool_call_pairing()` runs on the outbound copy next to the existing
surrogate sanitizer, so stored history is never mutated. It:
- renames a tool_call id that has already been seen in this request and
rewrites the matching results together, preserving pairing;
- replaces an assistant turn that has neither content nor tool_calls with a
short placeholder.
An assistant turn with empty content but real tool_calls is deliberately left
alone, since that shape is legal and rewriting it would change what the model
sees.
This is a distinct producer of the empty-assistant shape in NousResearch#66429, which
tracks the builder appending empty turns in a runaway loop, and of the phantom
`content:""` messages in NousResearch#63200. The second guard here covers those shapes on
the wire as well, but the id-collision cause appears not to have been
identified yet.
Co-Authored-By: Claude <noreply@anthropic.com>
1 task
Contributor
|
Both halves of this PR are now resolved on main. The duplicate tool_call id uniquification landed via commit 474c84e ( |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Some providers reuse
tool_callids across turns instead of emitting globally unique ones. Kimi K3 (Moonshot, via OpenRouter) emits ids shaped<tool_name>_<n>. In one observed sessionterminal_46was emitted eight separate times, and after a compression pass three of them were live in the same context window at once.Duplicate ids break result pairing. The results attach to the first assistant message carrying that id, the later assistant turns lose their
tool_calls, and one ends up with neither content nortool_calls. Moonshot rejects that shape with a non-retryable 400:The failed turn is then persisted, so every subsequent message rebuilds the same illegal transcript and fails in roughly a second with a single API call. The agent never recovers on its own — it presents as a hang rather than an error, which is how it was originally reported ("it dies and won't come back"). Restarting the gateway does not help, because the poison is in the session, not the process.
This adds
sanitize_tool_call_pairing()to the pre-request path, next to the existing surrogate sanitizer. It operates on the outbound copy only, so stored history is never mutated. It:tool_callid already seen earlier in the same request, rewriting the matchingtoolresults together so pairing is preserved;An assistant turn with empty content but real
tool_callsis deliberately left untouched — that shape is legal, and rewriting it would change what the model sees.Why this approach
Fixing it on the outbound copy rather than at persist time means no migration, no mutation of existing sessions, and no behaviour change for providers that already emit unique ids (the sanitizer returns
Falseand makes no edits on a clean transcript, which is pinned by a test).Related Issue
Relates to #66429.
That issue tracks the same visible shape — empty assistant messages reaching the wire and never appearing in
state.db— but a different producer: the builder appending{"role":"assistant","content":"","tool_calls":[]}in a runaway loop. What I hit is an id collision strippingtool_callsoff a real turn. The id-collision cause does not appear to have been identified yet, so I believe this is complementary rather than a duplicate.The second guard here (never emit an assistant turn with neither content nor tool_calls) also covers the shape described in #66429 and the phantom
content:""messages in #63200 at the wire, though it does not address the runaway append site itself — that is still worth fixing separately, since this only stops the bad shape leaving.Searched open and merged issues and PRs first, per CONTRIBUTING. #31175, #31582 and #47280 sanitize adjacent shapes (empty content with tool_calls, partial-stream empty turns); none covers duplicate ids.
Type of Change
Changes Made
agent/message_sanitization.py— newsanitize_tool_call_pairing(), exported in__all__.agent/conversation_loop.py— import it and call it immediately after_sanitize_messages_surrogates(api_messages).tests/agent/test_duplicate_tool_call_ids.py— 7 tests.Testing
pytest tests/agent/test_duplicate_tool_call_ids.py— 7 passed.Coverage:
content: NonehandledFalse(no churn)Also validated against the real captured request that Moonshot rejected: replaying it through the sanitizer took illegal empty-assistant turns from 1 to 0 with no orphaned tool results introduced. Then ran live against Moonshot for several hours of agent traffic, including multi-tool turns, with no recurrence.
Reproduced on 0.18.2 with
moonshotai/kimi-k3via OpenRouter, Windows 11, Python 3.11.