From b9e743c3e08ea4e6c2d7a651eead87935c963e17 Mon Sep 17 00:00:00 2001 From: qbit-mirror-bot Date: Thu, 2 Jul 2026 13:18:45 +0000 Subject: [PATCH] fix(agent): strip orphan tool_calls in repair_message_sequence (#56980) --- agent/agent_runtime_helpers.py | 38 ++++++++++ .../run_agent/test_message_sequence_repair.py | 73 +++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 18ed3102c272..f86b2322aef2 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -384,6 +384,14 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: any preceding assistant tool_call — dropped. 2. Consecutive ``user`` messages — merged with newline separator so no user input is lost. + 3. ``assistant`` messages whose ``tool_calls`` are missing all + ``tool`` results (``tool_call_id`` never seen) — the orphan + calls are stripped, leaving the assistant text intact. This + occurs after context compaction, partial session resume, or + retry loops that drop tool results. Without this pass, + strict providers (DeepSeek v4, Kimi) return HTTP 400 + "insufficient tool messages following tool_calls message". + Refs #56980. Deliberately does NOT rewind orphan ``assistant(tool_calls)+tool`` pairs that precede a user message — that pattern IS valid when the @@ -519,6 +527,36 @@ def _is_codex_interim(m: Dict) -> bool: continue merged.append(msg) + # Pass 3: strip orphan tool_calls from assistant messages that have + # no matching tool results. After Pass 1 dropped stray tool msgs, + # an ``assistant(tool_calls)`` that originally had results may now + # reference ids that were never seen — its tool calls are dangled. + # Strict providers (DeepSeek v4, Kimi) reject such histories with + # HTTP 400 "insufficient tool messages following tool_calls message". + # Fix: build a set of every tool_call_id actually present in tool + # messages, then strip tool_calls whose ids are absent. Refs #56980. + seen_tool_ids: set = set() + for msg in merged: + if isinstance(msg, dict) and msg.get("role") == "tool": + tc_id = msg.get("tool_call_id") + if tc_id: + seen_tool_ids.add(tc_id) + for msg in merged: + if not isinstance(msg, dict) or msg.get("role") != "assistant": + continue + calls = msg.get("tool_calls") + if not calls: + continue + orphan_calls = [tc for tc in calls if not (isinstance(tc, dict) and tc.get("id") in seen_tool_ids)] + if not orphan_calls: + continue + kept = [tc for tc in calls if tc not in orphan_calls] + if kept: + msg["tool_calls"] = kept + else: + del msg["tool_calls"] + repairs += len(orphan_calls) + if repairs > 0: # Rewrite in place so downstream paths (persistence, return # value, session DB flush) see the repaired sequence. diff --git a/tests/run_agent/test_message_sequence_repair.py b/tests/run_agent/test_message_sequence_repair.py index 93e65193756d..24659d321119 100644 --- a/tests/run_agent/test_message_sequence_repair.py +++ b/tests/run_agent/test_message_sequence_repair.py @@ -201,6 +201,79 @@ def test_repair_preserves_system_messages(): assert messages == original +def test_repair_strips_orphan_tool_calls_at_end(): + """assistant(tool_calls) at end with no tool results → strip tool_calls.""" + agent = _bare_agent() + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "", + "tool_calls": [{"id": "c1", "type": "function", + "function": {"name": "read_file", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "c1", "content": "file content"}, + {"role": "assistant", "content": "checking more", + "tool_calls": [{"id": "c2", "type": "function", + "function": {"name": "terminal", "arguments": "{}"}}]}, + # c2 has no tool result — context compaction stripped it + ] + + repairs = AIAgent._repair_message_sequence(agent, messages) + + assert repairs >= 1 + # The last assistant message must have its orphan tool_calls stripped + last = messages[-1] + assert last["role"] == "assistant" + assert "tool_calls" not in last + assert last["content"] == "checking more" + + +def test_repair_strips_orphan_parallel_tool_calls(): + """assistant with parallel tool_calls where some results are missing.""" + agent = _bare_agent() + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "", + "tool_calls": [ + {"id": "c1", "type": "function", + "function": {"name": "read_file", "arguments": "{}"}}, + {"id": "c2", "type": "function", + "function": {"name": "terminal", "arguments": "{}"}}, + ]}, + {"role": "tool", "tool_call_id": "c1", "content": "file content"}, + # c2 result is missing + ] + + repairs = AIAgent._repair_message_sequence(agent, messages) + + assert repairs >= 1 + # c2 should be stripped, c1 kept + assistant = messages[1] + assert assistant["role"] == "assistant" + remaining_ids = [tc["id"] for tc in assistant.get("tool_calls", [])] + assert "c1" in remaining_ids + assert "c2" not in remaining_ids + + +def test_repair_preserves_complete_tool_calls(): + """Complete tool_calls with matching results must NOT be stripped.""" + agent = _bare_agent() + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "", + "tool_calls": [{"id": "c1", "type": "function", + "function": {"name": "read_file", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "c1", "content": "file content"}, + {"role": "assistant", "content": "done"}, + ] + original = [dict(m) for m in messages] + + repairs = AIAgent._repair_message_sequence(agent, messages) + + # No repairs expected — sequence is valid + assert repairs == 0 + # tool_calls should be preserved + assert "tool_calls" in messages[1] + + # ── repair_message_sequence_with_cursor (#44837) ─────────────────────────── from agent.agent_runtime_helpers import repair_message_sequence_with_cursor