From 5881791adc596b9f6093c506bcfe99e3a53a5890 Mon Sep 17 00:00:00 2001 From: fsaad1984 <38867992+fsaad1984@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:18:39 +0530 Subject: [PATCH] fix(adapter): enforce tool_use/tool_result adjacency in _strip_orphaned_tool_blocks _strip_orphaned_tool_blocks collected tool_result ids across ALL user messages and kept any assistant tool_use whose id appeared anywhere, rather than requiring the result to be in the immediately-following user message. A stale match elsewhere in the transcript could keep a genuinely-orphaned tool_use, which Anthropic rejects. Rewrite to adjacency-checked two-pass logic so a tool_use is kept only when its result immediately follows. Salvaged from #52145. Co-authored-by: fsaad1984 <38867992+fsaad1984@users.noreply.github.com> --- agent/anthropic_adapter.py | 114 ++++++++++++++++++++++--------------- 1 file changed, 69 insertions(+), 45 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 60443f3c2d1..254d5e072cd 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -2103,57 +2103,81 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None: """Strip tool_use blocks with no matching tool_result, and vice versa. Context compression or session truncation can remove either side of a - tool-call pair. Anthropic rejects both orphans with HTTP 400. - + tool-call pair, or insert messages between a tool_use and its result. + Anthropic requires each tool_use to have a matching tool_result in the + IMMEDIATELY FOLLOWING user message — a global ID match is not enough. Mutates ``result`` in place. """ - # Strip orphaned tool_use blocks (no matching tool_result follows) - tool_result_ids = set() - for m in result: - if m["role"] == "user" and isinstance(m["content"], list): - for block in m["content"]: - if block.get("type") == "tool_result": - tool_result_ids.add(block.get("tool_use_id")) - for m in result: - if m["role"] == "assistant" and isinstance(m["content"], list): - kept = [ - b - for b in m["content"] - if b.get("type") != "tool_use" or b.get("id") in tool_result_ids - ] - # If stripping an orphaned tool_use mutated a turn that also carries a - # signed thinking block, that block's Anthropic signature was computed - # against the ORIGINAL (un-stripped) turn content and is now invalid. - # Anthropic rejects the replayed turn with HTTP 400 "thinking blocks in - # the latest assistant message cannot be modified". Flag the turn so - # _manage_thinking_signatures can demote the dead signature instead of - # replaying it verbatim. See hermes-agent: extended-thinking + parallel - # tool batch interrupted mid-flight → non-retryable 400 crash-loop. - if len(kept) != len(m["content"]) and any( - isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"} - for b in m["content"] - ): - m["_thinking_signature_invalidated"] = True - m["content"] = kept - if not m["content"]: - m["content"] = [{"type": "text", "text": "(tool call removed)"}] + # Pass 1: For each assistant message with tool_use blocks, check that + # EACH tool_use ID has a matching tool_result in the immediately following + # user message. Strip tool_use blocks that lack an adjacent result — + # Anthropic rejects non-adjacent pairs with HTTP 400 even when the IDs + # match somewhere later in the conversation. + for i, m in enumerate(result): + if m.get("role") != "assistant" or not isinstance(m.get("content"), list): + continue + tool_use_ids_in_turn = { + b.get("id") + for b in m["content"] + if isinstance(b, dict) and b.get("type") == "tool_use" + } + if not tool_use_ids_in_turn: + continue - # Strip orphaned tool_result blocks (no matching tool_use precedes them) - tool_use_ids = set() + # Collect result IDs from the immediately following user message only. + adjacent_result_ids: set = set() + if i + 1 < len(result): + nxt = result[i + 1] + if nxt.get("role") == "user" and isinstance(nxt.get("content"), list): + for block in nxt["content"]: + if isinstance(block, dict) and block.get("type") == "tool_result": + adjacent_result_ids.add(block.get("tool_use_id")) + + orphaned = tool_use_ids_in_turn - adjacent_result_ids + if not orphaned: + continue + + kept = [ + b + for b in m["content"] + if not (isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id") in orphaned) + ] + # If stripping an orphaned tool_use mutated a turn that also carries a + # signed thinking block, that block's Anthropic signature was computed + # against the ORIGINAL (un-stripped) turn content and is now invalid. + # Anthropic rejects the replayed turn with HTTP 400 "thinking blocks in + # the latest assistant message cannot be modified". Flag the turn so + # _manage_thinking_signatures can demote the dead signature instead of + # replaying it verbatim. See hermes-agent: extended-thinking + parallel + # tool batch interrupted mid-flight → non-retryable 400 crash-loop. + if len(kept) != len(m["content"]) and any( + isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"} + for b in m["content"] + ): + m["_thinking_signature_invalidated"] = True + m["content"] = kept if kept else [{"type": "text", "text": "(tool call removed)"}] + + # Pass 2: Rebuild the set of tool_use IDs that survived pass 1, then + # strip tool_result blocks that no longer have any matching tool_use + # anywhere in the conversation. + surviving_tool_use_ids: set = set() for m in result: - if m["role"] == "assistant" and isinstance(m["content"], list): + if m.get("role") == "assistant" and isinstance(m.get("content"), list): for block in m["content"]: - if block.get("type") == "tool_use": - tool_use_ids.add(block.get("id")) + if isinstance(block, dict) and block.get("type") == "tool_use": + surviving_tool_use_ids.add(block.get("id")) + for m in result: - if m["role"] == "user" and isinstance(m["content"], list): - m["content"] = [ - b - for b in m["content"] - if b.get("type") != "tool_result" or b.get("tool_use_id") in tool_use_ids - ] - if not m["content"]: - m["content"] = [{"type": "text", "text": "(tool result removed)"}] + if m.get("role") != "user" or not isinstance(m.get("content"), list): + continue + new_content = [ + b + for b in m["content"] + if not (isinstance(b, dict) and b.get("type") == "tool_result") + or b.get("tool_use_id") in surviving_tool_use_ids + ] + if len(new_content) != len(m["content"]): + m["content"] = new_content if new_content else [{"type": "text", "text": "(tool result removed)"}] def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any]]: