Chapter 88 · Agent loops and control
Parallel tool calls, and when they are unsafe
Reads in parallel halve the latency of an investigation. The four conditions that make a parallel set safe, and the read that was correct when issued and wrong when used.
11 min read
Chapter 76 gave a one-line rule: parallelise reads, never writes. That rule is right and it is not the whole story — there are safe writes and unsafe reads, and the conditions are worth stating precisely.
The payoff is real: parallel reads took Atlas's four-tool investigations from 2.1s to 0.9s of tool time, which is most of the difference between a usable and an unusable question shape.
The four conditions
A set of calls may run concurrently when all four hold.
export function canRunInParallel(calls: ResolvedCall[], ctx: RunContext): ParallelDecision {
// 1. No call writes. A write may be observed by a concurrent read, and
// two writes may interleave in the target system.
if (calls.some((c) => TOOLS[c.tool].kind === 'write')) {
return { parallel: false, reason: 'contains_write' }
}
// 2. No call depends on another's output. For an explicit plan this is
// declared (chapter 82); for an implicit turn it must be inferred.
if (hasDependency(calls)) return { parallel: false, reason: 'dependency' }
// 3. No two calls hit the same rate-limited dependency beyond its
// concurrency allowance.
const byDependency = countBy(calls, (c) => TOOLS[c.tool].dependency)
for (const [dep, n] of entries(byDependency)) {
if (n > CONCURRENCY[dep]) return { parallel: false, reason: 'rate_limit', dependency: dep }
}
// 4. ⚠️ No call's precondition is satisfied by another call in the SAME
// set. A precondition satisfied concurrently is not satisfied.
if (calls.some((c) => TOOLS[c.tool].preconditions
?.some((p) => calls.some((o) => o.tool === p.tool)))) {
return { parallel: false, reason: 'concurrent_precondition' }
}
return { parallel: true }
}Condition 4 is the one that is easy to miss and that defeats chapter 70's precondition mechanism entirely. search_tickets and open_ticket issued in the same turn, run concurrently, means the search result is not available when the ticket is created — and the called.includes('search_tickets') check may pass or fail depending on scheduling.
Inferring dependencies from an implicit turn
An explicit plan declares dependencies. A model emitting three tool_use blocks in one turn does not.
// Two signals, both cheap.
export function hasDependency(calls: ResolvedCall[]): boolean {
// a) A placeholder referencing another call.
if (calls.some((c) => JSON.stringify(c.arguments).includes('{{'))) return true
// b) ⚠️ An argument that is also an expected OUTPUT field of another
// call in the set. "get_order_status(orderId)" beside
// "get_account_orders(accountId)" is a dependency the model
// expressed by guessing the order id.
const outputs = new Set(calls.flatMap((c) => TOOLS[c.tool].outputFields ?? []))
return calls.some((c) => Object.keys(c.arguments).some((k) => outputs.has(k)))
}The second signal catches a real pattern: the model issuing both calls at once and inventing the dependent argument. That is chapter 69's invention problem, and running the calls sequentially converts it into a correct second call.
Executing a parallel set
export async function executeParallel(
calls: ResolvedCall[], scope: Scope, ctx: RunContext,
): Promise<ToolResult[]> {
const limit = pLimit(MAX_CONCURRENT_TOOLS) // 4 on Atlas
const settled = await Promise.allSettled(
calls.map((c) => limit(() => executeToolRequest(c, scope, ctx))))
// ⚠️ allSettled, not all. One failing call must not discard the others —
// that is chapter 86's partial result, created by a scheduling choice.
return settled.map((s, i) =>
s.status === 'fulfilled'
? s.value
: { ok: false, class: 'unavailable', error: 'call_failed',
message: `Could not complete ${calls[i].tool}.` })
}// ⚠️ And the context mutation hazard. executeToolRequest appends to
// ctx.called, which several concurrent calls will do simultaneously.
// Collect, then apply once.
const names = calls.map((c) => c.tool)
ctx.called.push(...names) // after the set completes, not inside itThat ordering detail matters for condition 4: if ctx.called is mutated during the set, a precondition check inside the set can observe a sibling's entry and pass when it should not.
Safe writes in parallel
The blanket prohibition is a good default and it is stricter than necessary. Two cases are genuinely safe.
// 1. Writes to disjoint objects, declared by the tool.
// open_ticket for account A and open_ticket for account B do not
// interact. The tool declares its partition key.
export const openTicket: Tool = {
// ...
partitionKey: (args) => args.accountId,
}
export function writesAreDisjoint(calls: ResolvedCall[]): boolean {
const keys = calls.map((c) => TOOLS[c.tool].partitionKey?.(c.arguments))
if (keys.some((k) => k === undefined)) return false // ⚠️ unknown is not safe
return new Set(keys).size === keys.length
}
// 2. Idempotent writes with distinct keys. Chapter 73's key already
// includes the arguments, so distinct arguments are distinct effects.⚠️ Atlas permits neither. The gain is small — writes are rare and usually singular — and the analysis has to be right every time. The rule stays "no writes in parallel" and the partition-key machinery exists only so that a future case can be argued with evidence rather than relaxed by assertion.
Consistency across a parallel set
The subtle problem, and the subject of the failure mode below: four reads issued simultaneously return four views of a world that was changing.
// Stamp the set, so the answer can say what it is a view of.
export async function executeParallelSet(calls: ResolvedCall[], scope: Scope, ctx: RunContext) {
const issuedAt = new Date()
const results = await executeParallel(calls, scope, ctx)
// ⚠️ The spread between the earliest and latest data is the window in
// which the views can disagree. Report it when it is large.
const stamps = results.map((r) => r.asOf).filter(Boolean) as Date[]
const spreadMs = stamps.length ? Math.max(...stamps.map(Number)) - Math.min(...stamps.map(Number)) : 0
return {
results,
issuedAt,
spreadMs,
inconsistent: spreadMs > CONSISTENCY_TOLERANCE_MS,
}
}// Where a consistent view genuinely matters, the tools have to share a
// transaction — which means they are not separate tools.
export const accountSnapshot = registerTool({
name: 'get_account_snapshot',
description: 'Account summary, open invoices and recent payments as a ' +
'single consistent view. Use when comparing these against each other.',
async run(args, scope) {
// One transaction, one point in time. Three "tools" collapsed into
// one because the consistency is the requirement.
return replica.transaction(async (tx) => {
await tx.query(`set transaction isolation level repeatable read`)
return {
summary: await accountSummary(tx, args.accountId, scope),
invoices: await openInvoices(tx, args.accountId, scope),
payments: await recentPayments(tx, args.accountId, scope),
}
})
},
})Collapsing three tools into one is the correct answer when the comparison between them is the point. It is also a smaller tool surface, which helps selection.
What it buys
Atlas, investigative questions, four reads:
| Sequential | Parallel | |
|---|---|---|
| Tool time p50 | 2.1 s | 0.9 s |
| Tool time p95 | 4.4 s | 1.6 s |
| Total run p50 | 4.8 s | 3.4 s |
| Failed calls per run | 0.04 | 0.06 |
| Correct | 0.93 | 0.93 |
The p95 improvement is larger proportionally than the p50, because sequential execution accumulates every tail and parallel execution is bounded by the slowest single call.
The failure rate rising slightly is the concurrency limit interacting with a rate-limited dependency — handled by allSettled and partial answering, and visible because it was measured.
The failure mode: the read that was correct when issued
A rep asks whether Henderson's discount is being applied. Atlas issues two reads in parallel: the contract terms and the current orders.
The contract read returns at 14:22:01 — 12% above $25,000. The orders read returns at 14:22:01 — three orders, one at 0%.
Between the two reads being issued and the orders read being served, the contract was amended: the threshold moved to $15,000. The orders read saw the new world; the contract read saw the old one.
Atlas reports a discrepancy that does not exist. The 0% order is $18,400 — below the old threshold, above the new one — so under the current contract it should have a discount, and under the one Atlas read it should not.
Both reads were correct. The comparison between them was not.
// The detection: a comparison across sources needs a consistency check,
// and the check is cheap because asOf is already carried.
export function assertConsistent(results: ToolResult[], tolerance = 2_000): Issue | null {
const stamps = results.map((r) => r.asOf).filter(Boolean) as Date[]
if (stamps.length < 2) return null
const spread = Math.max(...stamps.map(Number)) - Math.min(...stamps.map(Number))
return spread > tolerance
? { kind: 'inconsistent_views', spreadMs: spread,
message: 'These were read at different moments and may disagree.' }
: null
}// The fix, for comparisons specifically: one tool, one transaction.
// ⚠️ The general rule is narrow and worth stating exactly — parallel
// reads are safe to REPORT and unsafe to COMPARE.
if (isComparison(question)) {
return getAccountSnapshot({ accountId }, scope) // one consistent view
}
return executeParallelSet(calls, scope, ctx) // independent factsParallel reads are safe when the results are reported independently and unsafe when they are compared. A comparison needs one point in time, which means one tool and one transaction.
Key points
- Four conditions for a safe parallel set: no writes, no dependencies, within concurrency allowances, and no precondition satisfied by a sibling.
- Condition 4 defeats the precondition mechanism silently, because satisfaction becomes scheduling-dependent.
- Infer dependencies from placeholders and from an argument that is another call's output field — that second signal catches the model inventing the dependent value.
- Use
allSettled, and apply context mutations after the set rather than inside it. - Disjoint and idempotent writes are arguably safe; Atlas still forbids them, and keeps the partition-key machinery so the case can be argued with evidence later.
- Parallel reads halved tool time and cut p95 by nearly two thirds.
- Stamp the set and report the spread between the earliest and latest data.
- Parallel reads are safe to report and unsafe to compare. A comparison needs one tool and one transaction.