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>
This commit is contained in:
fsaad1984 2026-07-01 15:18:39 +05:30 committed by kshitij
parent ede5c09f3b
commit 5881791adc

View file

@ -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]]: