Status: Accepted (2026-05-07).
ADR 0002 made the public API sync. The reasoning held: the dominant use case is "one host process orchestrates one agent run," and the internals are subprocess + threading.Event + Queue — primitives that work cleanly under sync code. Twelve months later, three classes of caller want to await eden.run(...) without wrapping every call in asyncio.to_thread:
- Web services orchestrating agents from FastAPI / Starlette handlers want one syntactic layer.
- Notebook authors running agents from
awaitable cells (%autoawaitenabled) hit the same friction. - Multi-agent fan-out code that already coordinates with
asyncio.gather(...)would prefer awaiting eden alongside everything else.
Three options were considered:
- Async-ify the core. Replace
subprocess.Popenwithasyncio.create_subprocess_exec; replaceQueuereads withasyncio.Queue; replacethreading.Eventwithasyncio.Event. Provideeden.aio.run(...)natively async, then a synceden.run(...)wrapper that callsasyncio.run(_aio_run(...)). Reverses ADR 0002. Forces the sync surface to exist inside an event loop, which breaks Jupyter /pytest -p no:asynciouse cases that ADR 0002 explicitly preserved. - Add a thin
eden.aionamespace whose functions wrap the sync API inasyncio.to_thread. Non-breaking — sync stays the way it is. The async functions delegate the blocking work to a worker thread andawaitits completion. Composable withasyncio.gather. Concurrency-bounded byasyncio's default thread pool (ThreadPoolExecutorofmin(32, cpu+4)); users override withasyncio.set_default_executorif they need more. - Document the
asyncio.to_threadrecipe indocs/python-api.md. Don't ship any new symbols. Cheapest, but every async caller writes the same boilerplate, and the recipe doesn't compose with type checkers (callers loseRunResulttyping without explicit annotations).
Adopt option 2. Ship eden.aio containing async wrappers around eden.run, eden.create_sandbox, and eden.interactive. Each wrapper has the same signature as its sync counterpart and returns the same value type — eden.aio.run returns RunResult, eden.aio.create_sandbox returns Sandbox, eden.aio.interactive returns InteractiveResult. The implementations are one line each: return await asyncio.to_thread(<sync_fn>, **kwargs).
Three intentional non-decisions:
- No async core refactor. ADR 0002 still applies. The sync API doesn't need an event loop; Jupyter / REPL re-entrancy keeps working.
- No
Sandbox.run_asyncmethod. Adding async methods to a frozen-ish dataclass leaksasynciointo the sync surface. Users who want toawait s.run(...)writeawait asyncio.to_thread(s.run, agent=...)— five extra characters, no surface complexity. - No async-only features. Anything in
eden.aiois reachable from the sync API too; the namespace is purely a syntactic convenience, not a feature gate.
- Async callers get
await eden.aio.run(...)with no boilerplate. The wrapped function runs on the asyncio default executor; cancellation propagates viaasyncio.CancelledError(the wrapper raises immediately when the task is cancelled, leaving the underlying sync call to complete in the worker thread — same semantics asasyncio.to_thread). - Type checkers see the same return types because the wrappers preserve them via direct annotations.
- Concurrency is bounded by the default
ThreadPoolExecutor. Users running >32 concurrent eden tasks should configure a larger executor — same advice as anyasyncio.to_thread-heavy code. - The decision is reversible. If async-native primitives become essential later (e.g. for true cancellation that interrupts a running subprocess),
eden.aiocan grow real implementations behind the same names without breaking existing callers. - No new runtime dependency:
asyncio.to_threadis stdlib (Python 3.9+, eden requires 3.11+). eden.aiois documented alongside the sync API indocs/python-api.mdrather than as a separate "advanced" page; the symmetry of the interface makes the cross-reference cheaper than splitting.
- ADR 0002 — the original sync-first decision this refines.
docs/python-api.md— Async API.eden/aio/__init__.py— the wrappers.