Skip to content

ADR-011: Synchronous Runtime Event Loop

Status: Accepted
Date: 2026-06-06

Context

The runtime dispatches events to responders and processes their output sequentially via a deque. An async event loop was considered for allowing concurrent responder execution (e.g., multiple LLM calls in parallel).

Decision

Runtime.run() is synchronous. The event loop is a while queue: event = queue.popleft(); dispatch(event) pattern. All responders run sequentially in registration order. The LLMExecutorResponder blocks on llm_fn(...) before yielding llm.response.

The MCP server wraps Runtime.run() in asyncio.get_running_loop().run_in_executor(None, ...) to avoid blocking the MCP event loop, but the runtime itself remains synchronous.

Consequences

Positive: - Deterministic execution order: same events always produce the same dispatch sequence. - Simple mental model: no locks, no async context, no race conditions within a single run. - Responders are plain synchronous functions — no async def, no await. - The event queue is a single deque — no concurrent modification.

Negative: - Multiple LLM calls within a run execute serially. A run with 3 questions makes 3 sequential LLM calls. Async would allow parallel execution (3x speedup). - llm_fn must be synchronous. Async LLM SDKs require asyncio.run() or a thread bridge. - Long-running llm_fn calls block the entire run. No progress is made on other events while an LLM is being called.

Path to Async (Forward-Looking)

If parallel LLM calls become necessary: 1. Change ResponderFn to AsyncResponderFn = Callable[[KandoEvent, World], AsyncIterator[KandoEvent]]. 2. Change Runtime.run() to async def run() using asyncio.gather() for responders on the same event. 3. Move MCP server back to direct await runtime.run().

This is a breaking change to the Responder interface and is deferred until performance profiling demonstrates the need.