fix(agent): tool-pair sanitizers — whitespace ids pair, stale empty tool_calls drop, orphan logic unified (salvage #55845 + #78063 + #59434) - #97167
Merged
Conversation
…l_pairs _sanitize_tool_pairs() in ContextCompressor compared raw tool_call_id strings without stripping whitespace, the same bug fa3ab2f just fixed in agent_runtime_helpers.py / run_agent.py (_get_tool_call_id_static + sanitize_api_messages). ContextCompressor has its own near-identical reimplementation of the pair-repair logic that was left unpatched. When assistant-side and result-side IDs diverge only in surrounding whitespace, the compressor misclassifies valid results as orphaned and replaces them with [Result unavailable] stubs — silent data loss on every compression cycle that touches such pairs. Apply the same .strip() fix to all three sites: - _get_tool_call_id (extracts IDs from assistant tool_calls) - result_call_ids accumulation loop - orphaned_results filter predicate Closes the sibling gap of fa3ab2f / #42405.
Rebased onto current main to drop the empty-tool_calls fix (already on main via #86654, cherry-picked from #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, #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
…iminate drift sanitize_api_messages (agent_runtime_helpers) and _sanitize_tool_pairs (context_compressor) both collected tool-call IDs and classified orphans with near-identical logic that had already drifted: the canonical sanitizer added dedup (#58350), but the compressor's copy did not. Extract the shared orphan-detection logic into _classify_tool_call_orphans(messages) in agent_runtime_helpers. Both call sites now delegate to it, preserving their divergent remediation strategies (insert-stubs vs strip-orphans) while ensuring id-resolution rules and dedup stay in sync. Closes #58357
…ft by cherry-pick auto-merge
Contributor
૮ >ﻌ< ა ci reviewran on 3925230 — fix(sanitizer): drop duplicated legacy _classify_tool_call_o
|
This was referenced Aug 28, 2026
teknium1
added a commit
that referenced
this pull request
Aug 28, 2026
…phans (#97167) — classifier docstring reflects its remaining consumer; empty-id filter note updated
5 tasks
melon-xf
added a commit
to melon-xf/hermes-agent
that referenced
this pull request
Sep 3, 2026
…phans (NousResearch#97167) — classifier docstring reflects its remaining consumer; empty-id filter note updated
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.
Summary
Combined salvage of three community tool-pair sanitizer PRs, applied fixes-first so the final refactor deduplicates the already-fixed logic:
tool_call_idinContextCompressor._sanitize_tool_pairs, closing the sibling gap offa3ab2ffd: IDs differing only by stray whitespace were misclassified as orphaned and replaced with[Result unavailable]stubs on every compression cycle.agent/agent_runtime_helpers.py: drop stale emptytool_callssurviving the consecutive-assistant merge (fixes Still reproducing in v0.19.1: empty tool_calls after repair_message_sequence (follow-up to #58755) #77921), drop the staleapi_contentsidecar when the merge rewritescontent(with the wz-heng review's negative controls), and drop tool results with a missing/emptytool_call_idat the pre-API chokepoint (sanitize_api_messages does not catch tool messages with missing/empty tool_call_id #78071)._classify_tool_call_orphans(messages)used by bothsanitize_api_messagesand_sanitize_tool_pairs, so orphan-detection/id-resolution rules can never drift between the two copies again (refactor(compression): consolidate _sanitize_tool_pairs with the canonical agent_runtime_helpers sanitizers (dedup/whitespace/orphan drift) #58357).All three authors' commits are cherry-picked so authorship is preserved; conflict-resolution glue is in a separate commit.
Fixes #77921. Closes #58357. Closes #78071 (chokepoint half).
Changes
#55845 (whitespace ids) —
agent/context_compressor.py,tests/agent/test_context_compressor.py(+3 tests)tool_call_id_variants/tool_result_id_variantsfromagent/message_sanitization.py, Tool results silently dropped when provider returns composite tool_call ids (call_xxx|fc_yyy) — model never sees output and confabulates #63000/fix(agent): generic 'Unexpected item type in content' 400s now strip images and retry (#57948, salvage #58120) #97075 churn), whose_expand_tool_id_variantsalready.strip()s — so the behavioral whitespace fix was partially superseded. Salvaged: the.strip()on_get_tool_call_id(still raw on main) and the author's 3 regression tests, which now pin the whitespace-pairing contract against future drift.#78063 (stale fields on merge) —
agent/agent_runtime_helpers.py,tests/run_agent/test_message_sequence_repair.py(+7 tests)tool_callsdropped inrepair_message_sequencemerge) landed on main independently asf316f7d086— redundant, not re-applied.content_rewrittentracking in the consecutive-assistant merge; drop theapi_contentsidecar only when the mergedcontentvalue actually changed, so prompt-cache replay bytes stay stable when nothing moved (empty-string /None/ multimodal negative controls included).sanitize_api_messagesdroppingrole=toolmessages with a missing/emptytool_call_id, which previously slipped through the orphan sweep (truthy-only set membership) and reached strict providers as a schema violation.#59434 (dedup refactor) —
agent/agent_runtime_helpers.py,agent/context_compressor.py,tests/run_agent/test_message_sequence_repair.py(+5 tests)_classify_tool_call_orphans(messages)inagent_runtime_helpers.pyis the single owner of orphan detection; both call sites delegate to it while keeping their divergent remediation (insert-stubs vs strip-orphans, incl. the [Bug]: context compression can drop an in-flight tool chain — side effect completes, result never reaches the agent, agent replays #79278 in-flight-chain protection).id/call_id/response_item_id/compositecall|item, [Bug]: mnemosyne_recall and cronjob list consistently return [Result unavailable] — Hermes drops tool results #55626/Tool results silently dropped when provider returns composite tool_call ids (call_xxx|fc_yyy) — model never sees output and confabulates #63000) and returns orphaned message objects / missing tool_call entries (identity-matched), so the refactor is behavior-preserving against today's alias-aware code rather than regressing to single-id matching. The author's 5 classifier unit tests were adapted to the alias-aware return shape.Validation
Live A/B on the real modules (
.venvpython + worktree sys.path), BEFORE = currentmain, AFTER = this branch:tool_calls id 'call_x'+ result'call_x '(trailing space) through_sanitize_tool_pairs#77921shape — merge leaves staletool_calls: []f316f7d086)api_contentsidecar after merge rewrites contenttool_call_id: ""/ key absent throughsanitize_api_messages['call_Z', '', None])['call_Z'])_classify_tool_call_orphansimported by both call sitesagent_runtime_helpers.py+context_compressor.pydelegate); both suites pass pre/post refactor proving identical behaviorInvariants held in every probe: role alternation preserved, no orphaned tool results, deterministic output.
Live repro: BEFORE on main —
api_contentsidecar survives the assistant merge (stale bytes win at API-build time) and id-less tool results passsanitize_api_messagesuntouched (tool ids on wire: ['call_Z', '', None]); AFTER — sidecar dropped exactly when content changed, wire ids['call_Z'], and both sanitizers classify orphans through the one shared function.Tests:
tests/run_agent/test_message_sequence_repair.py+tests/agent/test_context_compressor.py→ 194 passed (memory-capped run), including the 15 new tests from the three PRs.ruff checkclean on all touched files.Note on #59434's
scripts/release.pyhunk: it appended to the frozenAUTHOR_MAP— dropped per current attribution policy (contributor mapping lives incontributors/emails/;[email protected],[email protected], andsrojk34noreply are all already mapped —scripts/audit_pr_attribution.py --fixreports clean).Infographic