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"}]