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