From b6a2b701c1d323db3a620ab19ea0db6d931955c8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:52:04 -0700 Subject: [PATCH] fix(anthropic): extend leading-user guard to the extracted-system-absent path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged guard from #52276 only fired when a system prompt was present in messages[] (system is not None after extraction). The live repro of #52160 is the auto path: the system prompt is passed outside messages[], so after the second compaction messages[0] is the assistant-role summary with system=None — and the guard never ran. Make _ensure_leading_user_turn unconditional, exactly mirroring the Bedrock Converse adapter ('Converse requires the first message to be from the user' — convert_messages_to_converse). Add a regression test building the exact post-double-compaction shape (no system in messages, messages[0]=assistant summary) asserting the converted payload leads with a user turn, and update existing fixtures that started with a bare assistant message to locate roles instead of indexing result[0]. --- agent/anthropic_adapter.py | 20 +++++++-- tests/agent/test_anthropic_adapter.py | 61 ++++++++++++++++++++++----- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 453140e18125..fd7596e7e3bf 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -2412,9 +2412,21 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None: ] -def _ensure_leading_user_turn(result: List[Dict[str, Any]], system: Any) -> None: - """Anthropic requires messages[0] to be user when system is extracted.""" - if system is not None and result and result[0].get("role") != "user": +def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None: + """Anthropic requires messages[0] to have role=user. + + After a second context compaction on the auto path the summary can be + emitted as role=assistant with nothing in front of it (the system prompt + lives outside messages[] or is extracted into the separate ``system`` + param), so messages[0] ends up assistant and the Messages API rejects + the request with HTTP 400 — often masked by a misleading + "tool_use ids were found without tool_result blocks" error (#52160). + + Mirror the Bedrock Converse adapter, which unconditionally prepends a + minimal user turn when the first message is not user + (convert_messages_to_converse). + """ + if result and result[0].get("role") != "user": result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]}) @@ -2476,7 +2488,7 @@ def convert_messages_to_anthropic( _strip_orphaned_tool_blocks(result) result = _merge_consecutive_roles(result) - _ensure_leading_user_turn(result, system) + _ensure_leading_user_turn(result) _manage_thinking_signatures(result, base_url, model) _evict_old_screenshots(result) diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 55c3aa1a6545..ae99f950f739 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -945,7 +945,7 @@ class TestConvertMessages: {"role": "tool", "tool_call_id": "tc_1", "content": "search results"}, ] _, result = convert_messages_to_anthropic(messages) - blocks = result[0]["content"] + blocks = next(m for m in result if m["role"] == "assistant")["content"] assert blocks[0] == {"type": "text", "text": "Let me search."} assert blocks[1]["type"] == "tool_use" assert blocks[1]["id"] == "tc_1" @@ -963,8 +963,13 @@ class TestConvertMessages: {"role": "tool", "tool_call_id": "tc_1", "content": "result data"}, ] _, result = convert_messages_to_anthropic(messages) - # tool result is in the second message (user role) - user_msg = [m for m in result if m["role"] == "user"][0] + # tool result is in the user message following the assistant turn + user_msg = next( + m for m in result + if m["role"] == "user" + and isinstance(m["content"], list) + and any(b.get("type") == "tool_result" for b in m["content"]) + ) assert user_msg["content"][0]["type"] == "tool_result" assert user_msg["content"][0]["tool_use_id"] == "tc_1" @@ -983,7 +988,12 @@ class TestConvertMessages: ] _, result = convert_messages_to_anthropic(messages) # assistant + merged user (with 2 tool_results) - user_msgs = [m for m in result if m["role"] == "user"] + user_msgs = [ + m for m in result + if m["role"] == "user" + and isinstance(m["content"], list) + and any(b.get("type") == "tool_result" for b in m["content"]) + ] assert len(user_msgs) == 1 assert len(user_msgs[0]["content"]) == 2 @@ -1041,7 +1051,12 @@ class TestConvertMessages: {"role": "tool", "tool_call_id": "tc_orphan", "content": "stale result"}, ] _, result = convert_messages_to_anthropic(messages) - user_msg = [m for m in result if m["role"] == "user"][0] + user_msg = next( + m for m in result + if m["role"] == "user" + and isinstance(m["content"], list) + and any(b.get("type") == "tool_result" for b in m["content"]) + ) tool_results = [ b for b in user_msg["content"] if b.get("type") == "tool_result" ] @@ -1096,7 +1111,12 @@ class TestConvertMessages: _, result = convert_messages_to_anthropic(messages) asst = [m for m in result if m["role"] == "assistant"][0] assert any(b.get("type") == "tool_use" for b in asst["content"]) - user = [m for m in result if m["role"] == "user"][0] + user = next( + m for m in result + if m["role"] == "user" + and isinstance(m["content"], list) + and any(b.get("type") == "tool_result" for b in m["content"]) + ) assert any(b.get("type") == "tool_result" for b in user["content"]) def test_system_with_cache_control(self): @@ -1381,6 +1401,27 @@ class TestConvertMessages: for m in result ) + def test_double_compaction_no_system_in_messages_leads_with_user(self): + """Exact post-double-compaction shape on the auto path (#52160). + + On the auto path the system prompt is NOT inside messages[] and after + the second compaction protect_head has decayed to 0, so the + assistant-role summary is messages[0]. The converted payload must + still lead with a user turn or Anthropic 400s. + """ + messages = [ + {"role": "assistant", "content": "[Context compaction summary] earlier work…"}, + {"role": "user", "content": "continue"}, + ] + + system, result = convert_messages_to_anthropic(messages) + + assert system is None + assert result[0]["role"] == "user" + assert result[0]["content"] == [{"type": "text", "text": " "}] + assert result[1]["role"] == "assistant" + assert "Context compaction summary" in str(result[1]["content"]) + def test_leading_user_message_is_not_modified(self): """A normal transcript that already starts with user must be untouched.""" messages = [ @@ -2145,7 +2186,7 @@ class TestThinkingBlockSignatureManagement: }, ] _, result = convert_messages_to_anthropic(messages) - blocks = result[0]["content"] + blocks = next(m for m in result if m["role"] == "assistant")["content"] thinking = [b for b in blocks if b.get("type") == "thinking"] assert len(thinking) == 1 assert thinking[0]["signature"] == "sig_valid" @@ -2163,7 +2204,7 @@ class TestThinkingBlockSignatureManagement: }, ] _, result = convert_messages_to_anthropic(messages) - blocks = result[0]["content"] + blocks = next(m for m in result if m["role"] == "assistant")["content"] # No thinking blocks should remain assert not any(b.get("type") == "thinking" for b in blocks) @@ -2183,7 +2224,7 @@ class TestThinkingBlockSignatureManagement: }, ] _, result = convert_messages_to_anthropic(messages) - blocks = result[0]["content"] + blocks = next(m for m in result if m["role"] == "assistant")["content"] redacted = [b for b in blocks if b.get("type") == "redacted_thinking"] assert len(redacted) == 1 assert redacted[0]["data"] == "opaque_signature_data" @@ -2277,7 +2318,7 @@ class TestThinkingBlockSignatureManagement: _, result = convert_messages_to_anthropic(messages) # First assistant is non-last, so thinking is stripped completely. # The original content was empty and thinking was unsigned → placeholder - first_assistant = result[0] + first_assistant = next(m for m in result if m["role"] == "assistant") assert first_assistant["role"] == "assistant" assert len(first_assistant["content"]) >= 1