fix(agent): normalize empty assistant content to None when tool_calls present - #31582
fix(agent): normalize empty assistant content to None when tool_calls present#31582kenanjun001 wants to merge 2 commits into
Conversation
…esent
Some OpenAI-compatible upstreams (Anthropic-compatible shims, certain
proxy gateways) hard-reject an assistant message that carries BOTH
tool_calls and content='' with:
400 'messages: text content blocks must be non-empty'
Both OpenAI and Anthropic accept content=None when tool_calls carry the
assistant turn, so sanitize_api_messages() now coerces empty content
(empty string, empty list, list of only-empty text blocks) to None at
the last moment before the request is sent. tool_calls payload is
preserved verbatim.
Placed in sanitize_api_messages() because:
- It is the single chokepoint every outgoing messages list passes
through (called from chat_completion_helpers.py:1001 and
conversation_loop.py:878).
- Touching build_assistant_message() would also affect persistence,
UI rendering, compression, and reasoning_content pairing; only the
outgoing-API path needs None.
Detection rules (only triggers when role=='assistant' AND tool_calls
present):
- content == '' -> None
- content == [] -> None
- list of only empty/missing text blocks -> None
- list with any non-text block (image, etc.) -> kept
- list with any non-empty text block -> kept
- non-empty string -> kept
- None -> kept
Adds TestEmptyContentWithToolCalls covering all branches of the sanitize_api_messages normalization rule: - normalizes content='' / content=[] / list of only empty text blocks - preserves non-empty string / non-empty text block / image block - does not touch assistant without tool_calls - does not touch system/user messages - preserves existing None content 9 new test cases, all pass.
|
Clean fix. The empty-content-with-tool_calls edge case is one of those cross-provider compatibility gaps that's painful to debug -- the 400 from Anthropic-compatible shims doesn't tell you why, and the fix (content=None instead of '') is invisible in the docs. The sanitize function is the right place for this -- last-moment normalization before the request goes out, so downstream consumers don't need individual workarounds. The content-as-list case is well handled too (non-text block types pass through, only truly empty text blocks get coerced). |
|
Thanks for the focused compatibility fix. The premise is still present on current The proposed location is appropriate: the main loop creates per-request shallow copies before calling the sanitizer ( Automated hermes-sweeper review. |
Fixes #31583
100|```
101|
102|Zero new entries in
~/.hermes/logs/errors.logduring the runs.103|
104|### 4. Unit tests added
105|
106|`tests/run_agent/test_agent_guardrails.py::TestEmptyContentWithToolCalls` — 9 cases covering every branch of the detection rule:
107|
108|- `test_normalizes_empty_string_content_when_tool_calls_present`
109|- `test_normalizes_empty_list_content_when_tool_calls_present`
110|- `test_normalizes_list_of_empty_text_blocks`
111|- `test_keeps_assistant_text_when_present`
112|- `test_keeps_non_empty_text_block_list`
113|- `test_keeps_non_text_content_blocks` (image blocks must pass through)
114|- `test_does_not_touch_assistant_without_tool_calls`
115|- `test_does_not_touch_user_or_system_messages`
116|- `test_preserves_existing_none_content`
117|
118|All 44 tests in the file pass (35 existing + 9 new).
119|
120|## Regression Risk
121|
122|Confined to a narrow branch (`role=='assistant' AND tool_calls AND content empty`):
123|
124|- Plain-text turns: untouched
125|- Assistant without tool_calls: untouched
126|- Any non-empty text or non-text content block: untouched
127|- Worst case if some other bug accidentally clears `content`: request succeeds instead of failing — degrades to "model called a tool without chain-of-thought text," which is already a normal mode
128|
129|## Compatibility
130|
131|- OpenAI chat/completions path: this is the path the patch fixes
132|- Anthropic Messages API path: uses `anthropic_adapter.py` which has its own `result_content = "(no output)" if not content else json.dumps(content)` fallback — unaffected
133|- reasoning_content padding (DeepSeek-v4 / Kimi): sits on `reasoning_content`, not `content` — orthogonal, no conflict
134|
135|## Files
136|
137|- `agent/agent_runtime_helpers.py` — +30 / -0 (the patch)
138|- `tests/run_agent/test_agent_guardrails.py` — +123 / -0 (new test class)
139|
140|## Related Issues
141|
142|None — surfaced from private deployment. Similar symptoms have been reported against several OpenAI-compatible proxies (new-api / one-api / openrouter shims) and Anthropic-compatible bridges that enforce strict text-block validation.
143|