From 753cd04b4e6520f8e4e31cfea4ac436d8ee030e6 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sun, 29 Mar 2026 18:26:25 -0700 Subject: [PATCH] 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. --- turnstone/core/providers/_anthropic.py | 32 +++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/turnstone/core/providers/_anthropic.py b/turnstone/core/providers/_anthropic.py index 819dd619..84975408 100644 --- a/turnstone/core/providers/_anthropic.py +++ b/turnstone/core/providers/_anthropic.py @@ -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":