Skip to content

fix(agent): heal threads left with an unanswered tool call - #174

Open
ciaransweet wants to merge 2 commits into
mainfrom
fix/dangling-tool-calls
Open

ciaransweet wants to merge 2 commits into
mainfrom
fix/dangling-tool-calls

Conversation

@ciaransweet

@ciaransweet ciaransweet commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A run cancelled while a tool is running leaves the model's tool call in the checkpoint with no result. This happens when the client closes the stream or a pod is rolled mid-turn. Providers then reject every later turn on that thread, and nothing the user sends can fix it.

Seen in production on the ECMWF deployment (ecmwf/dss-agentic-ai-services#231):

  • The browser closed /runs 8 seconds into a search_knowledge call, and uvicorn cancelled the run (Cancelled via cancel scope … by RequestResponseCycle.run_asgi()).
  • Every later message on the thread got Mistral 400 Not the same number of function calls and responses (code 3230).

Fix

New mcp_agent.interrupted_tool_calls: a before_agent middleware that closes each unanswered tool call with an error ToolMessage placed straight after it. It writes that back to the checkpoint, so the thread heals once, on its next turn, including threads that are already broken.

  • with_session_state always wires it in, after StateCaptureMiddleware and before the host's own middleware.
  • build_agent with session state off (MCP_AGENT_STATE=0) skips with_session_state but still keeps conversations, so it carries the repair as well.
  • docs/CONSUMING.md says both bundled agents carry it, and tells a host assembling its own checkpointed agent (section 4b) to add it to middleware.
  • A thread paused on interrupt() is never touched. A message on a paused thread is refused before the graph runs, and a resume re-enters the paused node without going through before_agent.

Proof

  • tests/mcp_agent/test_interrupted_tool_calls.py really cancels a run mid-tool against a model that validates history the way Mistral does.
    • With a plain create_agent, the next turn raises the 3230 error.
    • Through with_session_state, the next turn answers, and the repaired checkpoint has the placeholder right after the call.
  • Live: the production thread's checkpointed messages replayed against mistral-small-latest return the 400 as stored. Repaired, they succeed, and the model reissues the interrupted search_knowledge call on its own.

🤖 Generated with Claude Code

ciaransweet and others added 2 commits October 2, 2026 16:43
A run cancelled while a tool is running (the client closing the stream,
a pod rolled mid-turn) checkpoints the model's tool call with no result.
Providers then reject every later turn on that thread; Mistral answers
400 "Not the same number of function calls and responses".

with_session_state now always wires a before_agent middleware that
closes each unanswered call with an error ToolMessage placed straight
after it, written back to the checkpoint, so the thread heals on its
next turn. A thread paused on interrupt() is untouched: a message on one
is refused before the graph runs, and a resume skips before_agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plain agent build_agent returns under MCP_AGENT_STATE=0 bypasses
with_session_state but keeps conversations the same way, so a cancelled
run broke its threads just the same. It now carries the repair too.

CONSUMING.md says both bundled agents carry it, and tells a host
assembling its own checkpointed agent to add it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@j08lue j08lue left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I guess.

raise ValueError(MISTRAL_3230)
if not calls:
reply = AIMessage(
"", tool_calls=[{"name": "search", "args": {}, "id": "SH2lrEzCG"}]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better parameterise this ID. But nvm.

)
from mcp_agent.main import with_session_state

MISTRAL_3230 = "Not the same number of function calls and responses"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting static detail. So this expectation is provider-dependent?

This will likely break when we use another provider.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that they report it differently, but the high level expectation is the same.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree that it will likely break if we switch, but probably an 'expected' break.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants