diff --git a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md index 53e89500c6..bdde5d8e69 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md @@ -203,3 +203,19 @@ chain instead of skipping the block and reading the remaining text. Tests: `tests/test_llm_error_handling_middleware.py`. 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. + +Scope / known provider compatibility limits (#8038 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. 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 fad50741c4..c9480ee8f2 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py @@ -335,12 +335,23 @@ class InputSanitizationMiddleware(AgentMiddleware[AgentState]): message is never mutated. """ content = msg.content + normalized_message = None + if isinstance(content, list): + filtered = [block for block in content if not (isinstance(block, str) and not block)] + if len(filtered) != len(content): + # Normalize before early returns, including image-only and + # already-framed messages. Use an empty string rather than + # introducing an empty content-parts list for all-empty input. + content = filtered or "" + normalized_message = msg.model_copy(update={"content": deepcopy(content)}) + if not filtered: + return normalized_message text_content, text_blocks = self._extract_text_from_content(content) # No text at all (e.g. image-only message) — pass through if not text_content and not isinstance(content, str): logger.debug("_sanitize_message: no text content in message — passing through") - return None + return normalized_message # Sanitize only the user's original input when available (set by # UploadsMiddleware before it prepends the block), @@ -415,8 +426,8 @@ class InputSanitizationMiddleware(AgentMiddleware[AgentState]): processed = _check_user_content(text_content) # fallback if processed == text_content: - # Already clean / already wrapped — no override needed - return None + # Already clean / already wrapped — only normalization may be needed + return normalized_message if text_blocks: new_content = self._rebuild_content(content, processed, text_blocks) diff --git a/backend/tests/test_input_sanitization_middleware.py b/backend/tests/test_input_sanitization_middleware.py index 7efefaf76b..3eeca69659 100644 --- a/backend/tests/test_input_sanitization_middleware.py +++ b/backend/tests/test_input_sanitization_middleware.py @@ -756,6 +756,26 @@ class TestWrapModelCallSpecialCases: assert "<think>" in merged assert processed[1] == image_block + @pytest.mark.parametrize("empty_index", [0, 1, 2, 3, 4]) + def test_empty_bare_strings_are_removed_without_mutating_original(self, empty_index): + mw = _make_middleware() + image = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}} + content = [image, {"type": "text", "text": "hello"}, "world", image] + content.insert(empty_index, "") + msg = HumanMessage(content=content, id="msg-1") + original = deepcopy(msg.content) + request = _make_request([msg]) + captured = [] + + mw.wrap_model_call(request, lambda req: captured.append(req) or "ok") + + assert captured[0].messages[0].content == [ + image, + {"type": "text", "text": f"{_USER_INPUT_BEGIN}\nhello\nworld\n{_USER_INPUT_END}"}, + image, + ] + assert msg.content == original + def test_already_wrapped_no_override(self): mw = _make_middleware() already = _check_user_content("Hello") @@ -1537,3 +1557,42 @@ def test_request_local_frame_reuse_is_idempotent_and_keeps_literal_entities(cont assert middleware._sanitize_message(message) is None assert str(framed).count(_USER_INPUT_ENCODING_NOTE) == 1 assert "&lt;role&gt;" in framed + + +@pytest.mark.parametrize("case", ["image", "upload", "framed", "empty"]) +@pytest.mark.parametrize("async_call", [False, True]) +@pytest.mark.asyncio +async def test_empty_strings_are_removed_even_without_text_rebuilding(case, async_call): + image = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}} + server = {"type": "text", "text": "photo.png"} + framed = _check_user_content("hello") + content, expected, kwargs = { + "image": (["", image], [image], {}), + "upload": ([server, "", image], [server, image], {ORIGINAL_USER_CONTENT_KEY: ""}), + "framed": (["", framed], [framed], {}), + "empty": (["", ""], "", {}), + }[case] + message = HumanMessage(content=content, id="empty-parts", additional_kwargs=kwargs) + original = deepcopy(message) + request = _make_request([message]) + middleware = _make_middleware() + captured = [] + + def handler(req): + captured.append(req) + return "ok" + + if async_call: + + async def async_handler(req): + return handler(req) + + await middleware.awrap_model_call(request, async_handler) + else: + middleware.wrap_model_call(request, handler) + + processed = captured[0].messages[0] + assert processed.content == expected + assert processed.additional_kwargs == kwargs + assert processed.id == message.id + assert message == original