fix(agents): drop empty typed text blocks from user content (#8066)

* fix(agents): drop empty text items from sanitized list content

InputSanitizationMiddleware merges list-form user content into one typed
{"type": "text"} block, but only replaces the items it collected. Empty
bare strings and empty text blocks are skipped by the extractor (matching
message_content_to_text) and then re-emitted as-is, so a request whose
content was ["", {"type": "text", ...}] still carried a bare "" content
part. The Chat Completions schema only allows typed content-part objects
in a content list, so endpoints that validate against it rejected the
whole turn — and kept rejecting every later call that resent the message.

Empty text items are now recognised during extraction and dropped when the
content list is rebuilt: leading, interleaved and trailing positions. A
list made only of empty items collapses to one empty typed block, and an
image-only message still passes through untouched. Nothing else changes:
non-text blocks keep their positions, and the transform stays
request-scoped.

* fix(agents): drop empty text items when rebuilding sanitized content

InputSanitizationMiddleware merges list-form user content into one typed
{"type": "text"} block, but it only replaced the items it had collected.
Empty bare strings and empty text blocks were skipped during extraction
(matching message_content_to_text) and then re-emitted unchanged, so a
message whose content was ["", {"type": "text", ...}] still carried a bare
"" content part. The Chat Completions schema only allows typed content-part
objects inside a content list, so endpoints that validate against it
rejected the whole turn, and kept rejecting every later model call that
resent the message.

Empty text items are now collected during extraction (they are text-shaped,
so the rebuild is what has to drop them) and therefore disappear in all
positions: leading, interleaved and trailing. A list made only of empty
items collapses to one empty typed block instead of leaking the bare
string, and an image-only message still passes through untouched.

Non-text blocks keep their existing placement rule, and the transform stays
request-scoped.

* docs(middleware): state that empty typed text blocks are dropped

The #8038 scope note in middlewares/AGENTS.md still said empty typed text
blocks remain unchanged and that providers can still reject them. This
change drops those blocks too, so the note described the opposite of what
the code does. Rewrite the summary and that bullet to match, and keep the
remaining limits explicit: user content only, and an empty user turn can
still be rejected.

* test(middleware): keep the legacy cases out of the new class and pin keep-alive

Inserting TestEmptyTextBlockItemsAreDropped before the existing
already-wrapped, graph-bubble-up and fail-open cases silently moved those
three tests into the new class, changing their node IDs and putting them
under a docstring about empty typed text blocks. Place the new class after
test_fail_open_on_processing_error so the legacy tests keep their home.

Also pin the boundary the sweep must not cross: a whitespace-only part,
typed or bare, carries no words but still keeps the turn alive. Assert
both shapes survive next to an image so tightening not block to not
block.strip() fails here rather than turning an empty user turn into a
rejected one.

* docs(middleware): keep the empty-text note inside the guidance size limit

The added paragraph pushed the effective AGENTS.md chain 55 bytes past its hard limit. Condense the same facts instead of adding a new line to the budget.

---------

Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com>
This commit is contained in:
2SinceJuly 2026-11-10 08:04:43 +08:00 committed by GitHub
parent f17e0504d0
commit c612531213
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 119 additions and 12 deletions

View File

@ -204,18 +204,18 @@ chain instead of skipping the block and reading the remaining text. Tests:
Receipts check IDs/anchors/status; uncited results stay unresolved. Legacy no_citation_claims remains readable.
InputSanitization removes empty bare strings from genuine user content lists before
text-related early returns; all-empty string lists become an empty string. Request
copies preserve metadata and leave persisted history untouched.
InputSanitization drops empty bare strings and empty typed text blocks from
genuine user content lists before text-related early returns; all-empty lists
become an empty string. Only truly empty text is swept: whitespace-only parts
keep the turn alive. Request copies preserve metadata; history stays untouched.
Scope / known provider compatibility limits (#8038 review):
Scope / known provider compatibility limits (#8038, #8037 review):
- On the upload extraction-disagreement (`rfind` failure) fallback, non-empty
bare strings are framed individually but remain untyped list parts. Strict
Chat Completions endpoints can still reject them; typing those parts is deferred.
- Empty typed text blocks (`{"type": "text", "text": ""}`) remain unchanged:
this fix targets untyped empty strings, not all provider-specific validation.
Anthropic can still reject those empty typed blocks.
- All-empty bare-string lists become `content=""`; this middleware replaces
messages in place rather than dropping turns. This is not a guarantee of
Anthropic compatibility: an empty user turn can still be rejected. No upstream
Gateway / IM empty-turn handling is assumed by this normalization.
- Empty typed text blocks (`{"type": "text", "text": ""}`) are dropped alongside
empty bare strings, so Anthropic and strict Chat Completions endpoints no longer
see them. User content only; assistant and tool messages stay out of scope.
- All-empty lists become `content=""`; this middleware replaces
messages in place rather than dropping turns, and an empty user turn can still
be rejected. No upstream Gateway / IM empty-turn handling is assumed.

View File

@ -208,6 +208,18 @@ def neutralize_untrusted_tags(text: str) -> str:
return _neutralize_boundary_tokens(text)
def _is_empty_text_item(block) -> bool:
"""True for a content-list item that carries no text at all.
Covers both shapes the sanitizer normalizes away: an empty bare string and
an empty ``{"type": "text", "text": ""}`` block. Neither contributes text,
and both are rejected inside a content list by strict serializers.
"""
if isinstance(block, str):
return not block
return isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str) and not block["text"]
class _FramedUserText(str):
"""Request-local encoding provenance; JSON/plain strings cannot carry it.
@ -337,7 +349,11 @@ class InputSanitizationMiddleware(AgentMiddleware[AgentState]):
content = msg.content
normalized_message = None
if isinstance(content, list):
filtered = [block for block in content if not (isinstance(block, str) and not block)]
# Empty bare strings and empty typed text blocks both carry no text.
# Dropping them here keeps them out of both the extractor and the
# rebuilt list; an empty text part is rejected by serializers that
# validate content-part text, just like an untyped bare string.
filtered = [block for block in content if not _is_empty_text_item(block)]
if len(filtered) != len(content):
# Normalize before early returns, including image-only and
# already-framed messages. Use an empty string rather than

View File

@ -810,6 +810,97 @@ class TestWrapModelCallSpecialCases:
assert result == "ok"
class TestEmptyTextBlockItemsAreDropped:
"""Empty typed text blocks cannot reach the model as content parts (#8037).
Upstream's bare-string normalization leaves ``{"type": "text", "text": ""}``
untouched; strict Chat Completions serializers reject the empty text part.
"""
@staticmethod
def _sent_content(mw, content):
msg = HumanMessage(content=deepcopy(content), id="msg-1")
captured = []
mw.wrap_model_call(_make_request([msg]), lambda req: captured.append(req) or "ok")
return captured[0].messages[0].content
def test_empty_text_block_before_text_block_is_dropped(self):
processed = self._sent_content(
_make_middleware(),
[{"type": "text", "text": ""}, {"type": "text", "text": "Please summarize."}],
)
assert isinstance(processed, list)
assert len(processed) == 1
assert "Please summarize." in processed[0]["text"]
def test_empty_text_block_between_text_blocks_is_dropped(self):
processed = self._sent_content(
_make_middleware(),
[{"type": "text", "text": "first"}, {"type": "text", "text": ""}, {"type": "text", "text": "second"}],
)
assert isinstance(processed, list)
assert len(processed) == 1
assert "first" in processed[0]["text"] and "second" in processed[0]["text"]
def test_empty_text_block_after_text_block_is_dropped(self):
processed = self._sent_content(
_make_middleware(),
[{"type": "text", "text": "hello"}, {"type": "text", "text": ""}],
)
assert isinstance(processed, list)
assert len(processed) == 1
assert "hello" in processed[0]["text"]
def test_empty_text_blocks_only_become_empty_string(self):
processed = self._sent_content(_make_middleware(), [{"type": "text", "text": ""}])
assert processed == ""
def test_empty_text_block_next_to_image_drops_only_the_block(self):
image_block = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
msg = HumanMessage(content=[image_block, {"type": "text", "text": ""}], id="msg-1")
captured = []
_make_middleware().wrap_model_call(_make_request([msg]), lambda req: captured.append(req) or "ok")
assert captured[0].messages[0].content == [image_block]
def test_whitespace_only_text_is_kept_as_a_typed_block(self):
"""Only empty text is swept: a whitespace part still keeps the turn alive."""
image_block = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
processed = self._sent_content(
_make_middleware(),
[image_block, {"type": "text", "text": " "}],
)
assert isinstance(processed, list)
assert len(processed) == 2
assert processed[0] == image_block
assert processed[1]["type"] == "text"
assert processed[1]["text"].strip() == ""
def test_whitespace_only_bare_string_is_kept(self):
image_block = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
processed = self._sent_content(_make_middleware(), [image_block, " "])
assert isinstance(processed, list)
assert len(processed) == 2
assert processed[0] == image_block
assert processed[1].strip() == ""
def test_image_only_message_still_passes_through_untouched(self):
image_block = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
msg = HumanMessage(content=[image_block], id="msg-1")
request = _make_request([msg])
captured = []
_make_middleware().wrap_model_call(request, lambda req: captured.append(req) or "ok")
assert captured[0] is request
# ---------------------------------------------------------------------------
# _rebuild_content — preserves interleaved non-text blocks
# ---------------------------------------------------------------------------