Skip to content

Tool Execution sequential/parallel

源码版本v0.73.1

executeToolCalls is the sub-flow inside runLoop's inner loop that handles tool calls in an assistant message. It splits the tool-call lifecycle into five stages: prepareToolCall (lookup + validate + beforeToolCall hook) → executePreparedToolCall (actually run tool.execute) → finalizeExecutedToolCall (afterToolCall hook override) → emitToolExecutionEndcreateToolResultMessage. Both sequential and parallel modes share these five stages; the only difference is scheduling order: sequential finishes one tool before starting the next, while parallel prepares serially and then runs Promise.all to execute + finalize concurrently.

Responsibilities

  1. Mode dispatch: executeToolCalls checks config.toolExecution and whether any tool has executionMode === "sequential"; if either is true, it goes sequential. See packages/agent/src/agent-loop.ts:350-365.
  2. Prepare stage: prepareToolCall finds the tool, prepareToolCallArguments handles legacy args, validateToolArguments validates, and the beforeToolCall hook can block. See packages/agent/src/agent-loop.ts:529-579.
  3. Execute stage: executePreparedToolCall calls tool.execute(id, args, signal, onUpdate); partial results flow to the UI via tool_execution_update events; exceptions are converted into error results. See packages/agent/src/agent-loop.ts:581-616.
  4. Finalize stage: finalizeExecutedToolCall calls the afterToolCall hook to override content/details/isError/terminate by field; if the hook itself throws, it's also converted into an error result. See packages/agent/src/agent-loop.ts:618-661.
  5. Build result message: createToolResultMessage wraps the finalized result into a ToolResultMessage (role/toolCallId/content/details/isError/timestamp), to be pushed into context by the caller. See packages/agent/src/agent-loop.ts:680-690.

Design motivation

Why prepare serially and execute concurrently? Because the beforeToolCall hook usually does side-effectful work like auth, logging, or rate limiting, and concurrent triggering easily races (e.g. refreshing an OAuth token at the same time). Serial prepare keeps hooks running in order. Execute is concurrent because tools themselves (reading files, running bash, HTTP requests) are independent, and serial wastes wall-clock time.

Why does tool_execution_end fire in "completion order" while toolResult messages fire in "source order"? In parallel mode, after Promise.all, the loop builds each toolResultMessage in order, but emitToolExecutionEnd is called as soon as each tool finishes finalizing — the UI gets a result the moment a tool completes, without waiting for the whole batch. The toolResult message gets fed back to the LLM, so out-of-order would confuse the model; it's emitted in the toolCall order from the assistant message. This split — event stream by completion order, message stream by source order — keeps UI responses fast and the LLM context stable.

Why are there both immediate and prepared outcomes? Cases like tool-not-found, argument validation failure, or beforeToolCall block never reach the execute step; they return an error result directly. Wrapping both that and a normal execute result in the same FinalizedToolCallOutcome shape means downstream emitToolExecutionEnd/createToolResultMessage don't need to distinguish the two paths.

Key files

Core loop of sequential mode — one tool finishes all five stages before the next:

typescript
// packages/agent/src/agent-loop.ts:383-416
for (const toolCall of toolCalls) {
  await emit({ type: "tool_execution_start", toolCallId: toolCall.id, toolName: toolCall.name, args: toolCall.arguments });
  const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);
  let finalized: FinalizedToolCallOutcome;
  if (preparation.kind === "immediate") {
    finalized = { toolCall, result: preparation.result, isError: preparation.isError };
  } else {
    const executed = await executePreparedToolCall(preparation, signal, emit);
    finalized = await finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, signal);
  }
  await emitToolExecutionEnd(finalized, emit);
  const toolResultMessage = createToolResultMessage(finalized);
  await emitToolResultMessage(toolResultMessage, emit);
  finalizedCalls.push(finalized);
  messages.push(toolResultMessage);
}

In parallel mode prepare is serial and execute is concurrent; event ordering is the key difference:

typescript
// packages/agent/src/agent-loop.ts:434-477
for (const toolCall of toolCalls) {
  await emit({ type: "tool_execution_start", ... });
  const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);
  if (preparation.kind === "immediate") {
    const finalized = { toolCall, result: preparation.result, isError: preparation.isError } satisfies FinalizedToolCallOutcome;
    await emitToolExecutionEnd(finalized, emit);   // immediate fires end right away
    finalizedCalls.push(finalized);
    continue;
  }
  finalizedCalls.push(async () => {                 // deferred until Promise.all
    const executed = await executePreparedToolCall(preparation, signal, emit);
    const finalized = await finalizeExecutedToolCall(currentContext, assistantMessage, preparation, executed, config, signal);
    await emitToolExecutionEnd(finalized, emit);    // fires end as soon as finalize is done
    return finalized;
  });
}
const orderedFinalizedCalls = await Promise.all(finalizedCalls.map((entry) => typeof entry === "function" ? entry() : Promise.resolve(entry)));
// then build toolResultMessage in source order ...

Data flow

Timing for an assistant message with 3 tool calls in parallel mode:

Boundaries and failure

Summary

Tool execution is split into five stages: prepare/execute/finalize/emitEnd/createToolResultMessage. Sequential runs the whole batch serially; parallel runs prepare serially and execute concurrently, with events in completion order and toolResults in source order. The beforeToolCall/afterToolCall hooks are invoked during prepare and finalize; any exception is turned into an error result rather than breaking the batch. For the hook shapes and AgentTool fields, see Type Contracts; for where the five stages are called inside runLoop's inner loop, see Double while Loop.