mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 14:00:35 +00:00
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:
parent
f17e0504d0
commit
c612531213
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user