From 0cebf994c965672090a58a0dec3b78f565fbaac5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:57:46 -0700 Subject: [PATCH] fix(agent): repair empty-name tool_calls in sanitizer to prevent Responses 400 (salvage #12807/#52893) (#55922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent): drop tool_calls with empty function.name to prevent orphan 400 Salvage of #12807 by @melonboy312 — rebased onto current main (sanitizer moved to agent_runtime_helpers), scoped to the sanitizer fix, with a regression test that fails without it. * fix(agent): repair (not drop) empty-name tool_calls to preserve anti-priming + prevent 400 Dropping empty-name tool_calls in the pre-call sanitizer collided with #47967, which intentionally keeps an empty-name call paired with a synthesized 'tool name was empty' anti-priming result so weak models self-correct without a full catalog dump. Dropping the call orphaned that result and stripped the signal (breaking tests/agent/test_empty_tool_name_loop_dampening.py). The actual HTTP 400 cause is an ORPHANED function_call_output (adapter drops the empty-name function_call but keeps its output). Rename the blank name to a non-empty sentinel instead: the call and its result stay paired, the adapter no longer drops the function_call, no orphan, no 400 — and the anti-priming result content the model needs is preserved. --------- Co-authored-by: Bartok9 --- agent/agent_runtime_helpers.py | 48 +++++++++++++++++++++++++++++++ tests/run_agent/test_run_agent.py | 42 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index af64541a828..5560e4cd5c1 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -2257,6 +2257,54 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] filtered.append(msg) messages = filtered + # --- 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 + # silently DROP such function_call items while still emitting the matching + # function_call_output, producing the gateway's HTTP 400 + # "No tool call found for function call output with call_id ...". + # + # We do NOT drop the call: hermes' own dispatch loop intentionally keeps an + # empty-name call paired with a synthesized anti-priming tool result + # ("tool name was empty", see #47967) so weak models self-correct instead of + # being fed the full tool catalog. Dropping the call here would (a) orphan + # that result and strip the anti-priming signal, and (b) still leave any + # provider-side orphan. Instead, rename the blank name to a non-empty + # sentinel so the call and its result stay PAIRED — the adapter no longer + # drops the function_call, so there is no orphaned output and no 400, while + # the result content the model needs is preserved. + _EMPTY_NAME_SENTINEL = "invalid_tool_call" + for msg in messages: + if msg.get("role") != "assistant": + continue + tcs = msg.get("tool_calls") or [] + if not tcs: + continue + for tc in tcs: + if isinstance(tc, dict): + fn = tc.get("function") + name = fn.get("name") if isinstance(fn, dict) else getattr(fn, "name", None) + else: + fn = getattr(tc, "function", None) + name = getattr(fn, "name", None) if fn else None + if isinstance(name, str) and name.strip(): + continue + _ra().logger.warning( + "Pre-call sanitizer: repairing tool_call with empty " + "function.name -> %r (id=%s)", + _EMPTY_NAME_SENTINEL, + _ra().AIAgent._get_tool_call_id_static(tc), + ) + if isinstance(fn, dict): + fn["name"] = _EMPTY_NAME_SENTINEL + elif fn is not None and hasattr(fn, "name"): + try: + fn.name = _EMPTY_NAME_SENTINEL + except Exception: + pass + elif isinstance(tc, dict): + tc["function"] = {"name": _EMPTY_NAME_SENTINEL, "arguments": "{}"} + surviving_call_ids: set = set() for msg in messages: if msg.get("role") == "assistant": diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 7995d8feb04..4d00ada4fd6 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3659,6 +3659,48 @@ class TestHandleMaxIterations: "call_123" ] + def test_api_sanitizer_repairs_tool_call_with_empty_function_name(self, agent): + """A tool_call with id but empty function.name makes the Responses-API + adapter drop the function_call while keeping its function_call_output, + causing the gateway's HTTP 400 'No tool call found for function call + output ...'. The sanitizer renames the blank name to a non-empty + sentinel so the call and its result stay PAIRED (no orphaned output, + no 400) while the result content is preserved — it must NOT drop the + call, because hermes' dispatch loop keeps empty-name calls paired with + an anti-priming result for self-correction (#47967). (#12807)""" + messages = [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_good", + "type": "function", + "function": {"name": "web_search", "arguments": "{}"}, + }, + { + "id": "call_bad", + "type": "function", + "function": {"name": "", "arguments": "{}"}, + }, + ], + }, + {"role": "tool", "tool_call_id": "call_good", "content": "ok"}, + {"role": "tool", "tool_call_id": "call_bad", "content": "orphan"}, + ] + + sanitized = agent._sanitize_api_messages(messages) + + # The good call is untouched; the malformed call is repaired in place + # (renamed to a non-empty sentinel) rather than dropped. + assistant = next(m for m in sanitized if m.get("role") == "assistant") + names = [tc["function"]["name"] for tc in assistant["tool_calls"]] + assert names == ["web_search", "invalid_tool_call"] + # Both calls now have non-empty names, so neither output is orphaned + # and both tool results survive — this is what prevents the 400. + tool_ids = [m.get("tool_call_id") for m in sanitized if m.get("role") == "tool"] + assert tool_ids == ["call_good", "call_bad"] + class TestRunConversation: """Tests for the main run_conversation method.