NousResearch/hermes-agent

Empty tool_calls arrays in agent message sanitizing

Cluster 58755. Data as of

Nothing open on GitHub. Fix merged in #59110 on 6 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #59110. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #59110. That is separate from the fix event.
Still open
Nothing open on GitHub.

These members concern empty tool_calls arrays produced during message repair or sanitization, which are reported to trigger an HTTP 400 with DeepSeek v4. Two entries share the same title for a pre-API sanitizer change (#58755), and one is a bug report that follows up on #56980; no outcome is asserted.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #59110 fix(agent): drop empty tool_calls arrays in pre-API sanitizer (#58755)

    merged to main

    files: agent/agent_runtime_helpers.py, tests/run_agent/test_message_sequence_repair.py

    Show the diff
    • agent/agent_runtime_helpers.py
      @@ -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
    • tests/run_agent/test_message_sequence_repair.py
      @@ -710,3 +710,63 @@ def test_sanitize_preserves_distinct_tool_call_ids():
           assistant = [m for m in out if m.get("role") == "assistant"][0]
           assert [tc["id"] for tc in assistant["tool_calls"]] == ["call_A", "call_B"]
           assert sorted(m["tool_call_id"] for m in out if m.get("role") == "tool") == ["call_A", "call_B"]
      +
      +
      +def test_sanitize_drops_empty_tool_calls_array():
      +    """sanitize_api_messages strips ``tool_calls: []`` from assistant messages.
      +
      +    DeepSeek v4 rejects an empty tool_calls array with HTTP 400 "Invalid
      +    'messages[N].tool_calls': empty array" (#58755). The empty array is
      +    semantically "no tool calls", so the key is dropped while content is
      +    preserved.
      +    """
      +    from agent.agent_runtime_helpers import sanitize_api_messages
      +
      +    messages = [
      +        {"role": "user", "content": "hi"},
      +        {"role": "assistant", "content": "answer", "tool_calls": []},
      +    ]
      +    out = sanitize_api_messages(list(messages))
      +    assistant = [m for m in out if m.get("role") == "assistant"][0]
      +    assert "tool_calls" not in assistant
      +    assert assistant["content"] == "answer"
      +
      +
      +def test_sanitize_drops_non_list_tool_calls():
      +    """A malformed non-list ``tool_calls`` (e.g. None under the key) is also
      +    dropped so it can't reach a strict provider."""
      +    from agent.agent_runtime_helpers import sanitize_api_messages
      +
      +    messages = [
      +        {"role": "assistant", "content": "text", "tool_calls": None},
      +    ]
      +    out = sanitize_api_messages(list(messages))
      +    assert "tool_calls" not in out[0]
      +
      +
      +def test_sanitize_does_not_mutate_original_on_empty_tool_calls():
      +    """Stripping must be non-destructive: the caller's message dicts (the
      +    persisted trajectory) keep their original ``tool_calls`` key."""
      +    from agent.agent_runtime_helpers import sanitize_api_messages
      +
      +    original_assistant = {"role": "assistant", "content": "answer", "tool_calls": []}
      +    messages = [{"role": "user", "content": "hi"}, original_assistant]
      +    sanitize_api_messages(list(messages))
      +    assert original_assistant["tool_calls"] == []  # untouched in-place
      +
      +
      +def test_sanitize_preserves_populated_tool_calls():
      +    """Negative control: a non-empty tool_calls array (with its matching tool
      +    result) must survive untouched."""
      +    from agent.agent_runtime_helpers import sanitize_api_messages
      +
      +    messages = [
      +        {"role": "assistant", "content": None, "tool_calls": [
      +            {"id": "call_Z", "type": "function",
      +             "function": {"name": "foo", "arguments": "{}"}},
      +        ]},
      +        {"role": "tool", "tool_call_id": "call_Z", "content": "r"},
      +    ]
      +    out = sanitize_api_messages(list(messages))
      +    assistant = [m for m in out if m.get("role") == "assistant"][0]
      +    assert [tc["id"] for tc in assistant["tool_calls"]] == ["call_Z"]

1 PR closed without merging

From the close comments: merged via another change 1

  • #58768 fix(agent): drop empty tool_calls arrays in pre-API sanitizer (#58755) · merged via another change → #59110
Closed issues · 1
  • Closed#58755[Bug]: repair_message_sequence creates empty tool_calls array, triggering DeepSeek v4 HTTP 400 (follow-up to #56980)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#59110
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
6 Jul 2026

For your agent

The same facts, typed.