Skip to content

Performance

Where time goes, and the knobs that change it. Measure on your own machine: the example prints its numbers with --measure.

flowchart LR
  I["import agent_adapter<br/>only _version"] --> F["first use of a name<br/>loads its module + pydantic"]
  F --> E["Executor, built once"]
  E --> C["per call: validate, hooks,<br/>guards, cache, ledger, events"]
  C --> T["your tool<br/>(usually the slow part)"]

Import cost

def import_cost() -> tuple[bool, list[str]]:
    """In a fresh interpreter: is pydantic loaded, and which adapter modules are?"""
    code = (
        "import sys, agent_adapter\n"
        "print('pydantic' in sys.modules)\n"
        "print(sorted(m for m in sys.modules if m.startswith('agent_adapter')))\n"
    )
    out = subprocess.run(
        [sys.executable, "-c", code], capture_output=True, text=True, check=True
    ).stdout.splitlines()
    return out[0] == "True", eval(out[1])  # a list literal printed by the child
pydantic loaded: False | adapter modules: ['agent_adapter', 'agent_adapter._version']
Rule Effect
import agent_adapter loads only _version a CLI or a web app that imports it but never calls it pays almost nothing
a top-level name loads its module on first access (PEP 562) the first Executor or Registry access pays for pydantic and the core
integrations import their framework when first used an extra you never call costs nothing

Pay the cost at start-up, not in the first request: build the registry and the executor at import time. Check with python -X importtime -c "import agent_adapter; agent_adapter.Executor".

Parallelism and caps

@registry.tool(kind="search", parallel_safe=True)
async def search(query: str) -> str:
    """Search an index that allows two calls at a time."""
    in_flight["now"] += 1
    in_flight["peak"] = max(in_flight["peak"], in_flight["now"])
    await asyncio.sleep(0.01)
    in_flight["now"] -= 1
    return query


@registry.tool(cache_ttl=300, cache_scope="global")
def rates() -> dict[str, float]:
    """Exchange rates, the same for everyone."""
    return {"EUR": 0.92}


@registry.tool
def echo(text: str) -> str:
    """Return the text."""
    return text
# Build once per process, at import time; share it between requests.
executor = Executor(registry, cache=MemoryCache(), limits=RunLimits(per_kind={"search": 2}))
calls = [ToolCall(name="search", arguments={"query": str(i)}) for i in range(8)]
await executor.run_many(calls, concurrency=8)  # asks for 8 at once
print("peak search calls:", in_flight["peak"])  # the per-kind cap holds it at 2
first = await executor.run_call(ToolCall(name="rates"))
hit = await executor.run_call(ToolCall(name="rates"))
print("cache:", first.cached, hit.cached, hit.attempts)  # a hit runs nothing
peak search calls: 2
cache: False True 0
Knob Does
parallel_safe=True on a tool lets it share the run with other calls; without it the call takes the run's exclusive lock
run_many(..., concurrency=n) at most n calls of this batch at once
RunLimits(max_concurrency=) the default batch concurrency when a batch passes none (8)
RunLimits(per_kind={"search": 2}) a cap per tool kind and run: protects a rate-limited backend
RateLimitHook calls per second, not concurrency
sync tools run in asyncio.to_thread, so they never block the loop; they share the default thread pool

Caching

Cache Turn on with A hit costs
tool results cache_ttl= on the tool, cache= on the executor no tool run, attempts=0, $0
model responses executor.model(port, cache_ttl=) with an executor cache no model call, $0
embeddings CallableEmbedder(..., cache=) per text, not per batch
provider prefix cache PromptLayout and a provider plug-in a provider discount, still a call

See Caching and Cache model answers across workers.

Per-call overhead

python scripts/bench_overhead.py times a tool and a model that do nothing, directly and through the executor, with each feature on, in interleaved rounds, with INFO logging formatted as a service would. One run (agent_adapter 1.1.0, Python 3.12.8, Windows Server 2022, 4 CPUs, 10,000 calls each; your numbers will differ: run it on your hardware):

Scenario Median (µs) p95 (µs)
tool: direct await echo() (baseline) 0.4 0.7
tool: executor.run, nothing on 155 286
tool: + hooks (LoggingHook, PIIGuard) 449 650
tool: + approval (AutoApprove, confirm tool) 202 352
tool: + cache (every call a hit) 206 354
tool: + policy (allow rule, PII guard) 253 417
tool: + ledger 199 343
tool: hooks, approval, cache, ledger 513 806
tool: a sync tool, nothing on (worker thread) 450 605
model: direct generate (baseline) 3.6 6.8
model: executor.model(...), nothing on 74 123
model: + hooks and ledger 247 415
model: + response cache (every call a hit) 106 218

Each executor.run here starts a new run. Most of the hooks figure is LoggingHook writing its INFO lines. A model call takes hundreds of milliseconds, so the adapter's share is well under 1%. Sync tools pay a thread hop: make hot tools async.

Sync callers: run_sync or LoopRunner

Call Cost per call Use when
await executor.run(...) none extra you are in async code (FastAPI, async Django)
run_sync(work, timeout=) a new event loop per call; LoopLocal clients are made and closed each time WSGI views, Celery tasks, scripts: correct everywhere
run_sync inside a running loop plus a one-shot worker thread notebooks, sync callbacks inside async code
runner.run(work, timeout=) on one LoopRunner none extra after the first call: one loop in a daemon thread, LoopLocal clients stay warm a warm process with sync callers: Lambda, Cloud Run functions, gRPC sync servicers, Streamlit
made: list[int] = []


def new_client() -> int:
    """Stands in for an httpx or Redis client: expensive to build (TLS, pools)."""
    made.append(len(made) + 1)
    return made[-1]


client: LoopLocal[int] = LoopLocal(new_client)  # one client per event loop


async def which_client() -> int:
    return client.get()


def sync_callers() -> None:
    per_call = [run_sync(which_client, timeout=5) for _ in range(3)]  # a loop per call
    with LoopRunner() as runner:  # in real code: one module-level runner per process
        warm = [runner.run(which_client, timeout=5) for _ in range(3)]  # one loop, kept
    print("run_sync clients:", per_call, "| LoopRunner clients:", warm)
run_sync clients: [1, 2, 3] | LoopRunner clients: [4, 4, 4]

Three run_sync calls built three clients; three LoopRunner calls shared one.

LoopRunner rule Detail
prefer it over run_sync a process that stays warm and calls from sync code many times; keep run_sync for one-off scripts, cron, tests and Celery prefork tasks
one per process at module level; the loop starts in a daemon thread on the first run(), never at import
warm clients LoopLocal values (httpx, Redis) live as long as the loop, so TLS and pools are reused
fork a forked child (gunicorn --preload, Celery prefork) drops the parent's loop and starts its own on its first run()
close(timeout=30) lets in-flight work finish, cancels the rest (their callers get ToolUsageError), closes LoopLocal values, stops the loop; also runs at interpreter exit and at the end of a with block
inside its own loop run() or close() there raises ToolUsageError (it would deadlock): await instead

Details: Runtime; process models and probes: Deploy.

Anti-patterns

Anti-pattern Cost Do instead
an Executor per request rebuilds settings; run guards do not span requests one per process; executor.replace(...) for variants
a Registry per request rebuilds every schema build once; subset() views for tenants
one run_sync per call in a warm process a new event loop and new clients each time one LoopRunner, or one run_sync(batch) running run_many
module-global async clients break across loops, then get recreated LoopLocal
spec.invoke to "save overhead" skips approval, budgets, guards, cache and ledger the executor; it is the product
capture_content=True in production sinks large span attributes, PII leave it off

Files

File Does
examples/guide_performance.py import check, a capped search, a cache hit, an overhead timer, run_sync vs LoopRunner
scripts/bench_overhead.py the overhead table above (--iterations N)
tests/docs/test_guides.py runs the example offline and checks the output