fix(agent): drop stale empty tool_calls on repair_message_sequence merge (#77921) - #78063
Conversation
# Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit.
Duplicate of #77944, the earlier open implementation. Diff verification found identical production code: both add |
|
@alt-glitch That triage was accurate at the time — at that point this PR and #77944 did carry identical production code (the same Since then this PR grew beyond that single fix. While auditing
Current state: 3 distinct fixes, 6 new regression tests (20/20 total passing vs #77944's 15), Happy to have #77944 closed in favor of this one, or to have the maintainer merge whichever they prefer — just flagging that the "identical diff" basis for the duplicate call no longer holds. |
|
@alt-glitch Fair flag for Fix 1 — that one-line change ( Fix 2 ( |
|
Review finding: Please only drop the sidecar when the surviving content actually changes (e.g. compare before/after), and add a regression test with |
Re-triage correction: #77944 is closed, and this PR now contains its empty |
ebb02ed to
3f1dba4
Compare
TYSM , i´m solve this |
Review finding from wz-heng on NousResearch#78063: drop_stale_api_content(prev) ran unconditionally on every consecutive-assistant merge, but the merge does not always rewrite prev["content"] -- when the later turn's content is None, or either side is multimodal (list), both content branches skip the reassignment and prev["content"] stays untouched. In that shape the existing api_content sidecar is still the exact bytes previously sent for that unchanged content; dropping it diverged replay bytes and broke the prompt-cache invariant for no reason. Track whether a branch actually reassigned prev["content"] and only drop the sidecar in that case. Tests: 2 new negative controls (content=None, content=list) asserting api_content survives when content is untouched; the existing changed-string test continues to assert the opposite. 22/22 passed.
|
@wz-heng— fixed in de61802. Tracked whether a branch above actually reassigned
The existing changed-string test ( |
|
Thanks — the Could you make the decision from the before/after value (or leave the flag false when the joined value equals |
|
Status update: Fix 1 ( |
Duplicate of #86654, whose merged three-chokepoint repair covers this PR's empty |
|
@teknium1 done — pushed a209233697.
Added @wz-heng's requested negative control: 23/23 tests passing, ruff clean. Ready for re-review. |
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.
a209233 to
1208c2a
Compare
|
@teknium1 done — rebased and force-pushed (1208c2a).
Diff is now clean against |
Summary
Follow-up to #58755 / #59110, tracked in #77921 (empty
tool_callsarray still causing HTTP 400 from DeepSeek v4 in v0.19.1 — 3 reproductions between 2026-08-01 and 2026-08-03, session permanently stuck after the first hit).This PR fixes three stale-field bugs found in
agent/agent_runtime_helpers.py, all sharing the same shape: a merge or chokepoint rewrites a message but leaves a derived field pointing at pre-rewrite state, and that stale field later wins over the fresh one.repair_message_sequence(consecutive-assistant merge)tool_calls: []/Nonerepair_message_sequence(same merge)api_contentsidecarsanitize_api_messages(final pre-API chokepoint)tool_call_idreaches the provider unfilteredFix 1 — stale
tool_callssurvives the merge (#77921)Root cause: #59110 fixed the symptom at
sanitize_api_messagesby strippingtool_calls: []/Noneon the per-call wire copy. Butrepair_message_sequence's consecutive-assistant merge has its own gap at the origin: when the surviving turn already carries a staletool_calls: []/Noneand the turn being merged in has no real tool_calls either, bothprev_callsandnew_callsend up empty, so neitherif new_callsnorelif prev_callsfires —prev["tool_calls"]is left untouched and the stale falsy value survives the merge into the repaired (and persisted)messageslist.Fix: added an
elsebranch — when neither side carries real tool_calls, drop the key entirely. Non-destructive to persisted history: a falsytool_callsis already normalized toNULLon every DB write (_insert_message_rows).%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#00f0ff', 'mainBkg': '#0a0a16', 'primaryTextColor': '#ffffff', 'primaryBorderColor': '#ff007f', 'lineColor': '#00f0ff'}}}%% graph TD A[🔒 Consecutive Assistant Turns] --> B{⚡ Merge in repair_message_sequence} B -->|prev has real tool_calls| C[✅ Union: prev + new] B -->|new has real tool_calls, prev empty| D[✅ Union: new only] B -->|BOTH empty/None — the gap| E[🐛 Before: stale tool_calls left as-is] E --> F[🚀 Wire: tool_calls sent empty] F --> G[❌ DeepSeek v4: HTTP 400 empty array rejected] B -->|BOTH empty/None — fixed| H[🔧 After: prev.pop tool_calls] H --> I[🚀 Wire: key absent — accepted] style E fill:#ff007f,stroke:#ff0038 style G fill:#ff007f,stroke:#ff0038 style H fill:#00f0ff,stroke:#00f0ff,color:#0a0a16 style I fill:#00f0ff,stroke:#00f0ff,color:#0a0a16Fix 2 — stale
api_contentsidecar survives the same mergeRoot cause: the same consecutive-assistant merge concatenates
contentonto the surviving turn, but a pre-existingapi_contentsidecar on that turn was left untouched. That sidecar is the exact bytes previously sent to the API when they diverge from the clean stored content (stamped by_flush_messages_to_session_dbwhenever raw content diverges from whatsanitize_contextwould produce — e.g. echoed<memory-context>blocks). It takes priority overcontentat API-build time for roleassistant(conversation_loop'sapi_messagesbuild), so a merge could silently discard its own freshly-concatenated content and replay pre-merge bytes on the next call.Fix:
drop_stale_api_content(prev)on every consecutive-assistant merge, mirroring what the consecutive-user merge (Pass 2, same file) already does.Fix 3 —
sanitize_api_messagesnever flags an unpaired tool result with no idRoot cause: the orphan-detection set (
result_call_ids) only ever collects truthytool_call_idvalues, so a tool result with a missing/empty id is never added to it — and therefore can never land in the orphaned-ids set-difference either. The message passes through the "final chokepoint" completely unfiltered and can reach the provider with notool_call_idat all, a schema violation on strict OpenAI-compatible providers.Fix: drop such messages unconditionally, mirroring the guard
repair_message_sequence's Pass 1 already applies (if tc_id and tc_id in known_tool_ids).%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#8b0000', 'mainBkg': '#0a0204', 'primaryTextColor': '#ffccd5', 'primaryBorderColor': '#ff0038', 'lineColor': '#ff0038'}}}%% graph TD A[🩸 Consecutive Assistant Merge] -->|content rewritten| B{🔥 prev carries stale api_content sidecar?} B -->|before fix| C[⚔️ Sidecar wins at API-build time] C --> D[❌ Stale pre-merge bytes sent — new content silently lost] B -->|fixed| E[🔥 drop_stale_api_content clears sidecar on merge] E --> F[⚔️ Freshly merged content reaches the wire] G[🩸 sanitize_api_messages orphan sweep] -->|tool_call_id empty/missing| H{🔥 truthy-id-only check} H -->|before fix| I[⚔️ Never added to result_call_ids, never flagged orphaned] I --> J[❌ Unpaired tool result reaches provider — schema violation] H -->|fixed| K[🔥 Dropped unconditionally, regardless of id truthiness] K --> L[⚔️ Only properly-paired results survive] style C fill:#8b0000,stroke:#ff0038 style D fill:#8b0000,stroke:#ff0038 style I fill:#8b0000,stroke:#ff0038 style J fill:#8b0000,stroke:#ff0038 style E fill:#ff0038,stroke:#ff0038,color:#0a0204 style F fill:#ff0038,stroke:#ff0038,color:#0a0204 style K fill:#ff0038,stroke:#ff0038,color:#0a0204 style L fill:#ff0038,stroke:#ff0038,color:#0a0204Changes
agent/agent_runtime_helpers.pyrepair_message_sequence()— drop thetool_callskey on the surviving turn when the merge has nothing real to union in (Fix 1); drop the staleapi_contentsidecar on every consecutive-assistant merge (Fix 2).sanitize_api_messages()— drop tool results with a missing/emptytool_call_idunconditionally (Fix 3).tests/run_agent/test_message_sequence_repair.py— 6 new regression tests: stale[]dropped, staleNonedropped, real tool_calls preserved (negative control), union from later turn still works (negative control), staleapi_contentsidecar dropped on merge, unpaired tool result dropped by the sanitizer.Validation
tool_calls: [], new turn has none[]preservedtool_calls: None, new turn has noneNonepreservedapi_contentsidecartool_call_idtests/run_agent/test_message_sequence_repair.pytests/hermes_state/test_restore_alternation_repair.pytests/run_agent/test_agent_guardrails.py+test_session_meta_filtering.pyruff checkInfographic :
Test plan
pytest tests/run_agent/test_message_sequence_repair.py -q— 20 passedpytest tests/hermes_state/test_restore_alternation_repair.py -q— 3 passedpytest tests/run_agent/test_agent_guardrails.py tests/run_agent/test_session_meta_filtering.py -q— 32 passedruff check agent/agent_runtime_helpers.py tests/run_agent/test_message_sequence_repair.py— cleanRelated
A note on the AI-triage "duplicate" flag
An automated triage comment flagged this PR as a duplicate of #77944 — accurate for Fix 1: both PRs land on the identical one-line change (
else: prev.pop("tool_calls", None)) for #77921. @webtecnica opened #77944 first; credit for Fix 1 goes there.Fix 2 (
api_contentsidecar) and Fix 3 (sanitize_api_messagesmissing-id gap) are not in #77944 — found and implemented independently here while auditing the same function for other instances of the same failure shape, with their own regression tests. See the full discussion for the detailed comparison.