From 4c9628eab5393e7561bbd2c1faaa1765fb14a5f9 Mon Sep 17 00:00:00 2001 From: Prathamesh Chaudhari <118293218+PRATHAMESH75@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:06:37 +0530 Subject: [PATCH] fix(anthropic): coerce empty/whitespace-only text blocks on the request path (#69512) (#69517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An assistant message with an empty or whitespace-only text content block — produced by context compression or certain tool-call flows — is rejected by the Anthropic Messages API with HTTP 400 "text content blocks must contain non-whitespace text". Because the blank block is stored in session history and replayed verbatim every turn, the session is permanently wedged behind the same 400. The Bedrock adapter already guards this via _safe_text() (#9486); the native Anthropic path never got the same treatment. _sanitize_replay_block() rebuilt text blocks with the raw stored text, and the _convert_assistant_message() guard only caught a fully empty block list, not a list still containing a whitespace-only text block. Add a _safe_text() helper mirroring the Bedrock one and apply it at both points: the ordered-blocks replay path and a final in-place walk of the converted content list. Both are self-healing — sessions that stored blank blocks recover on the next API call. Only text blocks are coerced; thinking/tool_use/image blocks are untouched. Fixes #69512 --- agent/anthropic_adapter.py | 38 +++++- .../test_anthropic_whitespace_text_blocks.py | 111 ++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 tests/agent/test_anthropic_whitespace_text_blocks.py diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index fd7596e7e3bf..38431a8c1f5a 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1881,6 +1881,28 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]: return out +_EMPTY_TEXT_PLACEHOLDER = "(empty)" + + +def _safe_text(text: Any) -> str: + """Return ``text`` if it's non-whitespace, else a non-whitespace placeholder. + + The Anthropic Messages API rejects requests where a text content block is + empty or whitespace-only (HTTP 400 "text content blocks must contain + non-whitespace text"). When such a block gets stored in session history — + e.g. produced by context compression — it is replayed verbatim on every + subsequent turn, permanently wedging the session. Coercing to a + non-whitespace placeholder is self-healing: the next API call recovers. + + Mirrors ``bedrock_adapter._safe_text`` (#9486); ref #69512. + """ + if text is None: + return _EMPTY_TEXT_PLACEHOLDER + if not isinstance(text, str): + text = str(text) + return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER + + def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Strip output-only fields from a stored Anthropic content block so it is valid as REQUEST input on replay. @@ -1898,7 +1920,10 @@ def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: return None btype = b.get("type") if btype == "text": - out: Dict[str, Any] = {"type": "text", "text": b.get("text", "")} + # Coerce empty/whitespace-only text to a non-whitespace placeholder; + # the Messages input schema rejects blank text blocks (#69512), and a + # blank block stored in history replays on every turn → permanent 400. + out: Dict[str, Any] = {"type": "text", "text": _safe_text(b.get("text", ""))} # citations is input-valid ONLY when it's a non-empty list; the SDK # emits citations=None on responses, which the input schema rejects. cits = b.get("citations") @@ -2058,7 +2083,16 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: # Anthropic rejects empty assistant content effective = blocks or content if not effective or effective == "": - effective = [{"type": "text", "text": "(empty)"}] + effective = [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}] + elif isinstance(effective, list): + # The all-empty guard above misses a list that still contains a + # whitespace-only text block (e.g. from a content array of blank parts, + # or compression). Those also trip "text content blocks must contain + # non-whitespace text" (#69512). Coerce text blocks in place; other + # block types (thinking/tool_use/image) are left untouched. + for blk in effective: + if isinstance(blk, dict) and blk.get("type") == "text": + blk["text"] = _safe_text(blk.get("text", "")) return {"role": "assistant", "content": effective} diff --git a/tests/agent/test_anthropic_whitespace_text_blocks.py b/tests/agent/test_anthropic_whitespace_text_blocks.py new file mode 100644 index 000000000000..3ec7366a7463 --- /dev/null +++ b/tests/agent/test_anthropic_whitespace_text_blocks.py @@ -0,0 +1,111 @@ +"""Regression: whitespace-only text blocks must be coerced, not sent verbatim. + +Reproduces HTTP 400 ``messages: text content blocks must contain non-whitespace +text``. When context compression (or certain tool-call flows) produces an +assistant message with an empty or whitespace-only text block, that block is +stored in session history and replayed on every subsequent turn — permanently +wedging the session behind the same 400. + +Fix (mirrors ``bedrock_adapter._safe_text``, ref #9486): coerce empty/whitespace +text to a non-whitespace placeholder at two points on the Anthropic request path +— ``_sanitize_replay_block`` (ordered-blocks replay) and the final content walk +in ``_convert_assistant_message`` (main path). Ref #69512. +""" +import pytest +from agent.anthropic_adapter import ( + _EMPTY_TEXT_PLACEHOLDER, + _safe_text, + _sanitize_replay_block, + _convert_assistant_message, +) + + +def _text_blocks(msg): + return [b for b in msg["content"] if isinstance(b, dict) and b.get("type") == "text"] + + +def _assert_no_blank_text(msg): + """No text content block in the converted message is empty/whitespace-only.""" + assert isinstance(msg["content"], list) + for b in _text_blocks(msg): + assert b["text"].strip(), f"blank text block survived: {b!r}" + + +class TestSafeText: + def test_none_becomes_placeholder(self): + assert _safe_text(None) == _EMPTY_TEXT_PLACEHOLDER + + def test_empty_string_becomes_placeholder(self): + assert _safe_text("") == _EMPTY_TEXT_PLACEHOLDER + + @pytest.mark.parametrize("blank", [" ", "\n", "\t", " \n\t "]) + def test_whitespace_only_becomes_placeholder(self, blank): + assert _safe_text(blank) == _EMPTY_TEXT_PLACEHOLDER + + def test_real_text_is_kept_verbatim(self): + assert _safe_text("hello") == "hello" + assert _safe_text(" padded ") == " padded " + + def test_non_string_is_coerced_then_checked(self): + assert _safe_text(123) == "123" + + +class TestSanitizeReplayBlockWhitespace: + def test_whitespace_text_block_coerced_to_placeholder(self): + out = _sanitize_replay_block({"type": "text", "text": " \n"}) + assert out == {"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER} + + def test_empty_text_block_coerced_to_placeholder(self): + out = _sanitize_replay_block({"type": "text", "text": ""}) + assert out == {"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER} + + def test_real_text_block_unchanged(self): + out = _sanitize_replay_block({"type": "text", "text": "hi"}) + assert out == {"type": "text", "text": "hi"} + + +class TestConvertAssistantMessageWhitespace: + def test_ordered_blocks_replay_coerces_blank_text(self): + # The interleaved-thinking fast path replays anthropic_content_blocks + # through _sanitize_replay_block; a stored whitespace text block here is + # exactly the compression-produced poison that wedges the session. + msg = { + "role": "assistant", + "anthropic_content_blocks": [ + {"type": "thinking", "thinking": "reasoning", "signature": "sig-A"}, + {"type": "text", "text": " "}, + ], + } + out = _convert_assistant_message(msg) + _assert_no_blank_text(out) + assert _text_blocks(out) == [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}] + + def test_main_path_coerces_whitespace_string_content(self): + # A whitespace-only string content becomes a whitespace text block that + # the all-empty guard does not catch; the final walk must coerce it. + out = _convert_assistant_message({"role": "assistant", "content": " "}) + _assert_no_blank_text(out) + assert _text_blocks(out) == [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}] + + def test_fully_empty_content_still_gets_placeholder(self): + # Pre-existing behavior preserved. + out = _convert_assistant_message({"role": "assistant", "content": ""}) + assert out["content"] == [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}] + + def test_real_text_content_unchanged(self): + out = _convert_assistant_message({"role": "assistant", "content": "answer"}) + assert out["content"] == [{"type": "text", "text": "answer"}] + + def test_thinking_block_not_treated_as_text(self): + # Only text blocks are coerced; thinking blocks are left untouched even + # if their payload is whitespace (they obey a different schema rule). + msg = { + "role": "assistant", + "anthropic_content_blocks": [ + {"type": "thinking", "thinking": " ", "signature": "sig-B"}, + {"type": "text", "text": "real"}, + ], + } + out = _convert_assistant_message(msg) + thinking = [b for b in out["content"] if b.get("type") == "thinking"] + assert thinking == [{"type": "thinking", "thinking": " ", "signature": "sig-B"}]