mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 14:00:35 +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`.
|
||||
|
||||
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.
|
||||
"""
|
||||
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 <current_uploads> 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)
|
||||
|
||||
@ -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": "<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