How do I run agents in parallel and cancel all of them if one fails?¶
You want to do several things at once and stop paying for the rest the moment one of them goes wrong.
When you'd want this¶
Fan out a Planner's sub-questions across N Researchers. Race two
implementations of the same skill and keep the first. Kick off a batch
of independent enrichment tasks. In each case you want two properties
the raw asyncio.gather doesn't give you:
- Sibling cancellation on failure. If one Researcher blows up, the others should stop; you don't want to keep paying to finish work you're about to throw away.
- A shared budget and cancel token. Cost accrues on the parent's
Budget; an external cancel (operator hit stop) propagates to every child at every safe point.
run_agents(...) is the primitive. It builds one ctx.child() per
pair (depth+1, sharing budget / services / cancel by reference)
and calls gather_bounded(...) under the parent's semaphore.
Assumes ANTHROPIC_API_KEY in the environment
The successful children run on providers.claude(...). To
demonstrate sibling cancellation without hoping the model
misbehaves, the failing child uses a tiny FailingCognition that
raises immediately — cognitions are pure Protocol impls, so a
~5-line one is a legitimate way to script a failure inside a real
wire-up. Swap providers.claude for providers.openai (and set
OPENAI_API_KEY) if that's what you have.
To run it with no key at all, replace the providers.claude(...)
call with FakeLLM("a short answer") from agentkit.core.testing. Every
other line is identical — that substitution is how this snippet is
verified.
Working code¶
"""Requires ANTHROPIC_API_KEY in the environment."""
import asyncio
import os
from collections.abc import AsyncIterator
from agentkit.agents import Agent
from agentkit.core.execution.concurrency import CancellationToken, run_agents
from agentkit.core.contracts.types import Scope, StreamEvent
from agentkit.models.providers import frontiers as providers
from agentkit.core.runtime import Invoker, RunContext, Services
class FailingCognition:
"""A five-line cognition that raises immediately.
`Cognition` is a runtime-checkable Protocol — any object with a
`name: str` and a `drive(...)` async generator satisfies it, so
scripting a scripted-failure agent doesn't need a test double."""
name = "failing"
async def drive(self, agent, task, ctx, context) -> AsyncIterator[StreamEvent]:
raise RuntimeError("scripted failure")
yield # unreachable — satisfies the async-generator signature
def build_services() -> Services:
llm = providers.claude(
api_key=os.environ["ANTHROPIC_API_KEY"],
model="claude-sonnet-4-6",
)
return Services(invoker=Invoker(llm=llm))
async def demo_sibling_cancel() -> None:
"""One failing agent cancels the rest — TaskGroup semantics."""
ctx = RunContext(correlation_id="run-1", scope=Scope(), services=build_services())
a = Agent(name="a", model="claude-sonnet-4-6", prompt="Brief the topic in one line.")
b = Agent(name="b", model="claude-sonnet-4-6", prompt="unused", cognition=FailingCognition())
try:
await run_agents([(a, "topic-a"), (b, "topic-b")], ctx)
except* RuntimeError as eg:
for exc in eg.exceptions:
print(f"[sibling-cancel] caught: {exc}")
async def demo_best_effort() -> None:
"""`best_effort=True` isolates failures into Failure objects."""
ctx = RunContext(correlation_id="run-2", scope=Scope(), services=build_services())
a = Agent(name="a", model="claude-sonnet-4-6", prompt="Brief the topic in one line.")
b = Agent(name="b", model="claude-sonnet-4-6", prompt="unused", cognition=FailingCognition())
results = await run_agents([(a, "topic-a"), (b, "topic-b")], ctx, best_effort=True)
for r in results:
print(f"[best-effort] {type(r).__name__}: {r}")
async def demo_external_cancel() -> None:
"""External signal trips the token; the agent unwinds cooperatively."""
from agentkit.core.execution.concurrency import Cancelled
ctx = RunContext(
correlation_id="run-3",
scope=Scope(),
services=build_services(),
cancel=CancellationToken(),
)
async def cancel_after(delay: float) -> None:
await asyncio.sleep(delay)
assert ctx.cancel is not None
ctx.cancel.cancel()
async def long_running() -> None:
for _ in range(20):
await asyncio.sleep(0.02)
ctx.check_cancelled()
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(cancel_after(0.05))
tg.create_task(long_running())
except* Cancelled as eg:
print(f"[external-cancel] observed: {[type(e).__name__ for e in eg.exceptions]}")
async def main() -> None:
await demo_sibling_cancel()
await demo_best_effort()
await demo_external_cancel()
if __name__ == "__main__":
asyncio.run(main())
How it works¶
There are only two things to decide, and they are about what should happen when one of the children fails.
Should the others keep going? If you are fanning out five parts of one answer, no — a missing part makes the answer wrong, so stop everything and report. If you are enriching a thousand database rows, yes — one bad row should not discard nine hundred good ones.
That is the whole difference between the two modes below. The first cancels the siblings on the first failure; the second isolates each failure into its own slot and lets the rest finish.
run_agents(pairs, ctx) — the default mode. Builds per-child
contexts, runs each agent.run(task, child_ctx) inside an
asyncio.TaskGroup bounded by ctx.semaphore(). TaskGroup semantics
give sibling-cancellation for free: the first failure raises,
outstanding tasks are cancelled, and the raised exceptions are grouped
into an ExceptionGroup you catch with except*.
run_agents(pairs, ctx, best_effort=True) — for batches where one
failure must NOT sink the others. Each slot in the returned list is
either an AgentResult or a Failure (from
agentkit.core.contracts.errors). Failure carries the exception on
.cause, the classified category on .category, and a source string
naming the slot — so a caller can retry, route around, or escalate
uniformly instead of guessing what a raw exception meant.
CancellationToken is cooperative — a well-behaved async loop
checks ctx.check_cancelled() at every safe point (loop top, between
steps, between tool calls). Cancelling the token doesn't kill running
coroutines; it makes the next check raise Cancelled. run_agents
shares the token by reference across all children, so cancelling the
parent cancels the subtree.
What bites people¶
ExceptionGroupisn't a plain exception. Useexcept*(Python 3.11+, agentkit requires 3.12) or iterateeg.exceptionsinside a plainexcept ExceptionGroup.- Cooperative cancel means points matter. A synchronous CPU-bound
loop inside a tool never checks the token. Yield an
await asyncio.sleep(0)or checkctx.check_cancelled()on your own iteration boundaries. Blocking I/O belongs on aProcessPoolExecutoror a threadpool so the event loop stays reactive. Budget.max_concurrencybounds fan-out, notrun_agents. The semaphore lives onBudgetand defaults to 8; if you fan out 100 children they'll still run 8 at a time. IncreaseBudget(max_concurrency=...)if you need more.best_effort=Truestill charges the shared budget. AFailure-in-a-slot doesn't refund the cost of the LLM calls that raced to error.
Related¶
- Concepts · Agents — where
run_agentsfits alongsideWorkflowand the coordinator cognitions. - Cap spend with Budget and Quota — the ceiling that stops a runaway fan-out.
agentkit.core.execution.concurrencymodule docstring — the full concurrency surface (gather_bounded,gather_best_effort,run_sync).