Skip to content

fix(agent): close orphaned assistant(tool_calls) in repair_message_sequence (#56980) - #57013

Closed
JoaoMarcos44 wants to merge 1 commit into
NousResearch:mainfrom
JoaoMarcos44:fix/56980-repair-orphan-tool-calls
Closed

fix(agent): close orphaned assistant(tool_calls) in repair_message_sequence (#56980)#57013
JoaoMarcos44 wants to merge 1 commit into
NousResearch:mainfrom
JoaoMarcos44:fix/56980-repair-orphan-tool-calls

Conversation

@JoaoMarcos44

@JoaoMarcos44 JoaoMarcos44 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

repair_message_sequence handles stray tool messages, consecutive-assistant merges, and consecutive-user merges, but had no pass for the inverse case: an assistant message with tool_calls whose ids are not answered by the tool messages that follow. This happens after an interruption (/stop, process kill, resume mid tool-loop) when some or all of a turn's tool calls never got a result appended.

Strict OpenAI-compatible providers (DeepSeek v4, Moonshot/Kimi) reject that message shape outright:

An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'. (insufficient tool messages following
tool_calls message)

Scope, honestly

This does not fix an actively-reproducing HTTP 400 in the main request pipeline. sanitize_api_messages (called right after repair_message_sequence on every call, in conversation_loop.py and chat_completion_helpers.py — the single path CLI, gateway, and webui all funnel through) already injects a stub result for orphaned tool_call_ids before the request reaches the provider. Verified this directly against the real pipeline for every orphan shape in #56980; none reproduce the 400 on current main.

What this PR actually fixes: repair_message_sequence operates on the canonical, persisted messages list, while sanitize_api_messages only patches the ephemeral per-call api_messages copy. Any caller that runs repair_message_sequence without a follow-up sanitize_api_messages call — sub-agents, MoA reference-model calls, plugins, or the existing run_agent.py forwarder used only by tests — stays exposed to the exact symptom in #56980. This closes that gap at the source instead of depending on a second, separately-invoked function to catch it downstream every single call.

Fix

Added a pass to repair_message_sequence: for each assistant(tool_calls), scan the tool messages immediately following it, and for any tool_call_id still unanswered, insert a synthetic error result:

{"role": "tool", "tool_call_id": "<id>", "name": "<tool name>", "content": "Tool execution was interrupted before a result was returned."}

Stubs the missing result instead of stripping tool_calls from the assistant message, to avoid leaving {"role": "assistant", "content": None} when a turn's calls are all orphaned, and to keep the model's stated intent visible in the transcript.

No-op when every tool_call_id already has a matching result — the existing "ongoing dialog" pattern (a complete assistant(tool_calls)+tool pair followed by a user redirect) is left untouched, as documented in the function's docstring.

Follow-up fix (#57036): the id lookup in this pass initially read tool_calls[].id directly. Switched it to AIAgent._get_tool_call_id_static / _get_tool_call_name_static — the call_id || id extractor sanitize_api_messages and the context compressor already use — since Codex/Responses-API-style tool_calls key the id as call_id, not id. Without this, the pass silently had no effect on that shape.

Related

Fixes #56980 (design gap this closes), fixes #57036 (extractor follow-up).

Testing

  • tests/run_agent/test_message_sequence_repair.py: 6 new tests — fully unanswered trailing tool_calls, partially answered parallel tool_calls, orphaned tool_calls followed by an injected user/wakeup message, idempotency, call_id-keyed (Codex-shaped) orphans, and a non-regression check that a complete pair before a user redirect is left untouched.
  • Full file: python -m pytest tests/run_agent/test_message_sequence_repair.py -v — 31/31 passing.
  • Regression: tests/agent/test_context_compressor.py, tests/agent/test_replay_cleanup.py, tests/agent/test_close_interrupted_tool_sequence.py — 159/159 passing.
  • Full test_run_agent.py suite — 414/414 passing.

Type of Change

  • Bug fix / hardening (non-breaking)

Checklist

…quence

Interruption (/stop, process kill, resume mid tool-loop) can leave an
assistant(tool_calls) turn with some or all of its tool_call_ids
unanswered. Strict OpenAI-compatible providers (DeepSeek v4,
Moonshot/Kimi) reject that shape outright instead of completing, which
bricks the session until /new.

Add a repair pass that detects tool_calls not fully answered by the
tool messages immediately following them and inserts a synthetic
error result for each missing id, using the same stub shape already
used by sanitize_api_messages. Stubbing (instead of stripping the
tool_calls) keeps the model's stated intent visible in the transcript
and heals the persisted message list at the source, rather than only
the ephemeral per-call copy. No-op when every call already has a
matching result, so the existing ongoing-dialog pattern (complete
assistant(tool_calls)+tool pair followed by a user redirect) is left
untouched.

Fixes NousResearch#56980
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/deepseek DeepSeek API provider/kimi Kimi / Moonshot P1 High — major feature broken, no workaround sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 2, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: fixes #56980 (session-bricking HTTP 400 on strict providers). Distinct mechanism from the closed sibling #56990 — this PR stubs a synthetic tool result for each unanswered tool_call_id, whereas #56990 strips the orphan tool_calls. Part of the repair_message_sequence cluster with #55603 (merged) and #29168.

@JoaoMarcos44

Copy link
Copy Markdown
Contributor Author

Following up after this was closed: investigation found #56980 isn't reproducible as filed (the cited build/commit doesn't exist in repo history, and the pipeline already patches this shape downstream via sanitize_api_messages before it reaches the provider). The design gap this PR closed is still real though — repair_message_sequence isn't self-contained without a follow-up sanitize_api_messages call. Reopened cleanly as #57039, with a fix for an id-extractor gap this PR also had. New PR incoming from there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P1 High — major feature broken, no workaround provider/deepseek DeepSeek API provider/kimi Kimi / Moonshot sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

2 participants