When an LLM Returns Multiple Tool Calls, Parallel Execution Is Not the Default Answer
When an Agent is connected to multiple tools, a single model response may return more than one tool_call.
For example, if a user asks to analyze two files at the same time, the model might return:
read_file(a.js)
read_file(b.js)
If each file takes 2 seconds to read, sequential execution would take about 4 seconds, while parallel execution might take only about 2 seconds.
It seems like simply putting all tools into Promise.all() would improve performance.
But consider a different task:
Create project
Install dependencies
Start dev server
If all three steps run simultaneously, dependencies might be installed before the project is created, and the server might start before dependencies are ready.
Therefore, the key question for multiple Tool Calls is not "do you know how to use Promise.all()?" but rather:
Is there a dependency relationship between these calls?
This article compares two real Agent Loops from a project:
tool.mjsusesPromise.all()for parallel executionmini-cursor.mjsand multiple MCP Demos usefor...of + awaitfor sequential execution
It examines which tasks each approach suits and how tool results are accurately mapped back to their original tool_call_id.
The Model Returns a Tool Call Array
When the model decides to use a tool, it returns tool_calls inside an AIMessage:
response.tool_calls
Each item typically contains:
{
id: 'call_xxx',
name: 'read_file',
args: {
filePath: './src/app.js'
}
}
When a single response requires multiple tools, tool_calls is an array:
[
{
id: 'call_1',
name: 'read_file',
args: { filePath: './src/app.js' }
},
{
id: 'call_2',
name: 'read_file',
args: { filePath: './src/config.js' }
}
]
The model is only responsible for generating the invocation intent and parameters. The Runtime is what actually decides how to schedule these tasks.
The Runtime can choose:
Sequential execution: complete one before starting the next
Parallel execution: start all simultaneously, wait for all to complete
Approach 1: Parallel Execution with Promise.all()
tool.mjs uses Promise.all() to handle all Tool Calls in one round:
const toolResults = await Promise.all(
response.tool_calls.map(async (toolCall) => {
const tool = tools.find(
item => item.name === toolCall.name
);
if (!tool) {
return `Error: tool ${toolCall.name} not found`;
}
try {
return await tool.invoke(toolCall.args);
} catch (err) {
return `Error: ${err.message}`;
}
})
);
map() converts each Tool Call into a Promise, and Promise.all() waits for these asynchronous tasks simultaneously.
The execution process can be understood as:
tool_call 1 ──start────────complete
tool_call 2 ──start────complete
tool_call 3 ──start──────────complete
↓
Continue after all tasks finish
It is best suited for independent I/O tasks, such as:
- Reading multiple unrelated files simultaneously
- Querying weather for multiple cities
- Requesting data from multiple independent APIs
- Running non-interfering checks on multiple pieces of content
These tasks do not need to wait for a previous result and do not modify the same shared state.
Will Promise.all() Result Order Get Messed Up?
No.
Even if the second tool finishes executing first, the result array returned by Promise.all() is still arranged in the order of the input Promises.
Input order: call_1, call_2, call_3
Completion order: call_2, call_1, call_3
Result array: result_1, result_2, result_3
Therefore, within the project, results can be mapped back to the original calls using the same index:
response.tool_calls.forEach((toolCall, index) => {
messages.push(
new ToolMessage({
content: toolResults[index],
tool_call_id: toolCall.id,
})
);
});
Two correspondences exist here:
toolResults[index] ↔ response.tool_calls[index]
tool_call_id ↔ toolCall.id
The array index helps the Runtime fetch the correct result, and tool_call_id helps the model confirm which call a result belongs to.
Parallel execution does not mean tool_call_id can be omitted.
Why Does Promise.all() Often Fail All at Once?
Standard Promise.all() has an important characteristic: if any single Promise enters a rejected state, the outer await immediately rejects.
await Promise.all([
taskA(),
taskB(), // rejected
taskC(),
]);
Note that the outer failure does not automatically cancel the other asynchronous tasks that have already started. taskA and taskC may still continue executing, but the current code no longer waits for their full results.
The project's approach is to catch errors inside each tool:
try {
return await tool.invoke(toolCall.args);
} catch (err) {
return `Error: ${err.message}`;
}
When a tool fails, it returns an error string instead of continuing to throw an exception.
This way, Promise.all() can still obtain a complete result array:
[
'Content of file A',
'Error: File not found',
'Content of file C'
]
In the next round, the model can see both successful results and failure reasons, then decide whether to retry or handle the situation differently.
Approach 2: Sequential Execution with for...of
mini-cursor.mjs uses a different approach:
for (const toolCall of response.tool_calls) {
const foundTool = tools.find(
tool => tool.name === toolCall.name
);
if (foundTool) {
const toolResult = await foundTool.invoke(
toolCall.args
);
messages.push(
new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id,
})
);
}
}
await is inside the loop, so the program only executes the next tool after the current one completes.
tool_call 1: start → complete
tool_call 2: start → complete
tool_call 3: start → complete
Sequential execution may take longer but is more suitable for tasks that produce side effects or share state:
- Writing to the same file
- Creating a project before installing dependencies
- Installing dependencies before starting a service
- Opening a webpage before capturing a page snapshot
- Capturing a snapshot before clicking an element based on page content
- Clicking into a detail page before taking a screenshot
These tasks cannot just look at "how many Tool Calls" there are; the sequence of relationships must also be considered.
Can Sequential Execution Solve All Dependencies?
No.
Multiple Tool Calls within the same AIMessage are planned by the model all at once, before seeing any tool results.
Even if the Runtime executes them sequentially, the parameters for the later call have already been determined in advance:
Model generates call_1 and call_2 simultaneously
→ Runtime executes call_1
→ Runtime executes call_2
After executing call_1, the model has no opportunity to regenerate the parameters for call_2 based on the actual result.
For example, a browser task:
Open search page
→ Capture page snapshot
→ Find hotel button based on snapshot
→ Click button
The model must first see the page snapshot to know which element to click.
Tasks that truly depend on the result of a previous step are better broken into multiple Agent Loops:
Round 1: Model calls open webpage
→ ToolMessage returns page state
Round 2: Model calls page snapshot based on state
→ ToolMessage returns snapshot content
Round 3: Model decides which element to click based on snapshot
So it's necessary to distinguish between two types of "sequential":
Sequential execution within the same round: only guarantees the order of side effects
Execution across model rounds: the next step can truly re-plan based on the previous step's result
Which Tools Cannot Be Easily Parallelized?
1. Modifying the Same Resource
If two tools write to the same file simultaneously, the final result may depend on which one finishes last:
write_file(app.js, content A)
write_file(app.js, content B)
This creates a race condition and cannot simply be put into Promise.all().
2. Browser Operations
Clicks, tab switches, input, and screenshots depend on the current page state.
If two elements are clicked simultaneously or a screenshot is taken before the page loads, the result becomes unpredictable.
3. Command Line Tasks
The following three steps have clear dependencies:
pnpm install
pnpm build
pnpm preview
The build depends on the installation result, and the preview depends on the build artifact.
4. Shared Database State
Simultaneously deducting inventory, creating an order, and updating payment status can cause data consistency issues.
Whether concurrency is allowed should be determined by business transactions and idempotent design, not solely by model judgment.
Which Tools Are Suitable for Parallel Execution?
Tasks meeting the following conditions are generally more suitable for parallel execution:
- Do not depend on each other's return results
- Do not modify the same resource
- Failure does not disrupt other tasks
- Call order does not affect the final result
- The server allows the corresponding level of concurrency
For example:
Read a.js, b.js, c.js
Query weather for Beijing, Shanghai, Guangzhou
Check three independent configuration files
Fetch data from multiple unrelated public APIs
Before parallelizing, API rate limits, connection counts, and machine resources must still be considered.
If the model returns dozens of calls at once, launching them all directly might also trigger API rate limits or exhaust file handles. Real-world projects typically add a concurrency cap rather than using Promise.all() without limits.
How Should the Runtime Decide?
The decision of "parallel or sequential" cannot be entirely left to the LLM's natural language reasoning.
A more robust approach is to let the Runtime judge based on tool properties and resource conflicts.
Start with four questions:
- Does a later call need the return value of a previous call?
- Will multiple calls modify the same file, page, or database record?
- Can the tool be safely retried after failure?
- Does the external service have concurrency or rate limits?
A simple decision framework can be summarized as:
| Scenario | Suggested Approach |
|---|---|
| Multiple independent read-only queries | Parallel execution |
| Modifying the same state | Sequential execution |
| Later step needs previous step's result | Execute across Agent rounds |
| Browser clicks, waits, screenshots | Sequential and split across rounds |
| Large number of independent calls | Limit concurrency count |
ToolMessage Order Must Also Remain Stable
Regardless of how tools are scheduled, each result must ultimately be placed back into messages:
AIMessage(tool_calls)
→ Runtime executes tools
→ ToolMessage(tool_call_id)
→ Call model again
During parallel execution, the correspondence must not be lost by arbitrarily inserting results in the order they finish.
The project uses the result order from Promise.all() and the original tool_calls indices to complete the mapping, then preserves the tool_call_id for each message. This is key to making parallel tool execution work stably.
Summary
An Agent returning multiple Tool Calls at once does not mean all tools should be parallelized.
Parallelism solves waiting time
Sequential execution guarantees state and dependencies
Cross-round execution solves re-planning based on actual results
The selection principle can be remembered as:
Independent, read-only, no shared state → Promise.all()
Side effects or resource conflicts exist → for...of + await
Later step depends on previous step's result → Return ToolMessage, let model enter next round
The model is responsible for proposing the call plan; the Runtime should be responsible for concurrency control, error isolation, and execution order.
This is also an engineering capability that must be added for an Agent to move from "being able to call tools" to "reliably completing complex tasks."