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.