Skip to content

agent-core: parallel batch runs a prepared paid tool after the run is aborted mid-batch #102252

Description

@yetval

Summary

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.

Environment

  • Commit: 534ace4 (origin/main at time of filing)
  • Surface: agent-core tool loop, parallel execution path (packages/agent-core/src/agent-loop.ts)

Steps to reproduce

  1. 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).
  2. While the later tool is still in beforeToolCall, abort the run (user interrupt / cancel).
  3. 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:

async function executePreparedToolCall(
  prepared: PreparedToolCall,
  signal: AbortSignal | undefined,
  emit: AgentEventSink,
): Promise<ExecutedToolCallOutcome> {
  const updateEvents: Promise<void>[] = [];
  let acceptingUpdates = true;

  try {
    const result = await prepared.tool.execute(   // packages/agent-core/src/agent-loop.ts:992
      prepared.toolCall.id,
      prepared.args as never,
      signal,
      ...

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.

Sibling surfaces

  • Sequential path (executeToolCallsSequential): unaffected, prepare immediately precedes execute.
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:queueable-fixClawSweeper marked this issue as an existing queue_fix_pr work candidate.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:otherThis issue has meaningful maintainer-visible impact outside the owned taxonomy.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.no-staleExclude from stale automation

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions