From 009cce78ad27c4a147849cae55f0f848d5d9ef78 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Mon, 20 Apr 2026 17:31:12 +0000 Subject: [PATCH] fix(anthropic): also enforce leading-user + empty-array recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend `_merge_consecutive` so the three invariants from `LLMProvider._enforce_role_alternation` all hold for Anthropic: 1. collapse consecutive same-role turns (unchanged) 2. no trailing assistant — Anthropic rejects prefill (unchanged) 3. no leading assistant — Anthropic requires the first turn be user 4. non-empty messages array — recover the last stripped assistant as a user turn when every turn got stripped, so callers don't hit a secondary "messages array empty" 400 Anthropic-specific wrinkle: `tool_use` blocks live inside `content` (not a separate `tool_calls` field) and are illegal inside user turns, so both recovery paths skip any message carrying them rather than silently producing a malformed request. Adds 4 unit tests covering the new branches, including the tool_use opt-outs, and updates the existing `test_single_assistant_stripped` to reflect the new rerouting contract. Made-with: Cursor --- nanobot/providers/anthropic_provider.py | 67 ++++++++++++++-- .../test_anthropic_merge_consecutive.py | 77 ++++++++++++++++++- 2 files changed, 134 insertions(+), 10 deletions(-) diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index 364b799f..1d277d7f 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -246,12 +246,39 @@ class AnthropicProvider(LLMProvider): } @staticmethod - def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Anthropic requires alternating user/assistant roles. + def _has_tool_use(msg: dict[str, Any]) -> bool: + """True if ``msg.content`` carries any ``tool_use`` block. - Also strips trailing assistant messages since Anthropic does not - support assistant-message prefill and will reject the request with - a 400 error if the conversation ends with an assistant turn. + Anthropic forbids ``tool_use`` inside ``user`` turns, so messages that + issued a tool call cannot be safely rerouted when we patch the role. + """ + content = msg.get("content") + if not isinstance(content, list): + return False + return any( + isinstance(block, dict) and block.get("type") == "tool_use" + for block in content + ) + + @staticmethod + def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Normalize a message sequence for Anthropic's ``/messages`` endpoint. + + Anthropic's contract is stricter than OpenAI's: + + 1. Consecutive same-role turns must be collapsed into one. + 2. The conversation cannot end with an ``assistant`` turn — Anthropic + does not support assistant-message prefill and returns 400. + 3. The conversation cannot start with an ``assistant`` turn — the + first message must be ``user``. + + Rules 2 and 3 mirror ``LLMProvider._enforce_role_alternation`` in + ``base.py``, which applies the equivalent invariants to OpenAI-compat + providers. The only Anthropic-specific wrinkle: ``tool_use`` blocks + live inside ``content`` (not a separate ``tool_calls`` field) and are + invalid inside ``user`` turns, so the recovery paths below must skip + any message carrying them rather than silently producing a malformed + request. """ merged: list[dict[str, Any]] = [] for msg in msgs: @@ -268,10 +295,34 @@ class AnthropicProvider(LLMProvider): else: merged.append(msg) - # Drop trailing assistant messages to avoid Anthropic's - # "does not support assistant message prefill" 400 error. + # Rule 2: strip trailing assistant turns — Anthropic rejects prefill. + last_popped: dict[str, Any] | None = None while merged and merged[-1].get("role") == "assistant": - merged.pop() + last_popped = merged.pop() + + # Recovery for rule 2: if stripping removed every turn, reroute the + # last popped assistant as a user turn so upstream code still gets a + # valid request instead of a secondary "messages array empty" 400. + # Skip when the message carried ``tool_use`` blocks (see _has_tool_use). + if ( + not merged + and last_popped is not None + and not AnthropicProvider._has_tool_use(last_popped) + ): + merged.append({"role": "user", "content": last_popped.get("content")}) + + # Rule 3: prepend a synthetic opener if the first surviving turn is an + # assistant (e.g. upstream history truncation dropped the original + # user request). ``tool_use``-carrying assistants are left alone — + # that message will still fail validation, but injecting an opener + # before it would orphan the tool_use/tool_result pair that follows, + # turning a recoverable 400 into a harder-to-diagnose one. + if ( + merged + and merged[0].get("role") == "assistant" + and not AnthropicProvider._has_tool_use(merged[0]) + ): + merged.insert(0, {"role": "user", "content": "(conversation continued)"}) return merged diff --git a/tests/providers/test_anthropic_merge_consecutive.py b/tests/providers/test_anthropic_merge_consecutive.py index 94769f26..7013bd14 100644 --- a/tests/providers/test_anthropic_merge_consecutive.py +++ b/tests/providers/test_anthropic_merge_consecutive.py @@ -60,7 +60,80 @@ class TestMergeConsecutive: result = AnthropicProvider._merge_consecutive(msgs) assert len(result) == 1 - def test_single_assistant_stripped(self): + def test_single_assistant_rerouted_to_user(self): + """When stripping leaves nothing, the last assistant is rerouted to + ``user`` so we don't produce an empty messages array.""" msgs = [{"role": "assistant", "content": "hi"}] result = AnthropicProvider._merge_consecutive(msgs) - assert len(result) == 0 + assert len(result) == 1 + assert result[0]["role"] == "user" + assert result[0]["content"] == "hi" + + def test_all_assistants_collapse_then_rerouted(self): + """Consecutive trailing assistants merge into one, which is then + rerouted as a user turn carrying the merged content.""" + msgs = [ + {"role": "assistant", "content": "a"}, + {"role": "assistant", "content": "b"}, + ] + result = AnthropicProvider._merge_consecutive(msgs) + assert len(result) == 1 + assert result[0]["role"] == "user" + # "b" was merged into "a"'s block list during the merge pass. + assert result[0]["content"] == [ + {"type": "text", "text": "a"}, + {"type": "text", "text": "b"}, + ] + + def test_assistant_with_tool_use_not_rerouted(self): + """A trailing assistant carrying ``tool_use`` blocks cannot become a + user turn (Anthropic rejects ``tool_use`` inside user messages), so + the method returns an empty list rather than forging a bad request.""" + msgs = [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "let me search"}, + {"type": "tool_use", "id": "t1", "name": "search", "input": {}}, + ], + } + ] + result = AnthropicProvider._merge_consecutive(msgs) + assert result == [] + + def test_leading_assistant_gets_synthetic_user(self): + """If the first turn is a bare assistant (e.g. history truncation + dropped the original user request), prepend a synthetic opener so + the conversation still starts with ``user``.""" + msgs = [ + {"role": "assistant", "content": "hi"}, + {"role": "user", "content": "ok"}, + {"role": "assistant", "content": "reply"}, + ] + result = AnthropicProvider._merge_consecutive(msgs) + assert [m["role"] for m in result] == ["user", "assistant", "user"] + assert result[0]["content"] == "(conversation continued)" + assert result[1]["content"] == "hi" + assert result[2]["content"] == "ok" + + def test_leading_assistant_with_tool_use_left_alone(self): + """Don't prepend a synthetic opener before an assistant carrying + ``tool_use``; doing so would orphan the paired ``tool_result`` that + follows. The caller will see the original 400 rather than a + harder-to-diagnose tool-pair mismatch.""" + msgs = [ + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "t1", "name": "search", "input": {}}, + ], + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "ok"}, + ], + }, + ] + result = AnthropicProvider._merge_consecutive(msgs) + assert [m["role"] for m in result] == ["assistant", "user"]