From a3257cbf46b648c78836fdc455961298dcd8a64a Mon Sep 17 00:00:00 2001 From: Pooyan6 Date: Mon, 3 Aug 2026 00:31:57 +0200 Subject: [PATCH] fix(anthropic): drop whitespace-only text blocks reaching the Messages API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: two independent bugs in convert_messages_to_anthropic() (agent/anthropic_adapter.py), the final conversion step before every Anthropic messages.create() call, both producing HTTP 400 "text content blocks must contain non-whitespace text": 1. _ensure_leading_user_turn() synthesized a filler user turn with content [{"type": "text", "text": " "}] (a single space) whenever the built payload didn't start with role=user (e.g. after context compaction leaves a leading assistant summary). The space is itself whitespace-only, so the guard traded a "leading assistant turn" 400 for the "text content blocks" 400 it now hits. Fixed to reuse the existing non-blank _EMPTY_TEXT_PLACEHOLDER ("(empty)"). 2. _convert_user_message() filtered blank text blocks from list-type user content with an all-or-nothing check: all(blank for b in blocks if b.type == "text"). This is vacuously true when a message has zero text-type blocks (silently destroying valid non-text blocks like images/documents it never inspected), and false as soon as any single text block is non-blank — which let a *sibling* blank text block sit untouched next to valid content and reach Anthropic as-is. Replaced with per-block filtering (mirroring the assistant-side logic already in _convert_assistant_message), preserving all non-blank/non-text blocks and relocating any cache_control marker carried by a dropped block. Also added _scrub_blank_text_blocks(), a final defense-in-depth pass run as the last step of convert_messages_to_anthropic() (after every other transform, including nested tool_result content lists) so a blank text block from any current or future producer never reaches the wire. It logs only structural metadata (message index, role, content location, block index/type) — never message text, tool arguments, tokens, or credentials. An earlier local patch to sanitize_api_messages() (agent_runtime_ helpers.py) attempted to fix this by rewriting blank assistant content before the OpenAI->Anthropic conversion step, but the real leaks were introduced downstream of that sanitizer, inside the Anthropic-specific converter itself — the patch never touched the actual defect and has been fully reverted (agent_runtime_helpers.py is back to its committed state; verified via `git diff` showing no changes). Verified against a real Telegram message end-to-end: the gateway no longer produces the "text content blocks must contain non-whitespace text" error on a fresh conversation turn. Testing: - 9 new end-to-end regression tests in test_anthropic_adapter.py (TestFinalPayloadHasNoBlankTextBlocks) covering content="", content=" ", content=[{"type":"text","text":""}], mixed blank+valid text, blank text next to a valid tool block, an assistant tool-call message with blank content, the leading-synthesized-user-turn case, and a blank text block nested inside a tool_result's own content list. - Fixed one pre-existing test that had asserted the broken " " filler behavior as correct. - Full tests/agent/ + tests/run_agent/ suite (4671 tests) run against both the patched tree and a stashed pre-fix baseline: identical 148 pre-existing failures in both runs (unrelated subsystems — codex app-server integration, credential-pool interrupt handling, OpenAI client lifecycle), zero failures unique to either side. Co-Authored-By: Claude Sonnet 5 --- agent/anthropic_adapter.py | 147 ++++++++++++++++++-- tests/agent/test_anthropic_adapter.py | 189 +++++++++++++++++++++++++- 2 files changed, 327 insertions(+), 9 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 7457119921edb..962ccc6be0be2 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -2311,13 +2311,38 @@ def _convert_user_message(content: Any) -> Dict[str, Any]: """Validate and convert a user message to anthropic format.""" if isinstance(content, list): converted_blocks = _convert_content_to_anthropic(content) - if not converted_blocks or all( - (b.get("text") or "").strip() == "" - for b in converted_blocks - if isinstance(b, dict) and b.get("type") == "text" - ): - converted_blocks = [{"type": "text", "text": "(empty message)"}] - return {"role": "user", "content": converted_blocks} + # Drop individual blank/whitespace-only text blocks rather than an + # all-or-nothing check on the whole list. The prior all() check was + # vacuously true whenever there were zero text-type blocks (e.g. an + # image-only list), which nuked valid non-text blocks (images, + # documents) it never looked at — and, the opposite failure, left a + # blank text block sitting next to a *valid* text block untouched + # (all() is False as soon as one text block is non-blank), which is + # exactly the payload shape Anthropic 400s on: "text content blocks + # must contain non-whitespace text". Mirrors the per-block filtering + # already used for assistant content. + kept_blocks: List[Dict[str, Any]] = [] + dropped_cache_control = None + for blk in converted_blocks: + if ( + isinstance(blk, dict) + and blk.get("type") == "text" + and not (isinstance(blk.get("text"), str) and blk["text"].strip()) + ): + if isinstance(blk.get("cache_control"), dict): + dropped_cache_control = blk["cache_control"] + continue + kept_blocks.append(blk) + if not kept_blocks: + placeholder: Dict[str, Any] = {"type": "text", "text": "(empty message)"} + if dropped_cache_control is not None: + placeholder["cache_control"] = dropped_cache_control + kept_blocks = [placeholder] + elif dropped_cache_control is not None: + _apply_assistant_cache_control_to_last_cacheable_block( + kept_blocks, dropped_cache_control + ) + return {"role": "user", "content": kept_blocks} else: if not content or (isinstance(content, str) and not content.strip()): content = "(empty message)" @@ -2620,9 +2645,114 @@ def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None: Mirror the Bedrock Converse adapter, which unconditionally prepends a minimal user turn when the first message is not user (convert_messages_to_converse). + + The inserted text block must be non-whitespace: Anthropic separately + rejects any text content block whose text is empty or whitespace-only + ("text content blocks must contain non-whitespace text"), so a single + space here traded the "leading assistant turn" 400 for that one (#69512 + class). Uses the same placeholder as every other synthesized filler + block in this module for consistency. """ if result and result[0].get("role") != "user": - result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]}) + result.insert( + 0, {"role": "user", "content": [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}]} + ) + + +def _fix_blank_text_blocks_in_list( + blocks: List[Any], + *, + placeholder_text: str, + msg_index: int, + role: Any, + location: str, +) -> List[Any]: + """Drop blank/whitespace-only text blocks from ``blocks``, in place logic. + + Non-text blocks (tool_use, tool_result, image, document, thinking, …) + and the relative order of everything else are left untouched. A + cache_control marker riding on a dropped block is relocated onto the + last surviving text/tool_use block so a breakpoint is never silently + lost. If nothing survives, a single non-blank placeholder text block + takes the dropped blocks' place (carrying the relocated cache_control, + if any) so the message never has empty content. + + Returns a new list; does not mutate ``blocks``. + """ + kept: List[Any] = [] + relocated_cache_control = None + for block_index, blk in enumerate(blocks): + if ( + isinstance(blk, dict) + and blk.get("type") == "text" + and not (isinstance(blk.get("text"), str) and blk["text"].strip()) + ): + if isinstance(blk.get("cache_control"), dict): + relocated_cache_control = blk["cache_control"] + logger.warning( + "Pre-call sanitizer: dropped blank text content block " + "(message_index=%d role=%s location=%s block_index=%d " + "block_type=text)", + msg_index, + role, + location, + block_index, + ) + continue + kept.append(blk) + if not kept: + placeholder: Dict[str, Any] = {"type": "text", "text": placeholder_text} + if relocated_cache_control is not None: + placeholder["cache_control"] = relocated_cache_control + kept.append(placeholder) + elif relocated_cache_control is not None: + _apply_assistant_cache_control_to_last_cacheable_block(kept, relocated_cache_control) + return kept + + +def _scrub_blank_text_blocks(result: List[Dict[str, Any]]) -> None: + """Final provider-boundary guard against blank Anthropic text blocks. + + Anthropic rejects any text content block whose ``text`` is empty or + whitespace-only with HTTP 400 ("text content blocks must contain + non-whitespace text"). ``_convert_assistant_message``, + ``_convert_user_message`` and ``_ensure_leading_user_turn`` already + avoid emitting these for the paths that build them, but this pass runs + last — after every other transform in ``convert_messages_to_anthropic`` + — so a blank block from any current or future producer (including one + nested inside a ``tool_result``'s own content list) never reaches the + wire. Diagnostics are structural only: message index, role, content + location, block index/type. Never logs message text, tool arguments, + tokens, or credentials. Mutates ``result`` in place. + """ + for msg_index, msg in enumerate(result): + if not isinstance(msg, dict): + continue + role = msg.get("role") + content = msg.get("content") + if not isinstance(content, list) or not content: + continue + placeholder_text = _EMPTY_TEXT_PLACEHOLDER if role == "assistant" else "(empty message)" + new_content = _fix_blank_text_blocks_in_list( + content, + placeholder_text=placeholder_text, + msg_index=msg_index, + role=role, + location="content", + ) + for blk in new_content: + if not isinstance(blk, dict) or blk.get("type") != "tool_result": + continue + inner = blk.get("content") + if isinstance(inner, list) and inner: + blk["content"] = _fix_blank_text_blocks_in_list( + inner, + placeholder_text="(no output)", + msg_index=msg_index, + role=role, + location="tool_result", + ) + msg["content"] = new_content def convert_messages_to_anthropic( @@ -2686,6 +2816,7 @@ def convert_messages_to_anthropic( _ensure_leading_user_turn(result) _manage_thinking_signatures(result, base_url, model) _evict_old_screenshots(result) + _scrub_blank_text_blocks(result) return system, result diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index d29ed2080eb37..876f9b77aedf6 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -880,7 +880,7 @@ class TestConvertMessages: assert system == "You are helpful." assert result[0]["role"] == "user" - assert result[0]["content"] == [{"type": "text", "text": " "}] + assert result[0]["content"] == [{"type": "text", "text": "(empty)"}] assert result[1]["role"] == "assistant" assert any( m["role"] == "assistant" and "Context compaction summary" in str(m["content"]) @@ -1670,3 +1670,190 @@ class TestReplayAllBlankFallback: result = self._convert(msg) texts = [b for b in result["content"] if b.get("type") == "text"] assert texts == [{"type": "text", "text": "(empty)"}] + + +def _find_blank_text_blocks(messages): + """Recursively scan a converted Anthropic message list (including + nested tool_result content) for any text block whose text is empty or + whitespace-only. Returns a list of (message_index, role, location, + block_index) tuples for every violation found -- empty means the + payload is safe to send to Anthropic.""" + violations = [] + for m_idx, msg in enumerate(messages): + content = msg.get("content") + if not isinstance(content, list): + continue + for b_idx, blk in enumerate(content): + if not isinstance(blk, dict): + continue + if blk.get("type") == "text" and not ( + isinstance(blk.get("text"), str) and blk["text"].strip() + ): + violations.append((m_idx, msg.get("role"), "content", b_idx)) + if blk.get("type") == "tool_result" and isinstance(blk.get("content"), list): + for ib_idx, iblk in enumerate(blk["content"]): + if ( + isinstance(iblk, dict) + and iblk.get("type") == "text" + and not (isinstance(iblk.get("text"), str) and iblk["text"].strip()) + ): + violations.append((m_idx, msg.get("role"), "tool_result", ib_idx)) + return violations + + +class TestFinalPayloadHasNoBlankTextBlocks: + """End-to-end regression tests on the true final payload boundary: + ``convert_messages_to_anthropic`` -- the last transform before + ``build_anthropic_kwargs`` hands ``messages`` to the Anthropic SDK. + + Covers the blank-content shapes enumerated for the "text content + blocks must contain non-whitespace text" HTTP 400 class, verifying the + final built payload never contains a blank text block while tool_use, + tool_result, and image content are preserved. + """ + + def test_user_message_empty_string_content(self): + messages = [{"role": "user", "content": ""}] + _, result = convert_messages_to_anthropic(messages) + assert _find_blank_text_blocks(result) == [] + assert result[0]["content"] == "(empty message)" + + def test_user_message_whitespace_only_string_content(self): + messages = [{"role": "user", "content": " "}] + _, result = convert_messages_to_anthropic(messages) + assert _find_blank_text_blocks(result) == [] + assert result[0]["content"] == "(empty message)" + + def test_user_message_blank_list_content(self): + messages = [{"role": "user", "content": [{"type": "text", "text": ""}]}] + _, result = convert_messages_to_anthropic(messages) + assert _find_blank_text_blocks(result) == [] + assert result[0]["content"] == [{"type": "text", "text": "(empty message)"}] + + def test_user_message_mixed_blank_and_valid_text_blocks(self): + """A blank text block sitting alongside a non-blank one must be + dropped individually -- not left in place (the all-or-nothing bug) + and not used as an excuse to nuke the valid sibling block.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "real question"}, + {"type": "text", "text": " "}, + ], + } + ] + _, result = convert_messages_to_anthropic(messages) + assert _find_blank_text_blocks(result) == [] + assert result[0]["content"] == [{"type": "text", "text": "real question"}] + + def test_mixed_blank_text_plus_valid_tool_block_preserved(self): + """Blank text next to a valid non-text block (tool_result) must + drop only the blank text and keep the tool block intact.""" + messages = [ + {"role": "user", "content": "call a tool"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "function": {"name": "web_search", "arguments": '{"query": "x"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "result text"}, + ] + _, result = convert_messages_to_anthropic(messages) + assert _find_blank_text_blocks(result) == [] + assistant_msg = next(m for m in result if m["role"] == "assistant") + tool_use_blocks = [b for b in assistant_msg["content"] if b.get("type") == "tool_use"] + assert len(tool_use_blocks) == 1 + tool_result_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 tool_result_msg is not None + + def test_assistant_tool_call_message_with_blank_content(self): + """OpenAI-wire-shaped assistant turn: content is a blank string, + tool_calls carries the real payload. Must not surface a blank text + block, and the tool_use block must survive untouched.""" + messages = [ + {"role": "user", "content": "do it"}, + { + "role": "assistant", + "content": " ", + "tool_calls": [ + { + "id": "call_2", + "function": {"name": "web_search", "arguments": '{"query": "y"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_2", "content": "ok"}, + ] + _, result = convert_messages_to_anthropic(messages) + assert _find_blank_text_blocks(result) == [] + assistant_msg = next(m for m in result if m["role"] == "assistant") + assert assistant_msg["content"] == [ + {"type": "tool_use", "id": "call_2", "name": "web_search", "input": {"query": "y"}} + ] + + def test_leading_synthesized_user_turn_is_non_blank(self): + """_ensure_leading_user_turn's synthesized filler must itself be + non-whitespace -- regression for the literal " " placeholder bug.""" + messages = [ + {"role": "system", "content": "sys"}, + {"role": "assistant", "content": "[Context compaction summary] earlier work"}, + {"role": "user", "content": "continue"}, + ] + _, result = convert_messages_to_anthropic(messages) + assert _find_blank_text_blocks(result) == [] + assert result[0]["content"] == [{"type": "text", "text": "(empty)"}] + + def test_blank_text_nested_in_tool_result_content_is_dropped(self): + """A blank text part nested inside a tool_result's own multimodal + content list (e.g. alongside an image) must be scrubbed without + losing the image.""" + messages = [ + {"role": "user", "content": "screenshot please"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_3", + "function": {"name": "screenshot", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_3", + "content": [ + {"type": "text", "text": " "}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,AAAA"}, + }, + ], + }, + ] + _, result = convert_messages_to_anthropic(messages) + assert _find_blank_text_blocks(result) == [] + tool_result_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_result_block = next( + b for b in tool_result_msg["content"] if b.get("type") == "tool_result" + ) + image_blocks = [b for b in tool_result_block["content"] if b.get("type") == "image"] + assert len(image_blocks) == 1