diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 1bdce7a4989d4..07bc79ab9486c 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -640,6 +640,22 @@ def _is_verification_candidate(m: Dict) -> bool: prev["tool_calls"] = prev_calls + new_calls elif prev_calls: prev["tool_calls"] = prev_calls + else: + # Neither turn carries tool calls, but the surviving turn may + # still carry a stale ``tool_calls: []`` from the earlier + # message. An empty array is semantically "no tool calls", + # yet strict OpenAI-compatible providers (DeepSeek v4, + # Moonshot/Kimi) reject it with HTTP 400 ("Invalid + # 'messages[N].tool_calls': empty array..."). Drop the key + # HERE, at the source: ``sanitize_api_messages`` only fixes + # the per-call wire copy, so a ``[]`` left on the repaired + # turn survives in the live/persisted trajectory returned to + # callers (gateway/WebUI transcripts, session resume, + # subagents, cron) and is replayed on the next turn — which + # is how #58755 kept reproducing after the chokepoint fix + # (#77921). Popping is non-destructive: an empty array + # carries no information. + prev.pop("tool_calls", None) # Concatenate plain-text content; leave multimodal (list) # content on either side alone to avoid mangling attachment # blocks — fall back to keeping the existing content. diff --git a/tests/run_agent/test_message_sequence_repair.py b/tests/run_agent/test_message_sequence_repair.py index e169f1e06821d..314b81b1670b0 100644 --- a/tests/run_agent/test_message_sequence_repair.py +++ b/tests/run_agent/test_message_sequence_repair.py @@ -356,6 +356,33 @@ def test_sanitize_drops_empty_tool_calls_array(): assert assistant["content"] == "answer" +def test_repair_drops_stale_empty_tool_calls_on_merged_assistant(): + """repair_message_sequence must drop a stale ``tool_calls: []`` on the + surviving message of a consecutive-assistant merge (#77921). + + The chokepoint sanitizer (sanitize_api_messages) only patches the per-call + wire copy — a ``[]`` left on the repaired live/persisted trajectory is + replayed on the next turn and 400s strict providers (DeepSeek v4). The + merge's union branches only ever set non-empty lists or leave the key + untouched, so the empty array survives into the persisted state.""" + from agent.agent_runtime_helpers import repair_message_sequence + + messages = [ + {"role": "user", "content": "hi"}, + # surviving turn carries a stale empty tool_calls from an earlier pass + {"role": "assistant", "content": "first", "tool_calls": []}, + {"role": "assistant", "content": "second"}, + ] + # A dummy agent object is enough — repair only reads message roles/content. + agent = type("Agent", (), {})() + n = repair_message_sequence(agent, messages) + assert n >= 0 + assistants = [m for m in messages if m.get("role") == "assistant"] + assert len(assistants) == 1 + assert "tool_calls" not in assistants[0] + assert "second" in assistants[0]["content"] + +