fix(agent): make sanitize_api_messages a fixpoint so dedup cannot re-wedge sessions (#83312) - #83622
Closed
JoaoMarcos44 wants to merge 2 commits into
Closed
JoaoMarcos44 wants to merge 2 commits into
JoaoMarcos44 wants to merge 2 commits into
Conversation
…wedge sessions (NousResearch#83312) `sanitize_api_messages` enforces its invariants as an ordered pipeline, but the last pass can violate invariants the earlier passes established. The empty-`tool_calls` pass and the empty-content healer both run near the top of the function. The tool_call_id dedup pass runs last. When *every* call on an assistant turn is a duplicate — the normal shape after `repair_message_sequence` merges two consecutive assistant turns and unions their call lists onto the surviving text turn — dedup rewrites that turn to `tool_calls: []`, re-creating the exact payload the empty-array pass deleted a few steps earlier, at a point where no later pass can see it. DeepSeek rejects the request with HTTP 400 "Invalid 'messages[N].tool_calls': empty array", and because the turn is persisted, every subsequent send fails too: the session is wedged permanently. Healing only at the dedup site fixes half the bug. A collapsed turn that carried no text also comes back with empty content, which is a second, independent 400 ("all messages must have non-empty content except the final one"). The session stays wedged, just with a different error string. So instead of patching the site, restore the invariants after it: extract the empty-array normalization into `drop_empty_tool_calls_arrays` and re-run it together with `repair_empty_non_final_messages` on the deduped list. The re-run is gated on `removed_dupes`, so the common path is unchanged, and it covers any future pass inserted before the return rather than only this one. Order matters: arrays are dropped first so the content healer sees a genuinely payload-less turn and substitutes its placeholder. Tests cover both 400 classes plus an idempotence check asserting `sanitize(sanitize(x)) == sanitize(x)` over the wedge transcript.
Contributor
fix(agent): make sanitize_api_messages a fixpoint so dedup cannot re-wedge sessions (#83312)
|
Review on NousResearch#83622 noted the fixpoint test only asserts on the empty-array and empty-content invariants, not on orphan-repair — so a future dedup regression that orphans a tool result would slip through. Also narrow the comment's overstated "covers any future pass" claim to what it actually guarantees.
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.
The bug is real, and it is not where the issue says it is
The issue reports that
tool_calls: []reaches DeepSeek "after the sanitizer already stripped it". That is accurate as an observation, but the payload is not surviving the sanitizer — the sanitizer creates it.sanitize_api_messagesis an ordered pipeline. Two of its passes enforce invariants near the top:repair_empty_non_final_messages— no non-final turn may have empty contenttool_callsdrop — no assistant turn may carrytool_calls: []The tool_call_id dedup pass runs last. When every call on an assistant turn is a duplicate,
kept_tcsis empty and the turn is rewritten totool_calls: []— re-creating the exact payload that was deleted a few steps earlier, at a point where nothing downstream can see it.That "every call is a duplicate" shape is not exotic. It is what
repair_message_sequenceproduces when it merges two consecutive assistant turns and unions their call lists onto the surviving text turn, which is why the issue's reporters correlate the failure with theRepaired N message-alternation violationslog line.Because the poisoned turn is persisted, every later send fails identically. The session is wedged permanently — restart included.
Reproduction
On
main,sanitize_api_messagesreturns the 4th message as{"role": "assistant", "content": "here is the file", "tool_calls": []}→ HTTP 400Invalid 'messages[3].tool_calls': empty array.Why patching the dedup site is only half a fix
Several open PRs (#83315, #80827, #82252, #77377, #74906, #64843) drop the key at the dedup site, and #82252 additionally re-runs an empty-array sweep afterwards. Credit where due: that closes the empty-array half.
It leaves the other half open. If the collapsed turn carried no text, it comes back with empty content and no tool calls. That is a second, independent 400 —
all messages must have non-empty content except for the final one(INVALID_REQUEST_BODY) — and the content healer that exists to fix exactly this already ran, several passes earlier, back when the turn still had tool calls and therefore looked like it had a payload. The session stays wedged; only the error string changes.This PR: make the sanitizer a fixpoint
A sanitizer is only useful if every invariant it claims still holds on the value it returns. This restores that property instead of patching one symptom site:
drop_empty_tool_calls_arrays— the same code, now callable, so nothing is duplicated.removed_dupesso the common path is untouched.Order matters: arrays are dropped first, so the content healer sees a genuinely payload-less turn and substitutes its placeholder.
This covers both 400 classes, and covers any future pass inserted before the
return— not just this one.%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#8b0000', 'mainBkg': '#0a0204', 'primaryTextColor': '#ffccd5', 'primaryBorderColor': '#ff0038', 'lineColor': '#ff0038'}}}%% graph TD A[Transcript] --> B[Role Allowlist] B --> C[Heal Empty Content] C --> D[Drop Empty tool_calls] D --> E[Pair Orphan Calls / Results] E --> F[Dedup tool_call_ids] F -->|All calls duplicate| G[Turn Rewritten To tool_calls Empty Array] G -->|main| H[HTTP 400 Empty Array] G -->|main, turn had no text| I[HTTP 400 Empty Content] H --> J[Session Wedged Permanently] I --> J G -->|this PR| K[Re-run Drop Empty tool_calls] K --> L[Re-run Heal Empty Content] L --> M[Invariants Hold On Return] M --> N[Request Accepted]Tests
tests/run_agent/test_message_sequence_repair.py(extended, no new file):test_dedup_does_not_reintroduce_empty_tool_calls_array— the empty-array 400test_dedup_collapse_heals_contentless_turn— the empty-content 400 the site patches misstest_sanitize_is_a_fixpoint_over_the_wedge_transcript—sanitize(sanitize(x)) == sanitize(x)All three fail on
mainand pass here. Full file: 17 passed.test_agent_guardrails.py+test_restore_alternation_repair.py: 43 passed.test_tool_call_incremental_persistence.pyshows 9 passed / 4 failed both with and without this change (pre-existing local Windows failures, unrelated).Infographic :