From 2f02342083373eb8504154568848e59db1b41a7d Mon Sep 17 00:00:00 2001 From: hussein1362 Date: Sun, 19 Apr 2026 09:17:27 +0300 Subject: [PATCH] fix(anthropic): strip trailing assistant messages to prevent prefill error Anthropic does not support assistant-message prefill and returns a 400 error when the conversation ends with an assistant turn. This commonly happens when heartbeat/system messages accumulate trailing assistant replies in the session history. The _merge_consecutive method already handles same-role merging but did not strip trailing assistant messages. The base provider's _enforce_role_alternation (used by OpenAI-compat) does strip them, but AnthropicProvider uses its own _merge_consecutive instead. Add a trailing-assistant stripping loop to _merge_consecutive, matching the behavior already present in _enforce_role_alternation. Includes 7 new tests covering merge + strip behavior. --- nanobot/providers/anthropic_provider.py | 13 +++- .../test_anthropic_merge_consecutive.py | 66 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 tests/providers/test_anthropic_merge_consecutive.py diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index 8c1d5cc2..364b799f 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -247,7 +247,12 @@ class AnthropicProvider(LLMProvider): @staticmethod def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Anthropic requires alternating user/assistant roles.""" + """Anthropic requires alternating user/assistant roles. + + 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. + """ merged: list[dict[str, Any]] = [] for msg in msgs: if merged and merged[-1]["role"] == msg["role"]: @@ -262,6 +267,12 @@ class AnthropicProvider(LLMProvider): merged[-1]["content"] = prev_c else: merged.append(msg) + + # Drop trailing assistant messages to avoid Anthropic's + # "does not support assistant message prefill" 400 error. + while merged and merged[-1].get("role") == "assistant": + merged.pop() + return merged # ------------------------------------------------------------------ diff --git a/tests/providers/test_anthropic_merge_consecutive.py b/tests/providers/test_anthropic_merge_consecutive.py new file mode 100644 index 00000000..94769f26 --- /dev/null +++ b/tests/providers/test_anthropic_merge_consecutive.py @@ -0,0 +1,66 @@ +"""Tests for AnthropicProvider._merge_consecutive.""" + +from nanobot.providers.anthropic_provider import AnthropicProvider + + +class TestMergeConsecutive: + """Verify role alternation and trailing-assistant stripping.""" + + def test_basic_alternation(self): + msgs = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + {"role": "user", "content": "bye"}, + ] + result = AnthropicProvider._merge_consecutive(msgs) + assert len(result) == 3 + assert [m["role"] for m in result] == ["user", "assistant", "user"] + + def test_consecutive_same_role_merged(self): + msgs = [ + {"role": "user", "content": "a"}, + {"role": "user", "content": "b"}, + {"role": "assistant", "content": "reply"}, + ] + result = AnthropicProvider._merge_consecutive(msgs) + # Two user messages merged into one, trailing assistant stripped + assert len(result) == 1 + assert result[0]["role"] == "user" + + def test_trailing_assistant_stripped(self): + """Anthropic rejects prefill — trailing assistant must be removed.""" + msgs = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + result = AnthropicProvider._merge_consecutive(msgs) + assert len(result) == 1 + assert result[0]["role"] == "user" + assert result[0]["content"] == "hello" + + def test_multiple_trailing_assistant_stripped(self): + msgs = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "a"}, + {"role": "user", "content": "ok"}, + {"role": "assistant", "content": "b"}, + {"role": "assistant", "content": "c"}, + ] + result = AnthropicProvider._merge_consecutive(msgs) + # b+c merged into one assistant, then stripped as trailing + assert len(result) == 3 + assert result[-1]["role"] == "user" + assert result[-1]["content"] == "ok" + + def test_empty_messages(self): + assert AnthropicProvider._merge_consecutive([]) == [] + + def test_single_user_message(self): + msgs = [{"role": "user", "content": "hi"}] + result = AnthropicProvider._merge_consecutive(msgs) + assert len(result) == 1 + + def test_single_assistant_stripped(self): + msgs = [{"role": "assistant", "content": "hi"}] + result = AnthropicProvider._merge_consecutive(msgs) + assert len(result) == 0