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.