{"repo":"NousResearch/hermes-agent","public_id":"58755","display_label":"Empty tool_calls arrays in agent message sanitizing","summary":"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.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":59110,"merged_at":"2026-07-06T04:36:38+00:00"},"resolution":{"state":"resolved","by":"pr-59110"},"freshness":{"state":"verified","last_verified_at":"2026-07-06T04:36:38+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":58755,"pr_number":59110,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-06T04:36:38+00:00"}],"members":[{"kind":"issue","number":58755,"title":"[Bug]: repair_message_sequence creates empty tool_calls array, triggering DeepSeek v4 HTTP 400 (follow-up to #56980)","url":null,"state":"closed"},{"kind":"pr","number":58768,"title":"fix(agent): drop empty tool_calls arrays in pre-API sanitizer (#58755)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59110","number":59110}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":59110,"title":"fix(agent): drop empty tool_calls arrays in pre-API sanitizer (#58755)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","tests/run_agent/test_message_sequence_repair.py"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -2388,6 +2388,41 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]\n         filtered.append(msg)\n     messages = filtered\n \n+    # --- Drop empty / malformed tool_calls arrays on assistant messages ---\n+    # An assistant message carrying ``tool_calls: []`` (an empty array) — or a\n+    # non-list value under the key — is semantically identical to an assistant\n+    # message with no tool calls, but strict OpenAI-compatible providers reject\n+    # the empty array outright: DeepSeek v4 returns HTTP 400 \"Invalid\n+    # 'messages[N].tool_calls': empty array. Expected an array with minimum\n+    # length 1, but got an empty array instead.\" (#58755, follow-up to #56980).\n+    # Empty arrays reach here from session resume, host-fed histories, or the\n+    # consecutive-assistant merge in ``repair_message_sequence`` (which\n+    # preserves a pre-existing ``[]`` on the surviving turn). This is the final\n+    # pre-API chokepoint, so normalize defensively — and, per the #56980\n+    # review, do it HERE on the per-call copy rather than in\n+    # ``repair_message_sequence``, which would destructively rewrite the\n+    # persisted trajectory. Shallow-copy the message before dropping the key so\n+    # stored history (and prompt caching) stays byte-stable.\n+    normalized: List[Dict[str, Any]] = []\n+    dropped_empty_tool_calls = 0\n+    for msg in messages:\n+        if (\n+            isinstance(msg, dict)\n+            and msg.get(\"role\") == \"assistant\"\n+            and \"tool_calls\" in msg\n+            and not (isinstance(msg[\"tool_calls\"], list) and msg[\"tool_calls\"])\n+        ):\n+            msg = {k: v for k, v in msg.items() if k != \"tool_calls\"}\n+            dropped_empty_tool_calls += 1\n+        normalized.append(msg)\n+    if dropped_empty_tool_calls:\n+        messages = normalized\n+        _ra().logger.debug(\n+            \"Pre-call sanitizer: dropped empty/invalid tool_calls on %d \"\n+            \"assistant message(s)\",\n+            dropped_empty_tool_calls,\n+        )\n+\n     # --- Repair tool_calls whose function.name is empty/missing ---\n     # Some providers (and partially-streamed responses) emit a tool_call with\n     # id=\"call_xxx\" but function.name=\"\". Downstream Responses-API adapters"]},{"file":"tests/run_agent/test_message_sequence_repair.py","hunks":["@@ -710,3 +710,63 @@ def test_sanitize_preserves_distinct_tool_call_ids():\n     assistant = [m for m in out if m.get(\"role\") == \"assistant\"][0]\n     assert [tc[\"id\"] for tc in assistant[\"tool_calls\"]] == [\"call_A\", \"call_B\"]\n     assert sorted(m[\"tool_call_id\"] for m in out if m.get(\"role\") == \"tool\") == [\"call_A\", \"call_B\"]\n+\n+\n+def test_sanitize_drops_empty_tool_calls_array():\n+    \"\"\"sanitize_api_messages strips ``tool_calls: []`` from assistant messages.\n+\n+    DeepSeek v4 rejects an empty tool_calls array with HTTP 400 \"Invalid\n+    'messages[N].tool_calls': empty array\" (#58755). The empty array is\n+    semantically \"no tool calls\", so the key is dropped while content is\n+    preserved.\n+    \"\"\"\n+    from agent.agent_runtime_helpers import sanitize_api_messages\n+\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"hi\"},\n+        {\"role\": \"assistant\", \"content\": \"answer\", \"tool_calls\": []},\n+    ]\n+    out = sanitize_api_messages(list(messages))\n+    assistant = [m for m in out if m.get(\"role\") == \"assistant\"][0]\n+    assert \"tool_calls\" not in assistant\n+    assert assistant[\"content\"] == \"answer\"\n+\n+\n+def test_sanitize_drops_non_list_tool_calls():\n+    \"\"\"A malformed non-list ``tool_calls`` (e.g. None under the key) is also\n+    dropped so it can't reach a strict provider.\"\"\"\n+    from agent.agent_runtime_helpers import sanitize_api_messages\n+\n+    messages = [\n+        {\"role\": \"assistant\", \"content\": \"text\", \"tool_calls\": None},\n+    ]\n+    out = sanitize_api_messages(list(messages))\n+    assert \"tool_calls\" not in out[0]\n+\n+\n+def test_sanitize_does_not_mutate_original_on_empty_tool_calls():\n+    \"\"\"Stripping must be non-destructive: the caller's message dicts (the\n+    persisted trajectory) keep their original ``tool_calls`` key.\"\"\"\n+    from agent.agent_runtime_helpers import sanitize_api_messages\n+\n+    original_assistant = {\"role\": \"assistant\", \"content\": \"answer\", \"tool_calls\": []}\n+    messages = [{\"role\": \"user\", \"content\": \"hi\"}, original_assistant]\n+    sanitize_api_messages(list(messages))\n+    assert original_assistant[\"tool_calls\"] == []  # untouched in-place\n+\n+\n+def test_sanitize_preserves_populated_tool_calls():\n+    \"\"\"Negative control: a non-empty tool_calls array (with its matching tool\n+    result) must survive untouched.\"\"\"\n+    from agent.agent_runtime_helpers import sanitize_api_messages\n+\n+    messages = [\n+        {\"role\": \"assistant\", \"content\": None, \"tool_calls\": [\n+            {\"id\": \"call_Z\", \"type\": \"function\",\n+             \"function\": {\"name\": \"foo\", \"arguments\": \"{}\"}},\n+        ]},\n+        {\"role\": \"tool\", \"tool_call_id\": \"call_Z\", \"content\": \"r\"},\n+    ]\n+    out = sanitize_api_messages(list(messages))\n+    assistant = [m for m in out if m.get(\"role\") == \"assistant\"][0]\n+    assert [tc[\"id\"] for tc in assistant[\"tool_calls\"]] == [\"call_Z\"]\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}