Runtime¶
agent_adapter.runtime holds the pieces every async API leans on: a sync facade, values
kept per event loop, the current context and ids. This page also covers the two building
blocks the executor uses: hooks and cache keys.
run_sync: async tools from sync code¶
flowchart TD
C["run_sync(work, timeout=)"] --> Q{"loop running<br/>in this thread?"}
Q -- no --> A["asyncio.run on a fresh loop"]
Q -- yes --> T["fresh loop in a one-shot worker thread<br/>contextvars copied; caller waits"]
A --> W["await work under asyncio.timeout"]
T --> W
W --> L["LoopLocal.aclose_all()<br/>before the loop closes"]
| Input | Meaning |
|---|---|
work |
an awaitable, or a zero-argument callable returning one (called inside the new loop) |
timeout |
seconds; on expiry the work is cancelled and TimeoutError raised. In the worker-thread case the caller also gives up CALLER_GRACE_S (5 s) later if the work blocks its loop |
A worker thread abandoned that way (work stuck past timeout plus the grace) is not a
daemon thread: it still blocks interpreter exit until the work returns.
LoopRunner: one warm loop for a long-lived process¶
run_sync builds and closes a loop per call, so LoopLocal clients (httpx, Redis) are
rebuilt each time. In a process that stays warm, keep one LoopRunner at module level.
executor = Executor(registry)
runner = LoopRunner() # module level: the loop starts on the first run(), not at import
def handler(event: dict[str, str], context: object = None) -> dict[str, object]:
"""A sync entry point called once per request or message."""
rate = runner.run(executor.run("shipping_rate", {"region": event["region"]}), timeout=30)
return {"status": "ok", "rate": rate}
| Pick | When |
|---|---|
await executor.run(...) |
you are already in async code (FastAPI, aiohttp, Gradio, Arq) |
LoopRunner().run(...) |
sync code in a warm process: Lambda, Cloud Run functions, Azure sync functions, Modal, Dramatiq, gRPC sync servicers, Streamlit (st.cache_resource) |
run_sync(...) |
one-off scripts, cron, tests, a Celery prefork task |
- The loop starts in a daemon thread on the first
run(), never at import, so a module-level runner is safe beforefork(). A forked child starts its own loop. run(work, timeout=)takes the same inputs asrun_syncand copies the caller's contextvars. Many threads may call it at once; sync tools share the loop's default thread pool (min(32, cpu + 4)threads).close(timeout=30)lets in-flight work finish, cancels the rest (their callers getToolUsageError), awaitsLoopLocal.aclose_all()and stops the loop. It also runs at interpreter exit and at the end of awithblock.- Calling
run()orclose()from inside the runner's own loop raisesToolUsageError(it would deadlock):awaitthere instead.
Never uses nest_asyncio. A non-awaitable work, or a timeout that is not a positive
number, raises ToolUsageError.
LoopLocal: one client per loop¶
SDK clients (httpx, redis) and asyncio primitives (Lock, Semaphore, Event) break
when used from a loop other than the one that made them. Every run_sync call runs a new
loop, so keep such objects in a LoopLocal. After fork() a child forgets every value
(they belong to the parent) and builds its own.
class Client:
"""Stands in for an httpx / redis client bound to the loop that created it."""
def __init__(self) -> None:
self.loop = asyncio.get_running_loop()
self.closed = False
created.append(self)
async def aclose(self) -> None:
self.closed = True
async def close_client(client: Client) -> None:
await client.aclose()
clients = LoopLocal(Client, close=close_client)
@registry.tool
async def fetch(path: str) -> str:
"""Fetch a page."""
client = clients.get() # this loop's client, created on first use
assert client.loop is asyncio.get_running_loop()
return f"GET {path}"
| Method | Does |
|---|---|
LoopLocal(factory, *, close=None) |
factory() runs on first get() in each loop |
get() |
this loop's value; ToolUsageError outside a loop; thread-safe |
aclose() |
forget this loop's value and await close(value) |
LoopLocal.aclose_all() |
close every instance bound to the running loop; a failing close is logged and the rest still close |
Values are held per loop. run_sync closes them before its loop ends. If you run your
own loops, await LoopLocal.aclose_all() before closing one; otherwise its values are
only dropped (not closed) on the next get(), because a value that references its loop,
such as a contended Lock or a client, would keep it alive.
Current context¶
with use_context(ToolContext(run_id=new_id("run_"))):
ctx = current_context() # what a nested helper sees
return ctx.run_id[:4] if ctx else "-"
use_context(ctx) sets a ContextVar for the block; current_context() reads it.
Child tasks see the parent's value. Writes in a child never flow back up.
new_id(prefix) returns prefix + uuid4().hex. Prefixes in use: run_, call_, apr_,
evt_, opt_.
Hooks¶
Hook (from agent_adapter import Hook) has nine async methods, all returning None by default:
before_*, after_* and on_*_error for tool, llm and node. The executor
calls them through three dispatchers.
| Dispatcher | Rule |
|---|---|
run_before(hooks, method, inv) |
registration order; the first ShortCircuit(value) wins and skips the call |
run_after(hooks, method, inv, value) |
threads the value through each hook; None keeps it |
run_on_error(hooks, method, inv, error) |
the first ShortCircuit recovers from the error |
A hook that raises is re-raised unchanged and logged with its name: a policy outcome
(BudgetExceeded, GuardrailBlocked, ApprovalDenied, ApprovalPending,
RunLimitExceeded) only at DEBUG (the deciding hook logs its own WARNING), any other
error at ERROR by type, control flow (is_control_flow) not at all. Returning
anything but ShortCircuit or None from a before_* / on_*_error hook is a
ToolUsageError. Invocations are frozen records: ToolInvocation(spec, call, ctx),
LLMInvocation(request, provider, model, ctx), NodeInvocation(flow, node, state, ctx).
Cache keys¶
agent_adapter.cache builds keys and envelopes; backends, the LLM response cache and
provider plug-ins are on the Cache page.
| Key | Format |
|---|---|
tool, scope run |
{ns}:tool:v1:run={run_id}:{tool}:{digest(args)} |
tool, scope thread |
{ns}:tool:v1:thread={thread_id}:{tool}:{digest(args)} |
tool, scope global |
{ns}:tool:v1:global:{tool}:{digest(args)} |
| LLM response | {ns}:llm:v1:{provider}:{model}:{digest(payload)} |
| Gemini explicit cache | {ns}:gcache:v1:{model}:{prefix_digest} |
digest is the first 32 hex characters of SHA-256 over canonical JSON (sorted keys, no
spaces). A run- or thread-scoped call without that id is not cached. Entries are
versioned UTF-8 JSON envelopes {"v": 1, "kind", "stored_at", "payload"}, never pickle;
a bad, foreign or old entry decodes to a miss. ToolResultCache(port) wraps any
CachePort and treats backend errors as misses (logged at WARNING).
This page's code is examples/runtime.py and examples/backend_loop_runner.py. The test
suite runs both.