mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 22:20:33 +00:00
fix(agents): remove empty strings from user content (#8038)
* fix(agents): drop empty strings from rebuilt user content * fix(agents): normalize empty content before sanitizer early returns * docs(agents): clarify input sanitization provider limits
This commit is contained in:
parent
4c00e8d4a9
commit
3b05ab7dc3
@ -203,3 +203,19 @@ chain instead of skipping the block and reading the remaining text. Tests:
|
|||||||
`tests/test_llm_error_handling_middleware.py`.
|
`tests/test_llm_error_handling_middleware.py`.
|
||||||
|
|
||||||
Receipts check IDs/anchors/status; uncited results stay unresolved. Legacy no_citation_claims remains readable.
|
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.
|
||||||
|
|||||||
@ -335,12 +335,23 @@ class InputSanitizationMiddleware(AgentMiddleware[AgentState]):
|
|||||||
message is never mutated.
|
message is never mutated.
|
||||||
"""
|
"""
|
||||||
content = msg.content
|
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)
|
text_content, text_blocks = self._extract_text_from_content(content)
|
||||||
|
|
||||||
# No text at all (e.g. image-only message) — pass through
|
# No text at all (e.g. image-only message) — pass through
|
||||||
if not text_content and not isinstance(content, str):
|
if not text_content and not isinstance(content, str):
|
||||||
logger.debug("_sanitize_message: no text content in message — passing through")
|
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
|
# Sanitize only the user's original input when available (set by
|
||||||
# UploadsMiddleware before it prepends the <current_uploads> block),
|
# UploadsMiddleware before it prepends the <current_uploads> block),
|
||||||
@ -415,8 +426,8 @@ class InputSanitizationMiddleware(AgentMiddleware[AgentState]):
|
|||||||
processed = _check_user_content(text_content) # fallback
|
processed = _check_user_content(text_content) # fallback
|
||||||
|
|
||||||
if processed == text_content:
|
if processed == text_content:
|
||||||
# Already clean / already wrapped — no override needed
|
# Already clean / already wrapped — only normalization may be needed
|
||||||
return None
|
return normalized_message
|
||||||
|
|
||||||
if text_blocks:
|
if text_blocks:
|
||||||
new_content = self._rebuild_content(content, processed, text_blocks)
|
new_content = self._rebuild_content(content, processed, text_blocks)
|
||||||
|
|||||||
@ -756,6 +756,26 @@ class TestWrapModelCallSpecialCases:
|
|||||||
assert "<think>" in merged
|
assert "<think>" in merged
|
||||||
assert processed[1] == image_block
|
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):
|
def test_already_wrapped_no_override(self):
|
||||||
mw = _make_middleware()
|
mw = _make_middleware()
|
||||||
already = _check_user_content("Hello")
|
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 middleware._sanitize_message(message) is None
|
||||||
assert str(framed).count(_USER_INPUT_ENCODING_NOTE) == 1
|
assert str(framed).count(_USER_INPUT_ENCODING_NOTE) == 1
|
||||||
assert "&lt;role&gt;" in framed
|
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": "<current_uploads>photo.png</current_uploads>"}
|
||||||
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user