From e71c1137e8cfca506803f37c67cdc76201310182 Mon Sep 17 00:00:00 2001 From: fesalfayed Date: Wed, 24 Jun 2026 22:54:46 -0400 Subject: [PATCH] fix(anthropic): prepend user turn when compaction leaves a leading assistant message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic extracts the system prompt into a separate `system` field and requires messages[0] to be role="user"; a leading assistant turn is rejected with HTTP 400. After a second context compaction the only surviving leading anchor is the system prompt, so a summary/handoff message emitted as role="assistant" becomes the first messages entry once the system prompt is extracted. Anthropic reports this with a misleading error — `messages.N: tool_use ids were found without tool_result blocks immediately after: toolu_...` — even when every tool_use/tool_result pair is adjacent and matched; the real structural defect is the leading assistant role (#52160). This is engine-agnostic: it fires for any producer of a leading-assistant transcript (built-in compressor, the DAG/LCM context engine, session truncation), unlike the compressor-scoped fix in #52167. The native Bedrock Converse adapter already guards the same invariant (convert_messages_to_converse); this mirrors it for the native Anthropic path. Add _ensure_leading_user_turn() to the convert_messages_to_anthropic post-processing chain, scoped to the system-extracted case (the production trigger) so bare assistant-only unit fixtures are unaffected. Adds regression tests (leading-assistant, leading-assistant-with-adjacent-tool_use, and a no-op negative control). --- agent/anthropic_adapter.py | 7 +++ tests/agent/test_anthropic_adapter.py | 73 ++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index a7f13ba2d777..453140e18125 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -2412,6 +2412,12 @@ 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": + result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]}) + + def convert_messages_to_anthropic( messages: List[Dict], base_url: str | None = None, @@ -2470,6 +2476,7 @@ def convert_messages_to_anthropic( _strip_orphaned_tool_blocks(result) result = _merge_consecutive_roles(result) + _ensure_leading_user_turn(result, system) _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 982defc00a38..55c3aa1a6545 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -1121,7 +1121,8 @@ class TestConvertMessages: ]) _, result = convert_messages_to_anthropic(messages) - assistant_blocks = result[0]["content"] + assistant_msg = next(m for m in result if m["role"] == "assistant") + assistant_blocks = assistant_msg["content"] assert assistant_blocks[0]["type"] == "text" assert assistant_blocks[0]["text"] == "Hello from assistant" @@ -1207,7 +1208,12 @@ class TestConvertMessages: ], native_anthropic=True) _, 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_block = user_msg["content"][0] assert tool_block["type"] == "tool_result" @@ -1356,6 +1362,69 @@ class TestConvertMessages: assert isinstance(result[0]["content"], list) assert result[0]["content"] == [{"type": "text", "text": "(empty message)"}] + def test_leading_assistant_after_compaction_gets_user_turn_prepended(self): + """The adapter backstops compactors that emit a leading assistant summary.""" + messages = [ + {"role": "system", "content": "You are helpful."}, + {"role": "assistant", "content": "[Context compaction summary] earlier work…"}, + {"role": "user", "content": "continue"}, + ] + + system, result = convert_messages_to_anthropic(messages) + + assert system == "You are helpful." + assert result[0]["role"] == "user" + assert result[0]["content"] == [{"type": "text", "text": " "}] + assert result[1]["role"] == "assistant" + assert any( + m["role"] == "assistant" and "Context compaction summary" in str(m["content"]) + for m in result + ) + + def test_leading_user_message_is_not_modified(self): + """A normal transcript that already starts with user must be untouched.""" + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + + _, result = convert_messages_to_anthropic(messages) + + assert len(result) == 2 + assert result[0]["role"] == "user" + assert result[0]["content"] == "hello" + + def test_leading_assistant_with_tool_use_after_compaction_is_repaired(self): + """Repair the leading role without disturbing adjacent tool pairs.""" + messages = [ + {"role": "system", "content": "sys"}, + { + "role": "assistant", + "content": "running it", + "tool_calls": [ + {"id": "toolu_1", "function": {"name": "terminal", "arguments": "{}"}}, + ], + }, + {"role": "tool", "tool_call_id": "toolu_1", "content": "ok"}, + {"role": "user", "content": "next"}, + ] + + _, result = convert_messages_to_anthropic(messages) + + assert result[0]["role"] == "user" + asst_idx = next( + i for i, m in enumerate(result) + if m["role"] == "assistant" + and any(b.get("type") == "tool_use" for b in m["content"] if isinstance(b, dict)) + ) + nxt = result[asst_idx + 1] + assert nxt["role"] == "user" + assert any( + isinstance(b, dict) and b.get("type") == "tool_result" and b.get("tool_use_id") == "toolu_1" + for b in nxt["content"] + ) + # --------------------------------------------------------------------------- # Build kwargs