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 # ---------------------------------------------------------------------------