From c612531213b2505857bf733a6a332fd7653f84b4 Mon Sep 17 00:00:00 2001 From: 2SinceJuly <2929362966@qq.com> Date: Tue, 10 Nov 2026 08:04:43 +0800 Subject: [PATCH] fix(agents): drop empty typed text blocks from user content (#8066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .../deerflow/agents/middlewares/AGENTS.md | 22 ++--- .../input_sanitization_middleware.py | 18 +++- .../test_input_sanitization_middleware.py | 91 +++++++++++++++++++ 3 files changed, 119 insertions(+), 12 deletions(-) diff --git a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md index 9e0e22404e..c0605afa37 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md @@ -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. diff --git a/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py index c9480ee8f2..fa3b340061 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py @@ -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 diff --git a/backend/tests/test_input_sanitization_middleware.py b/backend/tests/test_input_sanitization_middleware.py index 3eeca69659..cd7d5b8fbf 100644 --- a/backend/tests/test_input_sanitization_middleware.py +++ b/backend/tests/test_input_sanitization_middleware.py @@ -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 # ---------------------------------------------------------------------------