fix(agent): drop unpairable tool calls before API send - #93875
JoaoMarcos44 wants to merge 1 commit into
Conversation
|
Nice work — I'd independently built the same fix this morning (same insertion point, same
Full write-up with repro code and four liftable regression tests (all-dropped healing, blank-name immutability, whitespace-only id, alias-only |
|
Reviewed this against current Dropping all tool_calls from a message can leave an empty non-final assistant turn.
{"role": "assistant", "content": None} # ← survives to the wireRepro (against this branch, [
{"role": "user", "content": "run tools"},
{"role": "assistant", "content": None, "tool_calls": [
{"type": "function", "function": {"name": "terminal", "arguments": "{}"}}, # no id
]},
{"role": "user", "content": "next turn"},
]Output: Anthropic (and the litellm/Bedrock proxies in front of it) 400 on exactly this — "all messages must have non-empty content except for the optional final assistant message" — so the session stays poisoned, just with a different shape of poison. Suggested fix — re-run the empty-message healing after the drop pass (3 lines): if unpairable_tool_calls:
messages = normalized_unpairable
# The drop can strip the only payload from a non-final assistant turn;
# heal the empties it may have created before anything downstream runs.
messages = repair_empty_non_final_messages(messages)
_ra().logger.warning(...)And a regression test for the all-calls-dropped case: def test_sanitize_drops_all_unpairable_pads_empty():
messages = [
{"role": "user", "content": "run tools"},
{"role": "assistant", "content": None, "tool_calls": [
{"type": "function", "function": {"name": "terminal", "arguments": "{}"}},
]},
{"role": "user", "content": "next turn"},
]
out = sanitize_api_messages(list(messages))
assistant = next(m for m in out if m.get("role") == "assistant")
assert "tool_calls" not in assistant
assert assistant.get("content") # placeholder from the healing pass, not NoneEverything else checks out from my run: your existing test passes, the orphaned tool-result direction is already handled by the later pairing pass, and the stored trajectory is untouched (shallow copies only). |
Summary
Problem and impact
An assistant message could contain two tool calls while only one had a correlatable
role: toolresult. The malformed payload reached the final API boundary as N calls and N-1 results, allowing strict provider validation to reject the request and leaving the model with an unpairable call.Root cause
sanitize_api_messages()indexed only calls returned by_tool_call_id_variants(). A call with no usable ID was excluded from the missing-result stub pass, but the later reconstruction loop copied the original call into the assistant message anyway.Implementation decision
The fix drops only unpairable assistant calls from the per-call copy. It deliberately does not invent an ID because there is no safe way to correlate a future result with a fabricated identifier. The stored transcript remains unchanged for prompt-cache and history stability.
Scope and compatibility
id,call_id, and alias variants remain unchanged.tool_callsarrays remain covered by the existing empty-array fixes, including PR fix(agent): close the empty tool_calls 400 class at all three chokepoints (#83312, #77921) #86654.Files
agent/agent_runtime_helpers.pytests/run_agent/test_message_sequence_repair.pyVerification
scripts/run_tests.sh tests/run_agent/test_message_sequence_repair.py -q→36 passed.ruff check: passed.git diff --check: passed.Fixes #93769