mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
fix(agent): drop empty tool_calls arrays in pre-API sanitizer (#58755)
DeepSeek v4 (and other strict OpenAI-compatible providers) reject an
assistant message carrying ``tool_calls: []`` with HTTP 400 "Invalid
'messages[N].tool_calls': empty array. Expected an array with minimum
length 1, but got an empty array instead." Once it hits, every retry on
the session returns the same 400 and the conversation is stuck.
An empty array is semantically identical to "no tool calls", but it
reaches the wire from session resume, host-fed histories, and the
consecutive-assistant merge in repair_message_sequence (which preserves
a pre-existing []). None of the existing sanitizer passes touch it —
they all short-circuit on ``if not tcs`` / ``if msg.get("tool_calls")``.
Fix it at sanitize_api_messages, the final pre-API chokepoint, per the
#56980 review guidance: normalize on the per-call copy (shallow-copy the
message, drop the key) rather than in repair_message_sequence, which
would destructively rewrite the persisted trajectory and prompt cache.
This commit is contained in:
parent
5cc7c9b6a0
commit
a7932d86c5
1 changed files with 35 additions and 0 deletions
|
|
@ -2388,6 +2388,41 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
|
|||
filtered.append(msg)
|
||||
messages = filtered
|
||||
|
||||
# --- Drop empty / malformed tool_calls arrays on assistant messages ---
|
||||
# An assistant message carrying ``tool_calls: []`` (an empty array) — or a
|
||||
# non-list value under the key — is semantically identical to an assistant
|
||||
# message with no tool calls, but strict OpenAI-compatible providers reject
|
||||
# the empty array outright: DeepSeek v4 returns HTTP 400 "Invalid
|
||||
# 'messages[N].tool_calls': empty array. Expected an array with minimum
|
||||
# length 1, but got an empty array instead." (#58755, follow-up to #56980).
|
||||
# Empty arrays reach here from session resume, host-fed histories, or the
|
||||
# consecutive-assistant merge in ``repair_message_sequence`` (which
|
||||
# preserves a pre-existing ``[]`` on the surviving turn). This is the final
|
||||
# pre-API chokepoint, so normalize defensively — and, per the #56980
|
||||
# review, do it HERE on the per-call copy rather than in
|
||||
# ``repair_message_sequence``, which would destructively rewrite the
|
||||
# persisted trajectory. Shallow-copy the message before dropping the key so
|
||||
# stored history (and prompt caching) stays byte-stable.
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
dropped_empty_tool_calls = 0
|
||||
for msg in messages:
|
||||
if (
|
||||
isinstance(msg, dict)
|
||||
and msg.get("role") == "assistant"
|
||||
and "tool_calls" in msg
|
||||
and not (isinstance(msg["tool_calls"], list) and msg["tool_calls"])
|
||||
):
|
||||
msg = {k: v for k, v in msg.items() if k != "tool_calls"}
|
||||
dropped_empty_tool_calls += 1
|
||||
normalized.append(msg)
|
||||
if dropped_empty_tool_calls:
|
||||
messages = normalized
|
||||
_ra().logger.debug(
|
||||
"Pre-call sanitizer: dropped empty/invalid tool_calls on %d "
|
||||
"assistant message(s)",
|
||||
dropped_empty_tool_calls,
|
||||
)
|
||||
|
||||
# --- Repair tool_calls whose function.name is empty/missing ---
|
||||
# Some providers (and partially-streamed responses) emit a tool_call with
|
||||
# id="call_xxx" but function.name="". Downstream Responses-API adapters
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue