hermes - 💡(How to fix) Fix Empty assistant content with tool_calls causes HTTP 400 from strict OpenAI-compat upstreams [3 pull requests]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Conversations that include any pure-tool-call assistant turn (one with tool_calls but no chain-of-thought text) become permanently un-replayable against strict OpenAI-compatible upstreams. Every subsequent API call hits:

HTTP 400: messages: text content blocks must be non-empty

Once the offending message lands in history, retries send the same payload, so the failure is deterministic, not flaky — the conversation stays broken until cleared.

Error Message

ERROR agent.conversation_loop: Non-retryable client error: Error code: 400 - {'error': {'message': 'messages: text content blocks must be non-empty', 'type': 'invalid_request_error', 'param': '', 'code': None}}

Root Cause

agent/chat_completion_helpers.py::build_assistant_message() always writes a string into content (_raw_content = assistant_message.content or ""), never None. The dict is then persisted to history and replayed verbatim on the next turn.

Sample messages list extracted from a real request dump (~/.hermes/sessions/request_dump_<sid>_<ts>.json, 14 messages):

#rolecontenttool_calls
0systemstr(len=16178)
1–9user/assistant/tool… various …
10assistantstr(len=158)terminal
11toolresult
12assistant"" ⚠️write_file
13toolresult

Message 12 is the offender — content: "" together with tool_calls. The OpenAI and Anthropic specs both accept content: null here, but strict shims read "" as a zero-length text content block and reject the entire request.

Fix Action

Fixed

Code Example

HTTP 400: messages: text content blocks must be non-empty

---

ERROR agent.conversation_loop:
  Non-retryable client error: Error code: 400 -
  {'error': {'message': 'messages: text content blocks must be non-empty',
             'type': 'invalid_request_error', 'param': '', 'code': None}}

---

{"role": "assistant", "content": "", "tool_calls": []}
RAW_BUFFERClick to expand / collapse

Summary

Conversations that include any pure-tool-call assistant turn (one with tool_calls but no chain-of-thought text) become permanently un-replayable against strict OpenAI-compatible upstreams. Every subsequent API call hits:

HTTP 400: messages: text content blocks must be non-empty

Once the offending message lands in history, retries send the same payload, so the failure is deterministic, not flaky — the conversation stays broken until cleared.

Environment

  • Hermes Agent: main (verified as of commit 3bace071b)
  • OS: Ubuntu 24.04.4 LTS, Python 3.11
  • Triggering upstreams: OpenAI-compatible proxies / Anthropic-compatible bridges that enforce strict text-content-block validation
    • Examples observed: new-api / one-api strict mode, some Anthropic-compatible shims, certain proxy gateways
    • Official OpenAI and official Anthropic both accept the payload — only strict-validating shims reject it

User-Facing Symptom

On any gateway platform (Telegram / WeChat / Discord / etc.) the chat suddenly starts emitting:

⚠️ The model provider failed after retries. I kept raw provider details out of chat; check gateway logs for diagnostics.

…repeating on every subsequent turn until the session is reset.

~/.hermes/logs/errors.log:

ERROR agent.conversation_loop:
  Non-retryable client error: Error code: 400 -
  {'error': {'message': 'messages: text content blocks must be non-empty',
             'type': 'invalid_request_error', 'param': '', 'code': None}}

Steps to Reproduce

  1. Configure a model whose upstream is a strict OpenAI-compat shim or Anthropic-compat bridge (e.g. new-api / one-api in strict mode).
  2. Start a normal conversation that leads the model to call a tool without first producing chain-of-thought text. This is common with concise models — DeepSeek-V3, Qwen, some Anthropic shims — and naturally happens after a short user message like "再看一下" / "继续" / "now check X".
  3. The model issues a tool call. Hermes records:
    {"role": "assistant", "content": "", "tool_calls": []}
  4. Conversation continues. On the next API call, the upstream rejects the replayed history with 400 messages: text content blocks must be non-empty.
  5. All further turns fail the same way until /reset.

Root Cause

agent/chat_completion_helpers.py::build_assistant_message() always writes a string into content (_raw_content = assistant_message.content or ""), never None. The dict is then persisted to history and replayed verbatim on the next turn.

Sample messages list extracted from a real request dump (~/.hermes/sessions/request_dump_<sid>_<ts>.json, 14 messages):

#rolecontenttool_calls
0systemstr(len=16178)
1–9user/assistant/tool… various …
10assistantstr(len=158)terminal
11toolresult
12assistant"" ⚠️write_file
13toolresult

Message 12 is the offender — content: "" together with tool_calls. The OpenAI and Anthropic specs both accept content: null here, but strict shims read "" as a zero-length text content block and reject the entire request.

Why This Is Hard to Notice

  • Reproducible only on strict OpenAI-compat upstreams. Official OpenAI / official Anthropic accept the payload, so the issue is invisible during in-house testing against major vendors.
  • Only fires on a specific shape of turn (pure tool call with no leading text). Many models naturally emit some "thinking" text before a tool call, masking the bug.
  • The 400 is non-retryable, so the conversation gets stuck rather than degrading gracefully.

Expected Behavior

Outgoing assistant messages that carry tool_calls but no real text content should have content: null (which is valid everywhere) rather than content: "" (which is rejected by strict shims).

Proposed Fix

Normalize at the single chokepoint — sanitize_api_messages() in agent/agent_runtime_helpers.py — right before every outgoing API call. Coerce content to None only when:

  • role == "assistant" AND
  • tool_calls is non-empty AND
  • content is one of: "", [], or a list containing only empty/missing text blocks

Detection table:

contenthits?action
""None
[]None
[{"type":"text","text":""}]None
[{"type":"text","text":"hi"}]kept
[{"type":"image_url","image_url":{...}}]kept
"hi"kept
Nonekept (already valid)

Why sanitize_api_messages and not build_assistant_message?

sanitize_api_messages is called from exactly two places, both right before sending to the LLM:

  • agent/chat_completion_helpers.py:1001 (fallback summary call)
  • agent/conversation_loop.py:878 (main loop, every API call)

By contrast, build_assistant_message's output feeds five consumers — persistent history (state.db), UI rendering, compression, reasoning_content trailing-merge logic, and the API replay path. Only the replay path needs None. Fixing at the outgoing-API boundary is the minimum-risk surface.

PR

A PR with the fix + tests is up at #31582 (Draft, CI pending).

Related

Similar symptoms reported against several OpenAI-compatible proxies (new-api, one-api, OpenRouter strict shims) and Anthropic-compatible bridges that enforce strict text-block validation.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING