Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions tests/run_agent/test_message_sequence_repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]





Expand Down
Loading