fix(agent): repair empty-name tool_calls in sanitizer to prevent Responses 400 (salvage #12807/#52893) (#55922)

* 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 <danielrpike9@gmail.com>
This commit is contained in:
Teknium 2026-06-30 15:57:46 -07:00 • committed by GitHub
parent 638d2e7bfc
commit 0cebf994c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 90 additions and 0 deletions

View file

@ -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":

View file

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