Skip to content

Executor

Executor (from agent_adapter import Executor) runs tools. Every call, from every API and every framework integration, goes through one fixed pipeline: guards, validation, hooks, approval, cache, timeout and retry, ledger, events. Each policy is on Executor policies.

@registry.tool(parallel_safe=True, timeout=5, retries=2, cache_ttl=300)
async def search(query: str, top_k: int = 3) -> list[str]:
    """Search the help-center articles."""
    return [f"{query} #{i}" for i in range(top_k)]


@registry.tool(confirm=True)
async def send_invoice(customer_id: str, amount: float) -> str:
    """Send an invoice to a customer (needs approval)."""
    return f"invoiced {customer_id} for {amount:.2f} USD"


class AskOnce:
    """An ApprovalPort that approves and fixes the amount."""

    async def request(self, request: ApprovalRequest) -> ApprovalDecision:
        return ApprovalDecision(approved=True, edited_args={**request.arguments, "amount": 45.0})


executor = Executor(registry, approval=AskOnce(), limits=RunLimits(max_calls=50))

One call

flowchart TB
  S["1 resolve spec · tool.started"] --> G["2 run guards<br/>once_per_run · max_calls"]
  G --> V["3 validate args"] --> B{"4 before_tool<br/>ShortCircuit?"}
  B -- yes --> AF
  B -- no --> AP["5 approval<br/>ctx.approval · confirm → port"]
  AP --> C{"6 tool cache hit?"}
  C -- hit --> AF
  C -- miss --> L["7 run lock + per_kind slot"] --> T["8 attempts under timeout<br/>retry with backoff"]
  T -- failed --> OE{"on_tool_error<br/>ShortCircuit?"}
  OE -- no --> E["error result / raise"]
  OE -- yes --> AF
  T -- ok --> AF["9 after_tool"] --> LE["10 ledger"] --> CS["11 cache store"] --> EV["12 tool.finished"]
hits = await executor.run("search", {"query": "returns"})
result = await executor.run_call(ToolCall(name="search", arguments={"query": 7}))
print(hits[0], result.status, result.error.kind if result.error else "")
Method Returns On failure
run(tool, args, ctx, *, call_id=None) the value raises (ToolNotFound, ToolArgsInvalid, ApprovalDenied, ApprovalPending, ToolTimeout, ToolFailed, ToolError, RunLimitExceeded, a hook's error)
run_spec(spec, args, ctx, *, call_id=None) the value as run; the spec need not be registered
run_call(call, ctx, *, registry=None) ToolResult status="error" or "pending"; never raises except control flow

call_id is call.id, else the call_id= keyword, else a new call_… id; registry= resolves names in another registry.

run takes the tool's name, its ToolSpec, or the function itself (@tool or registry.wrap). The function must be in this executor's registry, so nothing runs around the pipeline. Anything else is a ToolUsageError that says how to register it.

Sync code

Each async entry point has a blocking twin (SyncTwins). All twins run on one long-lived loop per process (a runtime.LoopRunner), so clients, semaphores and other loop-bound state stay valid across calls. Inside async code, await the async method.

Twin Same as
run_sync(tool, args, ctx, *, call_id=None, timeout=None) run
run_call_sync(call, ctx, *, registry=None, timeout=None) run_call
run_many_sync(calls, ctx, *, concurrency, on_error, registry, timeout) run_many
resume_sync(approval_id, decision=None, ctx=None, *, decided_by, reason, timeout) resume (Resume); a pending record needs decision and decided_by

A twin called from a tool that already runs on the shared loop raises ToolUsageError (it would deadlock): await instead.

Batches

calls = [
    ToolCall(name="search", arguments={"query": q, "top_k": 1}) for q in ("returns", "shipping")
]
results = await executor.run_many(calls, concurrency=4)
print([r.value for r in results])
Method Behaviour
run_many(calls, ctx, *, concurrency, on_error, registry) results in input order
as_completed(calls, ctx, *, concurrency, registry) async iterator in completion order; leaving early cancels the rest (use contextlib.aclosing)
run_plan(steps, ctx, *, concurrency, on_error, registry) {step.id: ToolResult} in step order
parallel(*work, concurrency, timeout, retries, on_error="fail_fast") non-tool work (e.g. LLM calls), results in input order

concurrency defaults to RunLimits.max_concurrency (8). on_error="collect" keeps failures in their ToolResult (ApprovalPending → status="pending"); "fail_fast" cancels the siblings and raises the first error. No batch raises an ExceptionGroup: the first escaping exception (control flow in every mode) is re-raised as itself. A batch may not repeat a non-empty call id (ToolUsageError).

Plans

plan = [
    Step(id="topics", tool="search", args={"query": "topics", "top_k": 1}),
    Step(
        id="detail",
        tool="search",
        args=lambda deps: {"query": deps["topics"].value[0], "top_k": 1},
        depends_on=["topics"],
    ),
]
by_step = await executor.run_plan(plan)
print(by_step["detail"].value)

The plan is checked first: duplicate ids, unknown dependencies and cycles all go into one PlanInvalid(problems). Steps run in waves: each wave holds every step whose dependencies are done, in step order, and runs as one run_many. A callable args gets {dep_id: ToolResult} for its own dependencies. A step whose dependency failed is not run: its error kind is dependency-failed. Step call ids are f"{run_id}:{step.id}", so running the same plan again in the same run (after an approval was decided) resumes its steps instead of starting new calls.

Non-tool work

async def summarise(text: str) -> str:
    return text.upper()

summaries = await executor.parallel(
    lambda: summarise("a"), lambda: summarise("b"), timeout=30, retries=1
)
print(summaries)

Items are coroutine functions or awaitables (not retryable). Expiry of timeout is ToolTimeout(tool="parallel[i]"); collect keeps exceptions in place.

Defaults and copies

Executor() with no registry uses the default registry, the one @tool and wrap fill; a registry you pass always wins. default_executor() is a process-wide Executor() made on first use; set_default_executor(ex) replaces it, reset_default_executor() (from agent_adapter.exec) drops it so the next use makes a fresh one (use it between tests that call configure), and runner_for(executor) returns executor or the default. ex.replace(**changes) builds a new executor with some constructor arguments changed; it shares the run table.

The default registry holds every @tool of every imported module, your libraries' too. An executor on it can run any of them by name, and so can a model in a tool loop. So servers, tool loops and queue workers should pass an explicit Registry. Executor() logs at INFO when it falls back to the default registry.

Shortcut Does
configure(registry=None, **options) set_default_executor(Executor(registry, **options)), returns it
call(tool, /, **args) run_sync on the default executor; returns the value
acall(tool, /, **args) await run(...) on the default executor

A registry that is not a Registry or an approval that is not a port (True, a string) is a ToolUsageError naming the fix.

An integration given executor=None reads the default when:

What Reads the default
exported tools, tool_node, tool_task, MCP servers at each call: set_default_executor after export applies
compile_flow (LangGraph, ADK) once, at compile: set the default first, or pass executor=
mcp_server(client_approval=True) also at build, for its approval store and port warning

Files

File Holds
exec/executor.py Executor: the public methods
exec/pipeline.py steps 1–12 for one call
exec/_approve.py step 5: confirm rule, port, edited args
exec/guards.py RunLimits, run table, run lock, per_kind slots
exec/retry.py Backoff, the retry rule, the attempt loop
exec/parallel.py batches without ExceptionGroup, parallel
exec/plan.py Step, plan validation, waves
exec/control.py register_control_flow, is_control_flow (re-exported from core/control.py, shared with approval and hooks)
exec/defaults.py default_executor, set_default_executor, reset_default_executor, runner_for, configure, call, acall
exec/_sync_twins.py SyncTwins: the blocking twins
exec/_sugar.py run by function, the shared sync loop, resume binding, constructor checks