Tool Execution sequential/parallel
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) → emitToolExecutionEnd → createToolResultMessage. 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
- Mode dispatch:
executeToolCallschecksconfig.toolExecutionand whether any tool hasexecutionMode === "sequential"; if either is true, it goes sequential. Seepackages/agent/src/agent-loop.ts:350-365. - Prepare stage:
prepareToolCallfinds the tool,prepareToolCallArgumentshandles legacy args,validateToolArgumentsvalidates, and thebeforeToolCallhook can block. Seepackages/agent/src/agent-loop.ts:529-579. - Execute stage:
executePreparedToolCallcallstool.execute(id, args, signal, onUpdate); partial results flow to the UI viatool_execution_updateevents; exceptions are converted into error results. Seepackages/agent/src/agent-loop.ts:581-616. - Finalize stage:
finalizeExecutedToolCallcalls theafterToolCallhook to overridecontent/details/isError/terminateby field; if the hook itself throws, it's also converted into an error result. Seepackages/agent/src/agent-loop.ts:618-661. - Build result message:
createToolResultMessagewraps the finalized result into aToolResultMessage(role/toolCallId/content/details/isError/timestamp), to be pushed into context by the caller. Seepackages/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
packages/agent/src/agent-loop.ts:350-365—executeToolCallsmode dispatch; if any tool declares sequential, the whole batch goes sequential.packages/agent/src/agent-loop.ts:372-422—executeToolCallsSequential:for (const toolCall of toolCalls)walks the five stages one at a time.packages/agent/src/agent-loop.ts:424-483—executeToolCallsParallel: first loops prepare/immediate dispatch, pushing pending executions into() => Promiseclosures, thenPromise.allruns them concurrently.packages/agent/src/agent-loop.ts:511-513—shouldTerminateToolBatch: the batch terminates only when all finalized calls setterminate: true; if any doesn't, it continues.packages/agent/src/agent-loop.ts:515-527—prepareToolCallArguments: uses the tool's ownprepareArgumentsfor arg compatibility; returns the same object without copying.packages/agent/src/agent-loop.ts:529-579—prepareToolCall: tool-table lookup, validation,beforeToolCallhook; returns aPreparedToolCallorImmediateToolCallOutcome.packages/agent/src/agent-loop.ts:581-616—executePreparedToolCall: callstool.execute; partial results go throughtool_execution_update; exceptions become errors.packages/agent/src/agent-loop.ts:618-661—finalizeExecutedToolCall: callsafterToolCall; field-level override; hook throws also become errors.packages/agent/src/agent-loop.ts:680-690—createToolResultMessage: wraps the finalized outcome into aToolResultMessage.
Core loop of sequential mode — one tool finishes all five stages before the next:
// 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:
// 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
- Tool not found:
prepareToolCallcan't findtoolCall.namein the tool table; it returns animmediateerror result without throwing or breaking the batch. Seepackages/agent/src/agent-loop.ts:536-543. - Argument validation failure:
validateToolArgumentsthrows are caught and turned into animmediateerror result; the tool'stool_execution_endis still emitted. Seepackages/agent/src/agent-loop.ts:572-578. - beforeToolCall block: when
beforeToolCallreturns{ block: true, reason }, the tool becomes an error result, with reason written into the result content. Seepackages/agent/src/agent-loop.ts:558-565. - execute throws:
executePreparedToolCallcatches the exception and returns anisError: trueresult; partial result events are awaited viaPromise.allbefore returning. Seepackages/agent/src/agent-loop.ts:608-615. - afterToolCall throws:
finalizeExecutedToolCallcatches hook exceptions and overrides with an error result; other tools' finalize are not affected. Seepackages/agent/src/agent-loop.ts:650-654. - Batch termination:
shouldTerminateToolBatchrequires the whole batch to setterminate: trueto return true;runLoopuses this to sethasMoreToolCalls=falseand exit the inner loop. Seepackages/agent/src/agent-loop.ts:511-513andpackages/agent/src/agent-loop.ts:206-214.
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.