Sync code and backends¶
The adapter is async. Sync code (a Flask or Django view, a Celery task, a cron script, a
notebook cell) calls it through run_sync. Async code (FastAPI, Django async views)
just awaits.
flowchart TD
Q{"Is an event loop running<br/>in this thread?"}
Q -- "yes, and you are in async code" --> AW["await executor.run(...)"]
Q -- "no (WSGI view, worker, script)" --> RS["run_sync(...)<br/>asyncio.run on a fresh loop"]
Q -- "yes, but this code is sync<br/>(notebook, callback)" --> TH["run_sync(...)<br/>fresh loop in a worker thread"]
RS --> CL["LoopLocal values closed<br/>before the loop ends"]
TH --> CL
Pick the call¶
| Where your code runs | Call | Guide |
|---|---|---|
| FastAPI, Starlette, any ASGI app | await executor.run(...) |
FastAPI |
| Django async view | await executor.run(...) |
Django |
| Flask, Django under WSGI | run_sync(executor.run(...), timeout=...) |
Flask, Django |
| Celery, RQ, cron, CLI scripts | run_sync(batch, timeout=...), one loop per task |
Celery, Scripts |
| Jupyter, or sync code inside a running loop | run_sync(...): it moves to a worker thread |
below |
run_sync never uses nest_asyncio.
One executor per process¶
Build it at import time and share it. Guard counters are thread-safe; the run lock and
per_kind slots exist per event loop, so concurrent run_sync calls from several
request threads do not block each other.
A WSGI view¶
def rate_view(item: str) -> dict[str, object]:
"""A Flask / Django (WSGI) view: plain sync code, no loop of its own."""
rate = run_sync(executor.run("shipping_rate", {"item": item}), timeout=30)
return {"item": item, "rate": rate}
Each run_sync call runs a fresh event loop and closes it. Always pass timeout: on
expiry the work is cancelled and TimeoutError is raised.
A worker task or script¶
def nightly_rates(items: list[str]) -> list[float]:
"""A Celery / RQ task or a cron script: one loop for the whole batch."""
async def batch() -> list[float]:
calls = [ToolCall(name="shipping_rate", arguments={"item": i}) for i in items]
return [r.unwrap() for r in await executor.run_many(calls)]
return run_sync(batch, timeout=300) # a callable: the coroutine is made inside the loop
Run a whole batch inside one run_sync call: one loop, and run_many runs the calls
concurrently. Pass a zero-argument callable (batch), not batch(): the coroutine is
created inside the new loop.
Inside a running loop¶
# Sync code called from inside a running loop (a notebook, an async callback):
# run_sync runs a fresh loop in a worker thread and blocks this thread until done.
return rate_view("book")
With a loop already running in this thread, run_sync starts a fresh loop in a one-shot
worker thread, copies the contextvars, and blocks the caller until done. If the work
blocks its loop past timeout, the caller stops waiting 5 s later (CALLER_GRACE_S); the
abandoned thread still holds up interpreter exit until it returns. In async code, prefer
await.
Loop-bound clients¶
class RatesClient:
"""Stands in for an httpx / redis client: bound to the loop that made it."""
def __init__(self) -> None:
self.loop = asyncio.get_running_loop()
opened.append(threading.current_thread().name)
async def aclose(self) -> None:
"""Close the connection pool."""
async def close(client: RatesClient) -> None:
await client.aclose()
rates_client = LoopLocal(RatesClient, close=close) # one client per loop, closed with it
@registry.tool(parallel_safe=True, timeout=10)
async def shipping_rate(item: str) -> float:
"""Shipping rate for an item, in percent of the order value."""
client = rates_client.get()
assert client.loop is asyncio.get_running_loop()
return 18.0 if item == "laptop" else 5.0
SDK clients (httpx, redis) and asyncio locks break on a loop other than the one that
made them, and every run_sync call has a new loop. Keep them in a LoopLocal: one
value per loop, created on first get(), closed by run_sync before its loop ends.
Never keep such a client in a module global.
Output¶
Three run_sync calls made three clients. The last one ran in the
agent_adapter-run_sync worker thread.
Rules¶
| Rule | Why |
|---|---|
Sync tools run in asyncio.to_thread |
a blocking tool never stalls the loop; contextvars are copied in |
Keep runs inside one run_sync call when they share state |
a run's lock lives on one loop; calls of one run on two loops do not exclude each other |
Pass a ctx you made with executor.context(...) to group calls |
guards, caps and budgets count per run |
| Ledger and trace state are objects passed around | never written through child-task contextvars, so nothing is lost across threads |
See Runtime for run_sync, LoopLocal and the current context.
Files¶
| File | Does |
|---|---|
examples/howto_sync.py |
a loop-local client, a WSGI view, a worker task, a running-loop call |
tests/docs/test_howto_examples.py |
runs it offline and checks the output |