ADR-007: Async-First Design with Sync Wrappers¶
Date: 2026-05-22
Status: Accepted
Deciders: ExecutionKit core team
Technical Story: All four core patterns (consensus, refine_loop, react_loop, structured) were implemented as async def coroutines from the outset. Synchronous variants (consensus_sync, refine_loop_sync, react_loop_sync, structured_sync, pipe_sync) are thin wrappers in executionkit/__init__.py that call asyncio.run() on the underlying coroutine.
Context and Problem Statement¶
ExecutionKit's patterns involve concurrent LLM calls (e.g. consensus fires num_samples requests in parallel via asyncio.gather), streaming retries, and tool execution loops (react_loop) that interleave network I/O with async Python function calls. The team needed to decide whether the primary public API should be synchronous (with an async variant for advanced users) or async-first (with sync wrappers for convenience).
Python's asyncio concurrency model imposes constraints: calling asyncio.run() from inside a running event loop raises RuntimeError. This means sync wrappers cannot be used inside Jupyter notebooks or other async frameworks without nest_asyncio, and must detect the running-loop condition explicitly.
Decision Drivers¶
consensusrequires concurrent HTTP requests;asyncio.gatheris the natural mechanism. Implementing this with threads adds complexity and theGILlimits true parallelism for CPU-bound code.react_looptools are typed asasync defcallables — the pattern naturallyawaits them inside the loop.- The library must be usable from plain synchronous scripts (the common case for quick experiments).
- Sync wrappers must fail clearly when called from inside an event loop, rather than hanging or producing
RuntimeErrorstack traces that are hard to interpret. - Coroutine objects must not be garbage-collected un-awaited (this emits a
"coroutine was never awaited"RuntimeWarning).
Considered Options¶
- Option A: Async-first (
async defpatterns); sync wrappers viaasyncio.run()in__init__.py - Option B: Sync-first; async variants via
asyncio.create_task()orloop.run_in_executor() - Option C: Threads for concurrency; no async API
Decision Outcome¶
Chosen option: Option A (async-first with sync wrappers), as implemented across executionkit/patterns/ and executionkit/__init__.py. Every pattern is an async def coroutine. Sync wrappers are defined in __init__.py using a shared _run_sync() helper that:
- Calls
asyncio.get_running_loop()to detect an active event loop. - If one is found, closes the coroutine before raising
RuntimeError("Cannot use sync wrappers inside an async context…"). Closing the coroutine before raising prevents the"coroutine was never awaited"RuntimeWarning(verified bytest_run_sync_closes_coroutine_in_active_event_loopintests/test_sync_wrappers.py). - If no loop is running, calls
asyncio.run(coro).
The sync wrappers are exported from __all__ alongside their async counterparts:
| Async | Sync |
|---|---|
consensus |
consensus_sync |
refine_loop |
refine_loop_sync |
react_loop |
react_loop_sync |
structured |
structured_sync |
pipe |
pipe_sync |
Positive Consequences¶
consensusachieves true concurrency overnum_samplescalls without threads.react_looptools areasync defcallables — tool execution integrates naturally into the pattern without executor hops.- Synchronous scripts call
consensus_sync(provider, prompt)with noasyncioknowledge required. - The clear error message in
_run_sync("Cannot use sync wrappers inside an async context — useawaitinstead, or installnest_asyncio") guides users toward the correct fix. - The coroutine-close guard eliminates a class of
RuntimeWarningnoise.
Negative Consequences¶
- Sync wrappers cannot be used inside Jupyter notebooks or async web frameworks without
nest_asyncio. Users in those environments must useawait. asyncio.run()creates and tears down a new event loop per call, which is wasteful if sync wrappers are called in a tight loop. Users with high-volume synchronous use cases should refactor to an async entry point.
Pros and Cons of the Options¶
Option A: Async-first with sync wrappers¶
- Good: Natural fit for concurrent I/O patterns (
consensusfan-out,react_looptool awaiting). - Good: Sync wrappers provide a low-friction entry point for scripts and notebooks (with
nest_asyncio). - Good: Tool implementations are
async def— no executor overhead for async tools. - Bad: Sync wrappers cannot be called inside a running event loop without
nest_asyncio.
Option B: Sync-first with async variants¶
- Good: Synchronous use is the default; no
asyncioknowledge needed for simple cases. - Bad: Concurrent
consensuscalls require thread pools or explicitasynciobridge code. - Bad: Tool implementations would need to be synchronous or wrapped in
run_in_executor, adding complexity.
Option C: Threads for concurrency¶
- Good: Works in synchronous contexts without
asyncio. - Bad: Thread-per-request model does not scale well for large
num_samples. - Bad: The GIL limits parallelism for CPU-bound work (though LLM calls are I/O-bound).
- Bad: Thread safety for
CostTrackerwould require locking; the module docstring incost.pyexplicitly documents thatCostTrackeris not thread-safe by design and relies on asyncio cooperative scheduling for its budget-check invariant.