Skip to content

fix(agent): normalize empty assistant content to None when tool_calls present - #31582

Draft
kenanjun001 wants to merge 2 commits into
NousResearch:mainfrom
kenanjun001:fix/empty-content-with-tool-calls
Draft

fix(agent): normalize empty assistant content to None when tool_calls present#31582
kenanjun001 wants to merge 2 commits into
NousResearch:mainfrom
kenanjun001:fix/empty-content-with-tool-calls

Conversation

@kenanjun001

@kenanjun001 kenanjun001 commented May 24, 2026

Copy link
Copy Markdown

Fixes #31583

 1|## TL;DR
 2|
 3|Some OpenAI-compatible upstreams (Anthropic-compatible shims, certain proxy gateways) reject `assistant` messages that carry both `tool_calls` **and** `content=""` with:
 4|
 5|```
 6|HTTP 400: messages: text content blocks must be non-empty
 7|```
 8|
 9|Both OpenAI and Anthropic accept `content=null` in this case. This PR normalizes empty content to `None` inside `sanitize_api_messages()` — the single chokepoint every outgoing messages list passes through.
10|
11|## Symptom
12|
13|On gateway platforms (Telegram / WeChat / Discord), users see a chain of:
14|
15|> ⚠️ The model provider failed after retries.
16|
17|with `errors.log` showing:
18|
19|```
20|Non-retryable client error: Error code: 400 -
21|{'error': {'message': 'messages: text content blocks must be non-empty',
22|           'type': 'invalid_request_error', ...}}
23|```
24|
25|Once the bad message lands in history, every retry sends the same payload, so failure is **deterministic, not flaky**.
26|
27|## Root Cause
28|
29|`build_assistant_message()` always writes a string into `content` (`content or ""`, never `None`). When the model decides on a pure tool-call turn with no chain-of-thought text, the resulting message looks like:
30|
31|```json
32|{
33|  "role": "assistant",
34|  "content": "",
35|  "tool_calls": [ { ... "function": { "name": "write_file", ... } } ]
36|}
37|```
38|
39|The empty string is then replayed to the upstream API on subsequent turns. Strict OpenAI-compat shims read this as a zero-length text content block and reject it.
40|
41|Reproduced from a real request dump (`~/.hermes/sessions/request_dump_<sid>_<ts>.json`) — 14 messages, the offending one was index 12:
42|
43|| # | role | content | tool_calls |
44||---|------|---------|------------|
45|| 10 | assistant | str(len=158) | terminal |
46|| 11 | tool | result | |
47|| **12** | **assistant** | **`""` ⚠️** | **write_file** |
48|| 13 | tool | result | |
49|
50|## Fix
51|
52|Add a third pass inside `sanitize_api_messages()` (after the existing orphan tool-pair repair). Coerce `content` to `None` **only when**:
53|
54|- `role == "assistant"` AND
55|- `tool_calls` is present AND
56|- `content` is one of: `""`, `[]`, or a list containing only empty/missing text blocks
57|
58|| content                                              | hits? | action  |
59||------------------------------------------------------|-------|---------|
60|| `""`                                                 | ✅    | → `None` |
61|| `[]`                                                 | ✅    | → `None` |
62|| `[{"type":"text","text":""}]`                        | ✅    | → `None` |
63|| `[{"type":"text","text":"hi"}]`                      | ❌    | kept     |
64|| `[{"type":"image_url","image_url":{...}}]`           | ❌    | kept     |
65|| `"hi"`                                               | ❌    | kept     |
66|| `None`                                               | ❌    | kept (already valid) |
67|
68|### Why `sanitize_api_messages` and not `build_assistant_message`?
69|
70|`sanitize_api_messages` is called from exactly the two places that send messages to the LLM:
71|
72|- `agent/chat_completion_helpers.py:1001` (fallback summary call)
73|- `agent/conversation_loop.py:878` (main loop, every API call)
74|
75|`build_assistant_message`, by contrast, writes the same dict into 5 different consumers — persistent history (`state.db`), UI rendering, compression, the `reasoning_content` trailing-merge logic, and the API replay path. Only the API replay path needs `None`. Putting the normalization at the outgoing-API boundary is the minimum-risk surface.
76|
77|## Verification
78|
79|### 1. Unit-level — feed the actual failing dump
80|
81|```python
82|from agent.agent_runtime_helpers import sanitize_api_messages
83|msgs = json.load(open("request_dump_..._b49e98ee_....json"))["request"]["body"]["messages"]
84|cleaned = sanitize_api_messages(copy.deepcopy(msgs))
85|# Before: msgs[12]["content"] == ""
86|# After:  cleaned[12]["content"] is None,  tool_calls preserved
87|```
88|
89|### 2. Integration-level — replay against upstream
90|
91|Same dump body, POSTed via the patched sanitizer:
92|- **Before:** `HTTP 400 messages: text content blocks must be non-empty`
93|- **After:** `HTTP 401 invalid token` (the token in the dump expired) — i.e. structure now accepted
94|
95|### 3. End-to-end — real tool-calling conversations
96|
97|```
98|hermes -z "Use terminal: date, hostname, uptime — summarize." --yolo  # OK
99|hermes -z "Create /tmp/hermes_patch_test.txt with timestamp, cat to verify." --yolo  # OK

100|```
101|
102|Zero new entries in ~/.hermes/logs/errors.log during 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|

…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.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint labels May 24, 2026
@Morad37

Morad37 commented May 24, 2026

Copy link
Copy Markdown
Contributor

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

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused compatibility fix. The premise is still present on current main: agent/chat_completion_helpers.py:1077 coerces absent tool-call response content to "", and the tool-call branch adds tool_calls to that stored message at agent/chat_completion_helpers.py:1202-1266. Current sanitize_api_messages() only drops empty or malformed tool_calls arrays (agent/agent_runtime_helpers.py:2482-2515), so populated tool-call messages with empty content continue unchanged to its return at agent/agent_runtime_helpers.py:2657.

The proposed location is appropriate: the main loop creates per-request shallow copies before calling the sanitizer (agent/conversation_loop.py:792-835, :901-905), and the fallback-summary path uses that same sanitizer (agent/chat_completion_helpers.py:1689-1728). This preserves persisted history while covering both outgoing paths.

Automated hermes-sweeper review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Empty assistant content with tool_calls causes HTTP 400 from strict OpenAI-compat upstreams

4 participants