# NousResearch/hermes-agent · Empty tool_calls arrays in agent message sanitizing

## Open now

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

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #59110. Verify it addresses your case.
- **Recommended:** read PR #59110 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-59110`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #58755: \[Bug\]: repair_message_sequence creates empty tool_calls array, triggering DeepSeek v4 HTTP 400 (follow-up to #56980)

## Prior & competing PRs

- **#59110: fix(agent): drop empty tool_calls arrays in pre-API sanitizer (#58755)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/agent_runtime_helpers.py`, `tests/run_agent/test_message_sequence_repair.py`
  - fix hunks:
  - `agent/agent_runtime_helpers.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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"]
    ```
- **#58768: fix(agent): drop empty tool_calls arrays in pre-API sanitizer (#58755)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#59110`

## Fix status

- Merged to main in PR #59110 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-06`.
