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:
alexcastgonz 2026-11-08 21:27:35 -04:00 committed by GitHub
parent 4c00e8d4a9
commit 3b05ab7dc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 89 additions and 3 deletions

View File

@ -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.

View File

@ -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)

View File

@ -756,6 +756,26 @@ class TestWrapModelCallSpecialCases:
assert "&lt;think&gt;" 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 "&amp;lt;role&amp;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