fix(anthropic): drop whitespace-only text blocks reaching the Messages API

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 <noreply@anthropic.com>
This commit is contained in:
Pooyan6 2026-08-03 00:31:57 +02:00 committed by kshitij
parent f07f47fe7d
commit a3257cbf46
2 changed files with 327 additions and 9 deletions

View File

@ -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

View File

@ -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