Fix/orphaned tool results (#243)

* fix: drop orphaned tool_results with no matching tool_use in _convert_messages

The context window increase from 200K to 1M for Claude 4.6 means
conversations that previously triggered auto-compaction now send their
full history.  Older messages with orphaned tool_results (from
pre-fix cancels or compaction boundaries) are now visible to the API,
causing "unexpected tool_use_id in tool_result blocks" errors.

The existing repair code handles orphaned tool_use (synthesizes
missing results), but not the reverse.  Now validates each
tool_result against the preceding assistant message's tool_use IDs
and silently drops results with no match.

* fix: filter empty IDs from prev_tool_use_ids, document pass-through

Code review: empty-ID tool_use blocks were added to the filter set,
and the intentional pass-through when prev_tool_use_ids is empty
needed documentation.
This commit is contained in:
Patrick Buckley
2026-03-29 18:26:25 -07:00
committed by GitHub
parent c3217748dc
commit 753cd04b4e
+29 -3
View File
@@ -405,17 +405,42 @@ class AnthropicProvider:
continue
if role == "tool":
# Anthropic: tool results are content blocks in a user message
# Anthropic: tool results are content blocks in a user message.
# Collect valid tool_use IDs from the preceding assistant message
# so we can drop orphaned tool_results that have no matching
# tool_use (e.g. from compaction boundary, old cancel stripping).
prev_tool_use_ids: set[str] = set()
if converted and converted[-1].get("role") == "assistant":
prev_content = converted[-1].get("content", [])
if isinstance(prev_content, list):
for block in prev_content:
if isinstance(block, dict) and block.get("type") == "tool_use":
bid = block.get("id", "")
if bid:
prev_tool_use_ids.add(bid)
tool_results: list[dict[str, Any]] = []
while i < len(messages) and messages[i]["role"] == "tool":
tool_msg = messages[i]
tc_id = tool_msg.get("tool_call_id", "")
# Drop orphaned tool_results with no matching tool_use.
# When prev_tool_use_ids is empty (no preceding assistant
# tool_use), let all results through — avoids false drops
# from unexpected message ordering.
if prev_tool_use_ids and tc_id not in prev_tool_use_ids:
log.debug(
"Dropping orphaned tool_result (no matching tool_use): %s",
tc_id,
)
i += 1
continue
content = tool_msg.get("content", "")
# Convert image_url parts to Anthropic image format
if isinstance(content, list):
content = self._convert_content_parts(content)
result_block: dict[str, Any] = {
"type": "tool_result",
"tool_use_id": tool_msg.get("tool_call_id", ""),
"tool_use_id": tc_id,
"content": content,
}
if tool_msg.get("is_error"):
@@ -426,7 +451,8 @@ class AnthropicProvider:
if pending_orphan_results:
tool_results.extend(pending_orphan_results)
pending_orphan_results = []
converted.append({"role": "user", "content": tool_results})
if tool_results:
converted.append({"role": "user", "content": tool_results})
continue
if role == "user":