fix(agent): close the empty tool_calls 400 class at all three chokepoints (#83312, #77921) - #86654
Merged
Conversation
The dedup pass in sanitize_api_messages (introduced by #58327) can produce an empty tool_calls array when all tool_call_ids in a message are duplicates of earlier messages in a long conversation history. DeepSeek v4 and newer OpenAI reject empty tool_calls with HTTP 400: 'Invalid messages[N].tool_calls: empty array'. When kept_tcs is empty after dedup, drop the tool_calls key entirely instead of writing tool_calls: []. Fixes #64335
Strict OpenAI-compatible providers (onerouter / Qwen, DeepSeek v4) reject an assistant message carrying tool_calls: [] (or null) with HTTP 400 'Empty tool_calls is not supported in message.' The pre-API sanitizer in agent_runtime_helpers.sanitize_api_messages already drops these on the conversation_loop path, but auxiliary / custom-provider routes that bypass that sanitizer can still reach the wire with an invalid empty array and abort the whole session (non-retryable 400). Normalize at the transport layer too: detect an empty-list / null tool_calls on assistant messages, strip the key on the per-call copy (never mutate the stored history), and keep real tool_calls untouched. Includes unit tests covering empty-list, null, real-call preservation, mixed batches, user-role non-mutation, copy-on-write, and cross-provider parity. Follow-up to #58755.
…ping, not empty array Reviewer noted the test name suggested an empty-array case but the fixture has one tool call; renamed to match actual behavior.
Contributor
Contributor
૮ >ﻌ< ა ci reviewrunning on 8e73134 — fix(transport): scope empty tool_calls comment to transport- Still running 1 job:
|
This was referenced Aug 15, 2026
fix(sanitize): drop tool_calls key when dedup removes all calls (fixes empty-array 400 class)
#77377
Closed
JoaoMarcos44
added a commit
to JoaoMarcos44/hermes-agent
that referenced
this pull request
Aug 16, 2026
Rebased onto current main to drop the empty-tool_calls fix (already on main via NousResearch#86654, cherry-picked from NousResearch#77944 with @webtecnica's authorship). This PR now carries only the two fixes unique to it: 1. A pre-existing api_content sidecar left stale on the consecutive- assistant merge. The sidecar takes priority over content at API-build time, so a merge could silently discard its own freshly concatenated content on the next call. Only dropped when the merge actually changes the resulting value (wz-heng, NousResearch#78063 review) -- content_rewritten compares before/after value, not just whether an assignment branch fired, so a falsy new_content (e.g. "") that strips to nothing no longer trips a spurious sidecar drop. 2. sanitize_api_messages never flagged a tool result with a missing/ empty tool_call_id -- its orphan-detection set only ever collected truthy ids, so an unpaired result with no id passed the final chokepoint untouched. Addresses teknium1's rebase request and wz-heng's review findings on NousResearch#78063.
This was referenced Aug 24, 2026
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.
Fixes #83312. Fixes #77921. Closes #64335-class empty-
tool_calls400s on strict providers (DeepSeek v4, Moonshot/Kimi, Qwen/onerouter) at every layer that can create or forward the invalid shape.The bug class
Strict OpenAI-compatible providers reject an assistant message carrying
tool_calls: [](ornull) with a non-retryable HTTP 400 (Invalid 'messages[N].tool_calls': empty array). Because the poisoned message stays in the replayed in-memory history, every subsequent turn in the session fails the same way — the session is permanently wedged until the agent cache is cleared (#83312 observed this across 6 sessions; #77921 reproduced it three times in v0.19.1 despite the #59110 chokepoint fix).The #58755/#59110 sanitizer strips
tool_calls: []where it sees it — but two later passes re-create the empty array after the strip has run, and some routes bypass the sanitizer entirely:sanitize_api_messagestool_call_id dedup (runs after the empty-array drop): when every call on an assistant turn is a duplicate,kept_tcscollapses to[]and was written back astool_calls: []— re-creating the exact payload deleted a few passes earlier. This is the mechanism behind DeepSeek 400: assistant messages with empty tool_calls:[] wedge sessions permanently #83312's "sanitizer works in isolation but the array still reaches the wire", and it correlates with theRepaired N message-alternation violationslog line becauserepair_message_sequence's consecutive-assistant merge is what produces the all-duplicates shape.repair_message_sequenceconsecutive-assistant merge: the union branch preserved a staletool_calls: []on the surviving turn (and the repaired list is the live/persisted trajectory, so the[]was replayed every turn — Still reproducing in v0.19.1: empty tool_calls after repair_message_sequence (follow-up to #58755) #77921's finding thattool_calls = NULLrows become[]on load and survive the merge).ChatCompletionsTransport.convert_messages()without passing throughsanitize_api_messagesat all.The fix (three chokepoints, salvaged from the three earliest PRs for each site)
agent/agent_runtime_helpers.py): when dedup removes ALL calls, drop thetool_callskey entirely instead of writing[]— salvaged from fix(sanitize): drop tool_calls key when dedup removes all calls #64345 by @liuhao1024 (earliest fix for this site, filed 2026-07-14).agent/agent_runtime_helpers.py): when neither merged turn carries calls, pop the staletool_callskey at the source so the live/persisted trajectory is clean — salvaged from fix(session): drop empty tool_calls in repair_message_sequence (#77921) #77944 by @webtecnica (earliest fix for this site).agent/transports/chat_completions.py): strip empty/nulltool_callson assistant messages inconvert_messages(), the last transformation before the request — salvaged from fix(transport): strip empty/null tool_calls on assistant messages #72591 by @TurgutKural (earliest transport-boundary fix).All three commits are cherry-picked with original authorship preserved.
Verification
Duplicate/overlapping PRs covered by this class fix
#64345 (salvaged), #77944 (salvaged, was already closed into #78063), #72591 (salvaged), #83600, #83315, #86020, #77377 (agent_runtime_helpers portion), #64843, #82252 — all address subsets of the same class at one of these three sites.
Infographic