Empty tool_calls arrays in agent message sanitizing
Cluster 58755. Data as of
Nothing open on GitHub. Fix merged in #59110 on 6 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #59110. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #59110. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These members concern empty tool_calls arrays produced during message repair or sanitization, which are reported to trigger an HTTP 400 with DeepSeek v4. Two entries share the same title for a pre-API sanitizer change (#58755), and one is a bug report that follows up on #56980; no outcome is asserted.
Open issues0
Open PRs0
Fixes on record
-
#59110 fix(agent): drop empty tool_calls arrays in pre-API sanitizer (#58755)
files:
agent/agent_runtime_helpers.py,tests/run_agent/test_message_sequence_repair.pyShow the diff
-
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 -
tests/run_agent/test_message_sequence_repair.py@@ -710,3 +710,63 @@ def test_sanitize_preserves_distinct_tool_call_ids(): assistant = [m for m in out if m.get("role") == "assistant"][0] assert [tc["id"] for tc in assistant["tool_calls"]] == ["call_A", "call_B"] assert sorted(m["tool_call_id"] for m in out if m.get("role") == "tool") == ["call_A", "call_B"] + + +def test_sanitize_drops_empty_tool_calls_array(): + """sanitize_api_messages strips ``tool_calls: []`` from assistant messages. + + DeepSeek v4 rejects an empty tool_calls array with HTTP 400 "Invalid + 'messages[N].tool_calls': empty array" (#58755). The empty array is + semantically "no tool calls", so the key is dropped while content is + preserved. + """ + from agent.agent_runtime_helpers import sanitize_api_messages + + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "answer", "tool_calls": []}, + ] + out = sanitize_api_messages(list(messages)) + assistant = [m for m in out if m.get("role") == "assistant"][0] + assert "tool_calls" not in assistant + assert assistant["content"] == "answer" + + +def test_sanitize_drops_non_list_tool_calls(): + """A malformed non-list ``tool_calls`` (e.g. None under the key) is also + dropped so it can't reach a strict provider.""" + from agent.agent_runtime_helpers import sanitize_api_messages + + messages = [ + {"role": "assistant", "content": "text", "tool_calls": None}, + ] + out = sanitize_api_messages(list(messages)) + assert "tool_calls" not in out[0] + + +def test_sanitize_does_not_mutate_original_on_empty_tool_calls(): + """Stripping must be non-destructive: the caller's message dicts (the + persisted trajectory) keep their original ``tool_calls`` key.""" + from agent.agent_runtime_helpers import sanitize_api_messages + + original_assistant = {"role": "assistant", "content": "answer", "tool_calls": []} + messages = [{"role": "user", "content": "hi"}, original_assistant] + sanitize_api_messages(list(messages)) + assert original_assistant["tool_calls"] == [] # untouched in-place + + +def test_sanitize_preserves_populated_tool_calls(): + """Negative control: a non-empty tool_calls array (with its matching tool + result) must survive untouched.""" + from agent.agent_runtime_helpers import sanitize_api_messages + + messages = [ + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call_Z", "type": "function", + "function": {"name": "foo", "arguments": "{}"}}, + ]}, + {"role": "tool", "tool_call_id": "call_Z", "content": "r"}, + ] + out = sanitize_api_messages(list(messages)) + assistant = [m for m in out if m.get("role") == "assistant"][0] + assert [tc["id"] for tc in assistant["tool_calls"]] == ["call_Z"]
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#58768 fix(agent): drop empty tool_calls arrays in pre-API sanitizer (#58755) · merged via another change →
#59110
Closed issues · 1
- Closed#58755[Bug]: repair_message_sequence creates empty tool_calls array, triggering DeepSeek v4 HTTP 400 (follow-up to #56980)
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #59110
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 6 Jul 2026