fix: never send empty tool_calls arrays to strict providers (#83312) - #83315
bsgdigital wants to merge 1 commit into
Conversation
|
Review from the #6545 analysis side (we maintain the repro session This is the right shape for the agent layer. Our root-cause trace identified the mutation as the dedup pass inside What we particularly like:
One note: the maintainer's invariant also mentions dropping a content-empty assistant message; at the chokepoint removing the key is sufficient (an empty assistant with no calls is harmless to strict providers once the key is gone) — the message-drop belongs in the earlier sanitizer, which already handles it. We could not run the transport tests locally (our installed agent version predates parts of current main; the tests' CI will cover them). Based on the diff, this PR looks mergeable. |
Duplicate of #64345, the earlier open fix for the same empty |
|
The empty- |
Summary
Fixes a session-wedging bug on strict OpenAI-compatible providers (DeepSeek v4): an assistant message carrying
tool_calls: [](an empty array) is rejected with HTTP 400 (Invalid 'messages[N].tool_calls': empty array. Expected an array with minimum length 1), and because the poisoned message stays in replayed history, every subsequent message to that session fails the same way until the agent cache is cleared.Motivation
We observed this across multiple sessions on DeepSeek. The pre-API sanitizer added for #56980/#58755 (
sanitize_api_messages) correctly stripstool_calls: []when it sees it, but the empty array can be reintroduced after that sanitizer — e.g. the consecutive-assistant merge inrepair_message_sequenceexplicitly preserves a pre-existing[]on the surviving turn (documented in the sanitizer's own comment as a known source). Once it reaches the wire, the failure is non-retryable and permanent for the session.Changes
agent/transports/chat_completions.pyconvert_messages()is the final chokepoint before the HTTP body is serialized. Two additions:tool_callslist now marks the history dirty, so the sanitize pass runs.tool_callson an assistant message is dropped (key removed entirely) via the existing copy-on-write path, so the caller's in-memory history is never mutated and the persisted transcript stays byte-stable.This guarantees the wire invariant regardless of which path built the messages (main loop, retries, fallback re-builds, host-fed histories).
agent/agent_runtime_helpers.pyIn
repair_message_sequence, the consecutive-assistant merge unionedtool_callsbut left a pre-existing[]in place when both sides were empty. The merge now pops the key when the union is empty — fixing the documented source of the poison.Tests
tests/agent/transports/test_chat_completions.pytest_convert_messages_drops_empty_tool_calls_array— empty array stripped on the sanitized copy, original untouched.test_convert_messages_keeps_nonempty_tool_calls_identity— healthy non-emptytool_callspasses through without a copy (no regression to the copy-on-write optimization).tests/run_agent/test_message_sequence_repair.pytest_repair_merge_drops_empty_tool_calls_on_surviving_turn— merge pops the key when neither side carries calls.test_repair_merge_keeps_nonempty_tool_calls_union— real tool calls are still unioned onto the surviving turn.Design decisions
convert_messagesruns after every request-builder path, so it is the right place to enforce "no assistant message ever carries an emptytool_callsarray".Backwards compatibility
Zero breaking changes. Messages with non-empty
tool_callsare untouched (identity-preserving). Emptytool_callsis semantically "no tool calls" — omitting the key is the canonical OpenAI shape and is what permissive providers already receive from this codebase's other message builders.Testing
tests/agent/transports/test_chat_completions.py— passtests/run_agent/test_message_sequence_repair.py— passtests/run_agent/test_agent_guardrails.py,tests/providers/test_transport_parity.py, related repair/sanitize subsets — passrequest_dump_1f819f5dbd28_20260808_144115_112720.json— assistant message 161 withtool_calls: []) now passes throughconvert_messages()with zero empty arrays remaining.