fix(anthropic): coerce blank system text blocks at extraction (#70909)
Residual from PR #70910 after #77509 landed the message-list scrub: a whitespace-only system content block carrying a cache_control marker still reached the wire and 400'd the whole request ("text content blocks must contain non-whitespace text"), wedging the session on every retry. The block cannot be dropped (it carries the cache breakpoint), so coerce its text to the shared non-whitespace placeholder when extracting the system param, copying the block so caller message dicts are never mutated. Adds SHL0MS's request-level regression suite from #70910; four of its five cases already pass on main via #77509 — the system-block case fails without this fix.
This commit is contained in:
@@ -2814,7 +2814,26 @@ def convert_messages_to_anthropic(
|
||||
p.get("cache_control") for p in content if isinstance(p, dict)
|
||||
)
|
||||
if has_cache:
|
||||
system = [p for p in content if isinstance(p, dict)]
|
||||
# Copy blocks before coercing so the caller's message
|
||||
# dicts are never mutated, then replace blank/whitespace
|
||||
# text with the shared non-whitespace placeholder —
|
||||
# Anthropic rejects a blank system text block with the
|
||||
# same HTTP 400 as message blocks ("text content blocks
|
||||
# must contain non-whitespace text"), and a blank block
|
||||
# carrying a cache_control breakpoint cannot simply be
|
||||
# dropped (#70909).
|
||||
system = []
|
||||
for p in content:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
if (
|
||||
p.get("type") == "text"
|
||||
and isinstance(p.get("text"), str)
|
||||
and not p["text"].strip()
|
||||
):
|
||||
p = dict(p)
|
||||
p["text"] = _EMPTY_TEXT_PLACEHOLDER
|
||||
system.append(p)
|
||||
else:
|
||||
system = "\n".join(
|
||||
p["text"] for p in content if p.get("type") == "text"
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Regression: the final Anthropic request must carry no blank text block.
|
||||
|
||||
`convert_messages_to_anthropic` runs per-message converters that coerce blanks
|
||||
they produce, but a blank/whitespace-only text block can be synthesized *after*
|
||||
those run — a compression summary message, a role merge, or an upstream message
|
||||
whose content arrives pre-shaped as content blocks. Any single blank text block
|
||||
makes Anthropic reject the whole request with HTTP 400 "text content blocks must
|
||||
contain non-whitespace text", which then replays on every turn and wedges the
|
||||
session.
|
||||
|
||||
`_scrub_blank_text_blocks` is the final backstop on the fully-assembled message
|
||||
list, and the system-block path coerces blanks at extraction time (a blank block
|
||||
carrying a cache_control breakpoint cannot be dropped).
|
||||
Ref #69512 / #70909 (follow-up: request-level guard, not just per-message).
|
||||
"""
|
||||
from agent.anthropic_adapter import (
|
||||
_EMPTY_TEXT_PLACEHOLDER,
|
||||
convert_messages_to_anthropic,
|
||||
)
|
||||
|
||||
|
||||
def _all_text_blocks(messages):
|
||||
for m in messages:
|
||||
content = m.get("content")
|
||||
if isinstance(content, list):
|
||||
for b in content:
|
||||
if isinstance(b, dict) and b.get("type") == "text":
|
||||
yield b
|
||||
|
||||
|
||||
def _assert_no_blank(system, messages):
|
||||
if isinstance(system, list):
|
||||
for b in system:
|
||||
if isinstance(b, dict) and b.get("type") == "text":
|
||||
assert b["text"].strip(), f"blank text block in system: {b!r}"
|
||||
for b in _all_text_blocks(messages):
|
||||
assert b["text"].strip(), f"blank text block survived: {b!r}"
|
||||
|
||||
|
||||
def test_pre_shaped_blank_block_in_user_content_is_coerced():
|
||||
# Content arrives already as blocks with a blank text part — the per-message
|
||||
# user converter does not walk-and-coerce these, so the final guard must.
|
||||
messages = [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": " "},
|
||||
{"type": "text", "text": "real question"},
|
||||
]},
|
||||
]
|
||||
system, result = convert_messages_to_anthropic(messages)
|
||||
_assert_no_blank(system, result)
|
||||
|
||||
|
||||
def test_blank_summary_style_user_message_is_coerced():
|
||||
# A compression summary that came back empty becomes a whitespace user
|
||||
# message; it must not reach the wire as a blank block.
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "answer"},
|
||||
{"role": "user", "content": "\n\n"}, # empty "summary"-style turn
|
||||
]
|
||||
system, result = convert_messages_to_anthropic(messages)
|
||||
_assert_no_blank(system, result)
|
||||
|
||||
|
||||
def test_blank_system_block_is_coerced():
|
||||
messages = [
|
||||
{"role": "system", "content": [
|
||||
{"type": "text", "text": " ", "cache_control": {"type": "ephemeral"}},
|
||||
]},
|
||||
{"role": "user", "content": "hello"},
|
||||
]
|
||||
system, result = convert_messages_to_anthropic(messages)
|
||||
_assert_no_blank(system, result)
|
||||
|
||||
|
||||
def test_prepended_leading_user_turn_is_not_blank():
|
||||
"""The root cause: _ensure_leading_user_turn prepends a placeholder turn when
|
||||
messages[0] is not a user turn (post-compaction histories start with an
|
||||
assistant summary). That placeholder must be NON-whitespace — a bare " "
|
||||
is itself a blank text block and 400s the whole request, wedging every turn.
|
||||
Bedrock's equivalent already uses the shared placeholder.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "assistant", "content": "summary of earlier turns"},
|
||||
{"role": "user", "content": "continue"},
|
||||
]
|
||||
system, result = convert_messages_to_anthropic(messages)
|
||||
assert result[0]["role"] == "user", "a leading user turn must be prepended"
|
||||
_assert_no_blank(system, result)
|
||||
|
||||
|
||||
def test_real_text_is_left_untouched():
|
||||
# A real, non-blank turn keeps its text verbatim and is never replaced by
|
||||
# the placeholder (the guard only touches blank blocks).
|
||||
messages = [
|
||||
{"role": "user", "content": "what is 2+2?"},
|
||||
]
|
||||
system, result = convert_messages_to_anthropic(messages)
|
||||
# Content may be a plain string or a list of blocks; collect text either way.
|
||||
texts = []
|
||||
for m in result:
|
||||
c = m.get("content")
|
||||
if isinstance(c, str):
|
||||
texts.append(c)
|
||||
elif isinstance(c, list):
|
||||
texts.extend(b.get("text", "") for b in c if isinstance(b, dict) and b.get("type") == "text")
|
||||
assert "what is 2+2?" in texts
|
||||
assert _EMPTY_TEXT_PLACEHOLDER not in texts
|
||||
Reference in New Issue
Block a user