M3.6
Parallel and concurrent tool calls
719 words · about 4 min
Concept
A model can emit several tool calls in one assistant message. Running them
concurrently cuts wall-clock latency substantially — reading six files in parallel
instead of six sequential round trips is a large win. But concurrency across
side-effecting tools introduces interference: two edit calls on one file, a
bash that removes a directory another tool is reading, a shell command that
changes a working directory.
The safe design is fail-closed classification: a tool is sequential unless it proves it is safe to parallelize.
Mechanism
dsh classifies each call via an optional predicate on the definition:
isConcurrencySafe?(args: unknown): booleanand resolves it to an execution mode:
type ToolExecutionMode = { kind: 'parallel' } | { kind: 'exclusive' }The registry's default is the critical line:
// packages/core/tools/src/index.ts
if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }No predicate means exclusive. An unknown tool means exclusive. And the wrapper
generated by defineTool returns false when the arguments fail validation —
invalid arguments cannot be assumed safe. Three separate fail-closed defaults for
one decision.
Note that the predicate takes arguments, not just the tool. This matters: a
tool can be safe for some calls and not others. A hypothetical git tool is safe
for status and unsafe for checkout, and an argument-aware predicate expresses
that without splitting the tool.
The scheduler then groups consecutive parallel calls into a bounded rolling pool
and treats each exclusive call as an ordering barrier. Two consequences follow.
First, only dispatch and body execution overlap — policy decisions, durable
results, and injected context all retain the model's original ordering, so the
transcript reads deterministically regardless of completion order. Second, because
a barrier only splits consecutive runs, a sequence read, read, bash, read, read
yields two parallel pairs with a barrier between, not five sequential calls.
Module 2 supplied the pool bound:
// packages/core/agent-loop/src/constants.ts
export const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10Worked example — the shipped opt-in census
Worked exampleAuditing the whole repository for isConcurrencySafe returns exactly four shipped
tools that opt in:
| Tool | Source |
|---|---|
read | packages/fs/tool-fs/src/read.ts |
read_image | packages/fs/tool-fs/src/read-image.ts |
web_fetch | packages/web/tool-web/src/fetch.ts |
web_search | packages/web/tool-web/src/search.ts |
All four declare () => true unconditionally. Everything else — write, edit,
bash, pwsh, run_code — is exclusive, which is unsurprising.
What is surprising, and is the best exam-grade discrimination in this section:
glob and grep do not opt in. Both are pure read-only searches. Both would
be obvious parallel candidates on a naive "read-only means safe" rule. But both
spawn packaged ripgrep subprocesses through ctx.subprocess, and a subprocess
carries resource cost, process-table pressure, and its own concurrency
characteristics that a read of an in-memory file does not.
The lesson generalizes: concurrency safety is a claim about resource interference, not merely about mutation. "Read-only" is necessary and not sufficient. Anything that spawns a process, holds a connection pool, or contends for a scarce external resource deserves the exclusive default even when it mutates nothing.
Tradeoffs
The fail-closed default leaves latency on the table — grep in particular could
likely parallelize with a concurrency cap on the subprocess layer. The
counter-argument is that a wrong true produces intermittent, non-reproducible
corruption that surfaces as "the agent is flaky," which is among the most expensive
bug classes to diagnose. Defaulting to slow-and-correct is the right call for a
harness whose failures are hard to reproduce.
How `dsh` does it
How dsh does itBecause the predicate lives on the definition rather than in the scheduler, the tool author — the person who actually knows whether their tool is reentrant — makes the call. The scheduler never infers safety from a name or a category.
How others differ
Claude Code documents the rule as a policy the model is asked to follow rather than
one the scheduler enforces: read-only tools may run concurrently, state-modifying
tools run sequentially, and custom tools default to sequential and must opt in via
MCP's readOnlyHint. Note the direction of that inheritance — MCP's hint is
advisory metadata about the tool, and the harness elects to treat it as a
concurrency permission. dsh makes it an executable predicate on the host side
instead, which is stronger, because an MCP server's self-description is a claim by
a third party.
Check yourself
2 questions on M3.6