Skip to content

fix: strip empty tool_calls before sending to strict APIs - #58953

Closed
webtecnica wants to merge 1 commit into
NousResearch:mainfrom
webtecnica:fix/strip-empty-tool-calls-before-api
Closed

fix: strip empty tool_calls before sending to strict APIs#58953
webtecnica wants to merge 1 commit into
NousResearch:mainfrom
webtecnica:fix/strip-empty-tool-calls-before-api

Conversation

@webtecnica

Copy link
Copy Markdown
Contributor

Problem

Conversations with 100+ messages on strict providers (DeepSeek, Mistral, Fireworks, Moonshot/Kimi) eventually hit:

HTTP 400: Invalid 'messages[409].tool_calls': empty array. Expected an array with minimum length 1, but got an empty array instead.

This happens because a message in the conversation history carries tool_calls: [] instead of having no tool_calls field. The empty array gets replayed verbatim to the provider, which rejects it.

Root cause

Two paths converge:

  1. get_messages_as_conversation (hermes_state.py): when the DB stores "[]" (JSON-encoded empty array), it's parsed and set as msg["tool_calls"] = [] — the key is present with an empty list.

  2. _sanitize_tool_calls_for_strict_api (run_agent.py): checks isinstance(tool_calls, list) but doesn't guard against empty lists, so [] passes through unchanged to the provider.

Fix

Two defence layers:

  • hermes_state.py — only set tool_calls on the hydrated message if the parsed JSON is non-empty.
  • run_agent.py — in _sanitize_tool_calls_for_strict_api, pop the key entirely when tool_calls is an empty list.

The first layer prevents [] from entering the live history; the second catches any remaining case at the API boundary.

Verification

  • Existing strict API validation tests pass (4/4)
  • Tool call incremental persistence tests pass (3/3)
  • Message sequence repair tests pass (33/33)
  • Syntax-checked with ast.parse

Changes

File Δ
run_agent.py +5 lines (guard in _sanitize_tool_calls_for_strict_api)
hermes_state.py +2/−1 (only set tool_calls when parsed result is non-empty)

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/deepseek DeepSeek API provider/kimi Kimi / Moonshot P2 Medium — degraded but workaround exists labels Jul 5, 2026
@webtecnica
webtecnica force-pushed the fix/strip-empty-tool-calls-before-api branch from 60047e0 to 11a1d65 Compare July 5, 2026 23:36
@webtecnica

Copy link
Copy Markdown
Contributor Author

Rebased on latest main (was 57 commits behind). ✅

Relevant tests passing:

  • tests/tools/test_hardline_blocklist.py: 185/185 passed
  • tests/tools/test_cron_approval_mode.py: clean
  • tests/tools/test_execute_code_approval_cluster.py: only pre-existing test failure unrelated to this change

No diff changes — same fix, just refreshed on top of current main. Ready for review.

@webtecnica

Copy link
Copy Markdown
Contributor Author

@teknium1 This small fix (8 lines) resolves HTTP 400 on strict providers when empty tool_calls are replayed from conversation history. Freshly rebased on main, ready for review whenever you have a moment. 🙏

Strict providers (DeepSeek, Mistral, Fireworks, Moonshot/Kimi) reject
a message with ``tool_calls: []`` as HTTP 400 — the field must be
absent or non-empty.

Two defence layers:
1. **run_agent.py – _sanitize_tool_calls_for_strict_api** — if the
   outgoing API copy has `tool_calls=[]`, pop the key entirely.
2. **hermes_state.py – get_messages_as_conversation** — only set
   `tool_calls` on the message when the parsed JSON is non-empty,
   preventing `[]` from entering the live history in the first place.

The empty array can arise from session replay when the DB stores
`"[]"` (JSON-encoded empty array), or from any code path that
defaults a message to `tool_calls=[]` instead of omitting the key.
@webtecnica
webtecnica force-pushed the fix/strip-empty-tool-calls-before-api branch from 11a1d65 to c19b785 Compare July 6, 2026 04:30
@teknium1

teknium1 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

The empty-array root cause you identified was correct — fixed via PR #59110 (salvage of #58768 by @xxxigm, submitted first with tests). Your PR took the same diagnosis but fixed it at two narrower sites (SQLite replay + _sanitize_tool_calls_for_strict_api); #58768's single unconditional final-chokepoint strip in sanitize_api_messages catches every source path (resume, host-fed histories, repair merges) regardless of provider, and does it non-destructively so persisted history and prompt caching stay byte-stable — so that implementation was merged. Both of you correctly identified the bug; closing this one as a duplicate with credit. Thanks!

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 provider/deepseek DeepSeek API provider/kimi Kimi / Moonshot type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants