You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In the default parallel tool-execution path, a paid tool that was already prepared earlier in a batch still has its execute invoked after the run is aborted, when the abort fires while a later tool in the same batch is still being prepared. For a paid tool whose execute does not honor the abort signal (for example image_generate, whose execute ignores signal and calls a paid provider generation API), this spends money on work the user just cancelled.
Emit an assistant turn with two or more parallel tool calls, where an earlier call is a paid tool (image_generate) and a later call requires an awaiting beforeToolCall step (plugin hook, tool policy, or an approval broker).
While the later tool is still in beforeToolCall, abort the run (user interrupt / cancel).
The earlier paid tool, already prepared, still runs its execute.
Expected
Once the run is aborted, no further tool execute is invoked for that batch. Already-prepared but not-yet-executed calls are skipped with an aborted result, matching the guard that already exists at the end of prepareToolCall.
Actual
The earlier prepared paid tool's execute runs after the abort. In the parallel path, preparation (guarded) and execution (unguarded) are separated by a Promise.all barrier, so an abort that lands during the preparation loop does not prevent execution of calls that were already prepared.
Root cause
executeToolCallsParallel prepares each call in a loop, pushing a deferred execution thunk per prepared call, then runs them all via Promise.all. prepareToolCall re-checks signal?.aborted right before returning a prepared result (packages/agent-core/src/agent-loop.ts:961), but executePreparedToolCall has no such re-check before calling prepared.tool.execute:
Because the parallel preparation loop awaits config.beforeToolCall per tool (plugin hooks, tool policies, approval brokers all await there), an abort can land after an earlier call is prepared but before the batch reaches Promise.all. Every already-prepared thunk then executes. The sequential path is unaffected: it prepares and executes each call with no await in between, so the prepareToolCall guard covers it.
Signal-forwarding paid tools (for example web_search, which passes signal into fetch) are effectively self-protected: fetch rejects a pre-aborted signal before the network call, so no spend. The money impact lands on paid tools that ignore the signal. image_generate (src/agents/tools/image-generate-tool.ts, execute: async (_toolCallId, args) =>) takes no signal and calls a paid provider job, so it is the concrete money-burning profile. music_generate / video_generate share the generate-tool shape and warrant the same check.
Prior PR fix(agent-core): stop loop after aborted tool run #94412 ("stop loop after aborted tool run") is adjacent but different: it stops the loop from continuing to the next model turn after an aborted batch; it does not re-check the signal before executing already-prepared calls within the current batch.
Real behavior proof
Behavior addressed: a paid tool prepared earlier in a parallel batch still executes after the run is aborted mid-batch.
Real environment tested: real runAgentLoop from packages/agent-core/src/agent-loop.ts driven end to end (real parallel batch path, real prepareToolCall / executePreparedToolCall, real beforeToolCall hook), at commit 534ace4. Two parallel paid tool calls; the run is aborted from inside beforeToolCall while the second call is being prepared.
Exact steps or command run after this patch: drive runAgentLoop with the two-call batch above and record each tool's execute-invocation count and the abort state to a file.
Evidence after fix:
# OBSERVED (buggy, origin/main 534ace4d8a)
run_aborted=true
paid_image_generate_a_execute_invocations=1
needs_approval_b_execute_invocations=0
# EXPECTED (add a signal.aborted re-check at the top of executePreparedToolCall, identical inputs)
run_aborted=true
paid_image_generate_a_execute_invocations=0
needs_approval_b_execute_invocations=0
Observed result after fix: on origin/main the earlier paid tool executes once after the run was aborted; with an aborted re-check before prepared.tool.execute it is skipped, so the paid call is not made.
What was not tested: not driven through a live channel or a live paid provider account; the paid provider HTTP call itself was not billed. The proof measures whether execute is invoked after abort, which for image_generate is where the paid provider job is dispatched.
Summary
In the default parallel tool-execution path, a paid tool that was already prepared earlier in a batch still has its
executeinvoked after the run is aborted, when the abort fires while a later tool in the same batch is still being prepared. For a paid tool whoseexecutedoes not honor the abort signal (for exampleimage_generate, whose execute ignoressignaland calls a paid provider generation API), this spends money on work the user just cancelled.Environment
packages/agent-core/src/agent-loop.ts)Steps to reproduce
image_generate) and a later call requires an awaitingbeforeToolCallstep (plugin hook, tool policy, or an approval broker).beforeToolCall, abort the run (user interrupt / cancel).execute.Expected
Once the run is aborted, no further tool
executeis invoked for that batch. Already-prepared but not-yet-executed calls are skipped with an aborted result, matching the guard that already exists at the end ofprepareToolCall.Actual
The earlier prepared paid tool's
executeruns after the abort. In the parallel path, preparation (guarded) and execution (unguarded) are separated by aPromise.allbarrier, so an abort that lands during the preparation loop does not prevent execution of calls that were already prepared.Root cause
executeToolCallsParallelprepares each call in a loop, pushing a deferred execution thunk per prepared call, then runs them all viaPromise.all.prepareToolCallre-checkssignal?.abortedright before returning apreparedresult (packages/agent-core/src/agent-loop.ts:961), butexecutePreparedToolCallhas no such re-check before callingprepared.tool.execute:Because the parallel preparation loop awaits
config.beforeToolCallper tool (plugin hooks, tool policies, approval brokers all await there), an abort can land after an earlier call is prepared but before the batch reachesPromise.all. Every already-prepared thunk then executes. The sequential path is unaffected: it prepares and executes each call with no await in between, so theprepareToolCallguard covers it.Sibling surfaces
executeToolCallsSequential): unaffected, prepare immediately precedes execute.web_search, which passessignalinto fetch) are effectively self-protected: fetch rejects a pre-aborted signal before the network call, so no spend. The money impact lands on paid tools that ignore the signal.image_generate(src/agents/tools/image-generate-tool.ts,execute: async (_toolCallId, args) =>) takes no signal and calls a paid provider job, so it is the concrete money-burning profile.music_generate/video_generateshare the generate-tool shape and warrant the same check.Real behavior proof
Behavior addressed: a paid tool prepared earlier in a parallel batch still executes after the run is aborted mid-batch.
Real environment tested: real
runAgentLoopfrompackages/agent-core/src/agent-loop.tsdriven end to end (real parallel batch path, realprepareToolCall/executePreparedToolCall, realbeforeToolCallhook), at commit 534ace4. Two parallel paid tool calls; the run is aborted from insidebeforeToolCallwhile the second call is being prepared.Exact steps or command run after this patch: drive
runAgentLoopwith the two-call batch above and record each tool's execute-invocation count and the abort state to a file.Evidence after fix:
Observed result after fix: on origin/main the earlier paid tool executes once after the run was aborted; with an aborted re-check before
prepared.tool.executeit is skipped, so the paid call is not made.What was not tested: not driven through a live channel or a live paid provider account; the paid provider HTTP call itself was not billed. The proof measures whether
executeis invoked after abort, which forimage_generateis where the paid provider job is dispatched.