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:
parent
f07f47fe7d
commit
a3257cbf46
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue