From a7932d86c5cb835309eca289006e95eafeaedfb0 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 5 Jul 2026 16:42:24 +0700 Subject: [PATCH] fix(agent): drop empty tool_calls arrays in pre-API sanitizer (#58755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek v4 (and other strict OpenAI-compatible providers) reject an assistant message carrying ``tool_calls: []`` with HTTP 400 "Invalid 'messages[N].tool_calls': empty array. Expected an array with minimum length 1, but got an empty array instead." Once it hits, every retry on the session returns the same 400 and the conversation is stuck. An empty array is semantically identical to "no tool calls", but it reaches the wire from session resume, host-fed histories, and the consecutive-assistant merge in repair_message_sequence (which preserves a pre-existing []). None of the existing sanitizer passes touch it — they all short-circuit on ``if not tcs`` / ``if msg.get("tool_calls")``. Fix it at sanitize_api_messages, the final pre-API chokepoint, per the #56980 review guidance: normalize on the per-call copy (shallow-copy the message, drop the key) rather than in repair_message_sequence, which would destructively rewrite the persisted trajectory and prompt cache. --- agent/agent_runtime_helpers.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index ade4831d1b8..691d796273f 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -2388,6 +2388,41 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] filtered.append(msg) messages = filtered + # --- Drop empty / malformed tool_calls arrays on assistant messages --- + # An assistant message carrying ``tool_calls: []`` (an empty array) — or a + # non-list value under the key — is semantically identical to an assistant + # message with no tool calls, but strict OpenAI-compatible providers reject + # the empty array outright: DeepSeek v4 returns HTTP 400 "Invalid + # 'messages[N].tool_calls': empty array. Expected an array with minimum + # length 1, but got an empty array instead." (#58755, follow-up to #56980). + # Empty arrays reach here from session resume, host-fed histories, or the + # consecutive-assistant merge in ``repair_message_sequence`` (which + # preserves a pre-existing ``[]`` on the surviving turn). This is the final + # pre-API chokepoint, so normalize defensively — and, per the #56980 + # review, do it HERE on the per-call copy rather than in + # ``repair_message_sequence``, which would destructively rewrite the + # persisted trajectory. Shallow-copy the message before dropping the key so + # stored history (and prompt caching) stays byte-stable. + normalized: List[Dict[str, Any]] = [] + dropped_empty_tool_calls = 0 + for msg in messages: + if ( + isinstance(msg, dict) + and msg.get("role") == "assistant" + and "tool_calls" in msg + and not (isinstance(msg["tool_calls"], list) and msg["tool_calls"]) + ): + msg = {k: v for k, v in msg.items() if k != "tool_calls"} + dropped_empty_tool_calls += 1 + normalized.append(msg) + if dropped_empty_tool_calls: + messages = normalized + _ra().logger.debug( + "Pre-call sanitizer: dropped empty/invalid tool_calls on %d " + "assistant message(s)", + dropped_empty_tool_calls, + ) + # --- 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