{"repo":"NousResearch/hermes-agent","public_id":"49968","display_label":"Fix missing function names in tool_calls","summary":"A bug where tool_call objects are missing the function.name field causes errors with certain providers. Multiple fix attempts address sanitizing or repairing these empty names before requests are sent.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":55922,"merged_at":"2026-06-30T22:57:46+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-30T22:57:46+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":4662,"pr_number":55922,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-30T22:57:46+00:00"},{"issue_number":11411,"pr_number":55922,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-30T22:57:46+00:00"},{"issue_number":49968,"pr_number":55922,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-30T22:57:46+00:00"}],"members":[{"kind":"issue","number":49968,"title":"tool_calls missing function.name when using Xiaomi MiMo provider","url":null,"state":"closed"},{"kind":"pr","number":49993,"title":"fix(agent): repair missing tool call function names (#49968)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0cebf994c965672090a58a0dec3b78f565fbaac5","number":0},{"kind":"pr","value":"#49968","number":49968}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":49999,"title":"fix: repair empty function.name in tool_calls for strict providers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0cebf994c965672090a58a0dec3b78f565fbaac5","number":0},{"kind":"pr","value":"#55922","number":55922},{"kind":"pr","value":"#47967","number":47967},{"kind":"pr","value":"#12807","number":12807}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":55922,"title":"fix(agent): repair empty-name tool_calls in sanitizer to prevent Responses 400 (salvage #12807/#52893)","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_run_agent.py"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -2257,6 +2257,54 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]\n         filtered.append(msg)\n     messages = filtered\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\n+    # silently DROP such function_call items while still emitting the matching\n+    # function_call_output, producing the gateway's HTTP 400\n+    # \"No tool call found for function call output with call_id ...\".\n+    #\n+    # We do NOT drop the call: hermes' own dispatch loop intentionally keeps an\n+    # empty-name call paired with a synthesized anti-priming tool result\n+    # (\"tool name was empty\", see #47967) so weak models self-correct instead of\n+    # being fed the full tool catalog. Dropping the call here would (a) orphan\n+    # that result and strip the anti-priming signal, and (b) still leave any\n+    # provider-side orphan. Instead, rename the blank name to a non-empty\n+    # sentinel so the call and its result stay PAIRED — the adapter no longer\n+    # drops the function_call, so there is no orphaned output and no 400, while\n+    # the result content the model needs is preserved.\n+    _EMPTY_NAME_SENTINEL = \"invalid_tool_call\"\n+    for msg in messages:\n+        if msg.get(\"role\") != \"assistant\":\n+            continue\n+        tcs = msg.get(\"tool_calls\") or []\n+        if not tcs:\n+            continue\n+        for tc in tcs:\n+            if isinstance(tc, dict):\n+                fn = tc.get(\"function\")\n+                name = fn.get(\"name\") if isinstance(fn, dict) else getattr(fn, \"name\", None)\n+            else:\n+                fn = getattr(tc, \"function\", None)\n+                name = getattr(fn, \"name\", None) if fn else None\n+            if isinstance(name, str) and name.strip():\n+                continue\n+            _ra().logger.warning(\n+                \"Pre-call sanitizer: repairing tool_call with empty \"\n+                \"function.name -> %r (id=%s)\",\n+                _EMPTY_NAME_SENTINEL,\n+                _ra().AIAgent._get_tool_call_id_static(tc),\n+            )\n+            if isinstance(fn, dict):\n+                fn[\"name\"] = _EMPTY_NAME_SENTINEL\n+            elif fn is not None and hasattr(fn, \"name\"):\n+                try:\n+                    fn.name = _EMPTY_NAME_SENTINEL\n+                except Exception:\n+                    pass\n+            elif isinstance(tc, dict):\n+                tc[\"function\"] = {\"name\": _EMPTY_NAME_SENTINEL, \"arguments\": \"{}\"}\n+\n     surviving_call_ids: set = set()\n     for msg in messages:\n         if msg.get(\"role\") == \"assistant\":"]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -3659,6 +3659,48 @@ def test_api_sanitizer_matches_responses_call_id_when_id_differs(self, agent):\n             \"call_123\"\n         ]\n \n+    def test_api_sanitizer_repairs_tool_call_with_empty_function_name(self, agent):\n+        \"\"\"A tool_call with id but empty function.name makes the Responses-API\n+        adapter drop the function_call while keeping its function_call_output,\n+        causing the gateway's HTTP 400 'No tool call found for function call\n+        output ...'. The sanitizer renames the blank name to a non-empty\n+        sentinel so the call and its result stay PAIRED (no orphaned output,\n+        no 400) while the result content is preserved — it must NOT drop the\n+        call, because hermes' dispatch loop keeps empty-name calls paired with\n+        an anti-priming result for self-correction (#47967). (#12807)\"\"\"\n+        messages = [\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"\",\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": \"call_good\",\n+                        \"type\": \"function\",\n+                        \"function\": {\"name\": \"web_search\", \"arguments\": \"{}\"},\n+                    },\n+                    {\n+                        \"id\": \"call_bad\",\n+                        \"type\": \"function\",\n+                        \"function\": {\"name\": \"\", \"arguments\": \"{}\"},\n+                    },\n+                ],\n+            },\n+            {\"role\": \"tool\", \"tool_call_id\": \"call_good\", \"content\": \"ok\"},\n+            {\"role\": \"tool\", \"tool_call_id\": \"call_bad\", \"content\": \"orphan\"},\n+        ]\n+\n+        sanitized = agent._sanitize_api_messages(messages)\n+\n+        # The good call is untouched; the malformed call is repaired in place\n+        # (renamed to a non-empty sentinel) rather than dropped.\n+        assistant = next(m for m in sanitized if m.get(\"role\") == \"assistant\")\n+        names = [tc[\"function\"][\"name\"] for tc in assistant[\"tool_calls\"]]\n+        assert names == [\"web_search\", \"invalid_tool_call\"]\n+        # Both calls now have non-empty names, so neither output is orphaned\n+        # and both tool results survive — this is what prevents the 400.\n+        tool_ids = [m.get(\"tool_call_id\") for m in sanitized if m.get(\"role\") == \"tool\"]\n+        assert tool_ids == [\"call_good\", \"call_bad\"]\n+\n \n class TestRunConversation:\n     \"\"\"Tests for the main run_conversation method.\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}