Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 115 additions & 8 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2311,13 +2311,14 @@ 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}
kept_blocks = _fix_blank_text_blocks_in_list(
converted_blocks,
placeholder_text="(empty message)",
msg_index=-1,
role="user",
location="_convert_user_message",
)
return {"role": "user", "content": kept_blocks}
else:
if not content or (isinstance(content, str) and not content.strip()):
content = "(empty message)"
Expand Down Expand Up @@ -2620,9 +2621,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(
Expand Down Expand Up @@ -2686,6 +2792,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

Expand Down
1 change: 1 addition & 0 deletions contributors/emails/pooyan6@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pooyan6
189 changes: 188 additions & 1 deletion tests/agent/test_anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@ def test_leading_assistant_after_compaction_gets_user_turn_prepended(self):

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"])
Expand Down Expand Up @@ -1670,3 +1670,190 @@ def test_thinking_plus_blank_unmarked_text_gets_schema_valid_placeholder(self):
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