Skip to content

Stop an interrupted tool call wedging a harness node forever - #404

Merged
dovvnloading merged 1 commit into
mainfrom
fix/harness-unanswered-tool-calls
Sep 4, 2026
Merged

Stop an interrupted tool call wedging a harness node forever#404
dovvnloading merged 1 commit into
mainfrom
fix/harness-unanswered-tool-calls

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Problem

backend/harness/loop.py:575-581 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 — a Stop before either tool ran:

reloaded history:
   user       tool_calls=-        id=-
   assistant  tool_calls=c1,c2    id=-

  requested tool_calls : ['c1', 'c2']
  answered             : []
  UNANSWERED           : ['c1', 'c2']
  history ends on      : assistant WITH tool_calls

loop.py:459 then appends the follow-up user message after that, so the provider is handed assistant(tool_calls=[c1,c2])user("try again"). Every major provider rejects that shape: 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 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_calls synthesizes 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

  • 5 new tests: a fully interrupted turn gets synthetic results for both calls; a partially answered turn (a Stop between two calls) keeps the real result and fills only the gap; every requested call ends up answered — the invariant providers actually enforce, asserted directly; a healthy transcript is untouched; and an assistant turn with no tool_calls is not disturbed.
  • Full suite: 3205 passed, 19 skipped. ruff check . clean.

🤖 Generated with Claude Code

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>
@dovvnloading
dovvnloading merged commit 129cdc1 into main Sep 4, 2026
5 checks passed
@dovvnloading
dovvnloading deleted the fix/harness-unanswered-tool-calls branch September 4, 2026 14:39
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.

1 participant