Certified Agent Harness Engineer
Course sections

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): boolean

and 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 = 10

Worked example — the shipped opt-in census

Worked example

Auditing the whole repository for isConcurrencySafe returns exactly four shipped tools that opt in:

ToolSource
readpackages/fs/tool-fs/src/read.ts
read_imagepackages/fs/tool-fs/src/read-image.ts
web_fetchpackages/web/tool-web/src/fetch.ts
web_searchpackages/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 it

Because 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

Start drill