[Bug] Agent loop: dangling tool/call without paired tool/result when a parallel tool call fails mid-dispatch — deriveMessages() builds a provider-invalid transcript #3591
Replies: 3 comments
|
这个根因和 #3524 完全一致—— |
Thanks @zoahdev — following the landing order from your reply (and #3524), I've assembled a branch that combines the community patches with the two missing pieces, verified red/green on clean master ( Branch: fix/unpaired-tool-call-recovery · diff vs master — 8 commits:
Reproduction → fix (same session log, two builds):
Red/green on clean master —
One design note: the repair had to live in the derivation rather than at load — a tail-appended Also confirmed while assembling this: the #3234 apiproxy noise ( |



Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Scenario
Found while reviewing the agent loop (
packages/core/agent-loop/src/tool-calls.ts, therunGroup()catch block, lines ~228–234 atdsh-v0.1.0-rc.7):The catch block in question:
assistant/messagecarries parallel tool calls A and B.startCallappendstool/callfor both (started = 2).dispatch(...)rejects (any tool execution error, including an AbortError on abort). The rejection handler setsschedulerFailureand does not setslots[B].throwSchedulerFailure()throws; the catch block drains in-flight promises but never callscommitReady()again — so neither A's nor B'stool/resultis appended — then rethrows. The turn ends withturn/end { kind: 'error' }.assistant/message(with A + B tool-call blocks), twotool/callevents, and zerotool/resultevents.tool/callis not a surface event, soderiveMessages()projects an assistant turn with tool-call blocks and no tool-result blocks.buildRequest()sends that malformed transcript to the provider (Anthropic/OpenAI reject it or misread it).interruptedTurnClosers(packages/session/session-persistence/src/coordinator.ts), returns[]for a closedturn/end{error}turn — so reloading persistence does not recover it.Design-intent conflict
The file docstring says "A terminal scheduler failure preserves already-recorded
tool/callevents without fabricating results" — so the current behavior is deliberate. But this post argues that intent conflicts with the derived-surface validity requirement: not fabricating a result produces an invalid transcript on the very next request, which seems strictly worse than a synthetic error result.Proposed direction
Parallel to what the abort path already does for unstarted calls via
appendSkippedToolCall— in the catch block, afterPromise.allSettled(inFlight.values()), synthesize an errortool/resultfor every started-but-uncommitted call:callSeqs[i]already holds thetool/callseq, so the pair stays linked.Would maintainers prefer repairing at append time (above), at replay time (
interruptedTurnClosers), or is the current shape considered acceptable? Happy to send a patch via a fork branch either way.All reactions