Stop an interrupted tool call wedging a harness node forever - #404
Merged
Conversation
backend/harness/loop.py appends the assistant turn - tool_calls and all -
and persists it to the transcript BEFORE invoking any tool. An
interruption in that window (a Stop, a timeout, a provider fault, the
process dying) leaves the transcript ending on an assistant turn whose
calls were never answered.
Measured against the real transcript writer and loader: after a Stop
before either tool ran, load_messages returns
user("go"), assistant(tool_calls=[c1, c2])
with both calls unanswered. loop.py appends the follow-up user message
after that, and the provider is handed
assistant(tool_calls) -> user(...), which every major provider rejects:
OpenAI requires a tool message per tool_call, Anthropic a tool_result per
tool_use. The malformed turn is on disk, so every subsequent follow-up
fails identically. The node is wedged permanently, and the only visible
symptom is a provider error the user cannot act on.
drop_leading_orphan_tools already handled the mirror case - a history that
OPENS mid tool-sequence, from a tail cut. This is the other end, and it
was missing.
close_unanswered_tool_calls synthesizes a result for every requested call
that has none, so the reloaded history always satisfies the
one-result-per-call contract. Repaired on LOAD rather than when the run
lands, deliberately: that fixes transcripts already on disk from before
this existed, and covers interruption paths that never reach a landing
handler at all. A synthetic result rather than dropping the assistant
turn, because the turn is a real record of what the agent decided to do -
dropping it would make the transcript lie about the run.
Test plan:
- 5 new tests: a fully interrupted turn gets synthetic results for both
calls; a partially answered turn keeps the real result and fills only
the gap; every requested call ends up answered (the invariant providers
actually enforce); a healthy transcript is byte-for-byte untouched; and
an assistant turn with no tool_calls is not disturbed.
- Full suite: 3205 passed, 19 skipped. ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
backend/harness/loop.py:575-581appends the assistant turn —tool_callsand all — and persists it to the transcript before invoking any tool. An interruption in that window (a Stop, a timeout, a provider fault, the process dying) leaves the transcript ending on an assistant turn whose calls were never answered.Measured against the real transcript writer and loader — a Stop before either tool ran:
loop.py:459then appends the follow-up user message after that, so the provider is handedassistant(tool_calls=[c1,c2])→user("try again"). Every major provider rejects that shape: OpenAI requires a tool message pertool_call, Anthropic atool_resultpertool_use.The malformed turn is on disk, so every subsequent follow-up fails identically. The node is wedged permanently, and the only visible symptom is a provider error the user cannot act on.
drop_leading_orphan_toolsalready handles the mirror case — a history that opens mid tool-sequence, from a tail cut, with a docstring saying "the rule lives in one place". This is the other end of the same rule, and it was missing.Change
close_unanswered_tool_callssynthesizes a result for every requested call that has none, so a reloaded history always satisfies the one-result-per-call contract.Two deliberate choices:
Repaired on load, not when the run lands. That fixes transcripts already sitting on disk from before this existed — users with an already-wedged node recover on the next follow-up — and covers interruption paths that never reach a landing handler at all (a crash, a kill).
A synthetic result rather than dropping the assistant turn. The turn is a real record of what the agent decided to do; dropping it would make the transcript lie about the run. The synthetic content says plainly that the tool was interrupted.
Test plan
tool_callsis not disturbed.ruff check .clean.🤖 Generated with Claude Code