Tool concurrency¶
A model that asks for four independent lookups in one turn should cost about one lookup. Running them one after another pays four round trips for work that had no order to it. Running them all at once is how a single agent turn becomes a rate-limit breach at a partner, an exhausted connection pool, or a bill nobody predicted.
So the batch runs concurrently, inside lanes somebody declared.
The three lanes¶
ConcurrencyConfig names them. Each call stands in all of the ones that apply, and the
tightest decides.
| Lane | Field | Bounds | Declared by |
|---|---|---|---|
| The turn | max_concurrent_tools |
One model response's batch | Runner, narrowable by the agent |
| The tool | per_tool |
One tool across every run on the runner | Runner |
| The tenant | per_tenant |
One tenant across all of its runs | Runner |
runner = AgentRunner(
provider=provider,
tools=registry,
concurrency=ConcurrencyConfig(
max_concurrent_tools=8,
per_tool={"search": 4, "booking": 1},
per_tenant=16,
),
)
An agent may narrow what the runner declared and may never widen it:
Agent(name="planner", instructions="...", model="claude-sonnet-5", free_text=True,
concurrency=ConcurrencyConfig(max_concurrent_tools=2))
ConcurrencyConfig.narrowed_to is the composition rule, and it takes the smaller of each
field. Per-tool and per-tenant lanes bound a downstream shared with every other run on the
process — an agent cannot know about the others, so those stay the runner's to declare.
What an agent narrows, it narrows for its own turn.
Every call takes the lanes in one fixed order — tenant, turn, tool — so two calls cannot each hold what the other is waiting for. A tool body that starts a sub-agent is a special case of the same thing: the nested run spends the lane its caller is already standing in rather than queueing behind itself, so nested concurrency counts against the parent's limits exactly once.
Order out is the order asked for¶
Results go back to the model in call order regardless of finish order. Each call is dispatched against the same run and records only what it changed; the deltas are merged in call order once the batch resolves. A batch that finished in whatever sequence the network allowed therefore reads — and replays — as one deterministic transcript, and a cassette recorded from it does not depend on which lookup happened to be quickest that day.
Approvals, guardrails and hooks stay serial and stay in call order. Only the invocations fan out, because a human deciding about four tool calls should see them one at a time in the order they were asked for.
Failure is per call¶
A tool that fails loses its own call and nothing else. There is no placeholder result: the model sees exactly which calls failed and why.
- A raising tool is reported against its own call id as a tool error.
- A tool that outran
per_tool_seconds[name]fails withToolTimedOutError, naming the tool and its ceiling. That ceiling is the tool's own, so one slow lookup does not spend the batch's time. The run-wideDeadlineConfig.tool_call_secondsis unchanged and still ends the run — it is a ceiling on the run, not on a call. - Whether the batch survives at all is the agent's existing
on_tool_errordeclaration.SURFACE_TO_MODELkeeps the siblings and sends the failures back for the model to work around.FAIL_RUNends the run, and the siblings are stopped.
When a batch stops — cancelled, or ended by a sibling — what it did is recorded distinctly from what it never started:
| What happened | Event | Reported as |
|---|---|---|
| Still queued behind a lane | TOOL_ERROR |
never dispatched: <why> |
| In flight, tool declared idempotent | TOOL_ERROR |
Safe to call again |
| In flight, not declared idempotent | TOOL_INDETERMINATE |
The side effect may have landed |
The distinction is the point. A call that never went out is undone; a call that went out and was cancelled is unknown, and claiming it was undone is how a booking gets made twice.
Tools that cannot be parallelised¶
The runtime cannot infer order-dependence from a signature, so a tool declares it:
A call to such a tool is given a phase of its own: everything the model asked for before
it has resolved, nothing else is in flight while it runs, and everything after it starts
once it is done. Consecutive parallel-safe calls share a phase. Declaring a tool
parallel_safe=False also serialises two concurrent calls to it with identical arguments,
which is the answer for a non-idempotent tool the model asked for twice.
parallel_safe is not sent to any vendor and is not part of a determinism fingerprint, so
declaring it changes scheduling and nothing else.
Blocking tool bodies¶
A synchronous body that blocks blocks the event loop, and its siblings with it. Offloading
is the registry's job, not the batch's: run it on a WorkerPool
(docs/async-and-sync.md), which bounds the threads and refuses rather than growing
without limit. The loop monitor catches an undeclared blocking body and fails the call
with EventLoopStalledError naming the tool, rather than letting the whole batch stall
anonymously.
Worked example¶
examples/tool_concurrency.py runs the whole of the above against scripted providers —
four lookups at once, the same four two at a time, one failure among three successes, a
hanging tool cut off by its own ceiling, and an order-dependent tool run alone.