The Runtime (L1)
What this is
The kernel page on runtime contracts describes a shape
— a handful of pure Protocols (EventLogProtocol, Journal, InboxProtocol, SchedulerProtocol,
SupervisorProtocol, SignalBusProtocol, FollowGraph, FanoutStrategy) with no I/O, no
sockets, no database. This page describes the first real machine that fills in
that shape.
It is the Stage-0, in-process implementation: everything runs inside one
Python process, on one asyncio event loop, with no serialization — a leased
run is just an asyncio.Task that stays alive in memory. There is no Postgres,
no Redis, no network. You can pip-free, docker-free run a durable agent loop
on your laptop with nothing but python.
Durability (concept) tells the story — what a crash looks like, how replay avoids re-charging the card. The kernel runtime contracts are the interfaces that make the story possible. This page is the working code that drives them in development. Read those two first for the "why" and the "what, precisely" — here we cross-link heavily and try not to repeat.
The one idea that makes it all worth it
The agent author writes async def run(self, ctx, inbox) once. Every
ctx.llm(), ctx.tool(), ctx.spawn() call is journaled under the hood. That
same agent code runs unchanged against the Postgres/Redis backends in
infrastructure/runtime/ — you swap the backends,
not the agent. Dev and production are literally the same call sites.
# Dev (Stage 0) — everything in-process, zero infra
async with Runtime() as rt:
await rt.register(my_agent)
run_id = await rt.submit(my_agent.id, boot_msg)
# Production (Stage 1) — durable backends injected by the infra factory
from substrate.infrastructure.runtime import build_postgres_runtime
async with build_postgres_runtime(postgres_url=..., redis_url=...) as rt:
await rt.register(my_agent)
run_id = await rt.submit(my_agent.id, boot_msg) # identicalHere is the cast this page covers, each with a one-line analogy.
| Piece | One-liner | Analogy |
|---|---|---|
Runtime | Facade that wires backends + the Worker, and the public API | the building manager who hooks up power, mail, and staff |
Worker | The run loop: lease → run → file paperwork → release | a driver who leases a job, does it, files the paperwork |
RunContext | The journaled ctx an agent receives for one run | the agent's toolkit + notebook for this one job |
| in-memory backends | Single-process stand-ins for the eight kernel Protocols | a single office standing in for the citywide postal system |
The Runtime facade
What & why: Runtime is one object you create, hand a few agents, and submit
messages to. It owns the eight backends (defaulting each to its in-memory
implementation), wires the inbox wakeup hook, and starts a single Worker. Think
of it as the building manager: it connects the power, hangs the mailboxes,
hires the one driver, and gives you a front desk to drop off work.
It lives at agents/runtime/runtime.py and is exported as Runtime.
Construction — inject or default
Every backend is an optional constructor argument. Pass nothing and you get the
full in-memory stack. Pass durable ones and the same Runtime runs against
Postgres/Redis — this is exactly what build_postgres_runtime does.
class Runtime:
def __init__(
self,
*,
event_log: EventLogProtocol | None = None,
inbox: InboxProtocol | None = None,
journal: Journal | None = None,
scheduler: SchedulerProtocol | None = None,
signal_bus: SignalBusProtocol | None = None,
follow_graph: FollowGraph | None = None,
fanout: FanoutStrategy | None = None,
) -> None:
self._event_log = event_log or InMemoryEventLog()
self._journal = journal or InMemoryJournal()
self._scheduler = scheduler or InMemoryScheduler()
self._inbox = inbox or InMemoryInbox()
self._inbox.set_deliver_hook(self._on_inbox_deliver) # the wakeup wire
...Notice it only ever names InMemory* classes — it never imports
EventLog or RedisJournal. That keeps the agents layer (L1)
strictly above infrastructure. The durable backends are injected from
the outside by the infra-layer factory, so the dependency rule
(agents may not reach sideways into infrastructure) is never violated.
The public API
Four verbs, plus lifecycle. All return fast — work happens on the Worker.
| Method | What it does |
|---|---|
register(agent) | Add an agent to the registry so the Worker can dispatch runs to it |
submit(agent_id, msg, *, priority, tenant, max_retries) | Deliver msg and enqueue a fresh run — returns the new RunId |
follow(follower, topic_type, topic_source) | Subscribe an agent to a topic (newsletter sign-up) |
publish(topic_type, topic_source, msg) | Fan msg out to every follower of a topic |
start() / stop() / cancel(run_id) | Lifecycle — also exposed via async with |
submit and the duplicate-run trap
submit does the delivery and the enqueue itself. The subtle bit is
notify=False:
async def submit(self, agent_id, msg, *, priority=5, tenant="default", max_retries=3):
run_id = new_run_id()
self._scheduler.register_run(run_id, agent_id)
# notify=False: submit() enqueues its OWN run below. If the deliver-hook
# also fired, it would find no active run yet and spawn a DUPLICATE.
await self._inbox.deliver(agent_id, msg, notify=False)
await self._scheduler.enqueue(run_id, priority=priority, tenant=tenant,
retry_policy=RunRetryPolicy(max_retries=max_retries))
return run_idThe deliver-hook exists for unsolicited deliveries — a publish fan-out, an
inter-agent ask, a child's boot message — that arrive with no accompanying
submit. When submit is the one delivering, it suppresses the hook so the
explicit enqueue is the only run created. Forgetting this spawns two runs
for one message.
The inbox deliver-hook: wake vs spawn
When a message lands in a dormant agent's mailbox via the normal path
(notify=True), the Inbox calls a sync hook, which schedules
_handle_inbox_delivery. That method makes the central wake-vs-spawn
decision using scheduler.find_run_for_agent() — the same API on both the
in-memory and Postgres schedulers, so no private attributes are touched.
async def _handle_inbox_delivery(self, agent_id: AgentId) -> None:
result = await self._scheduler.find_run_for_agent(agent_id)
if result is None:
await self._spawn_run_for_inbox(agent_id) # no active run -> spawn fresh
return
run_id, status = result
if status == RunStatus.SUSPENDED:
await self._scheduler.wake_suspended(run_id) # dormant run -> wake it
# PENDING / RUNNING -> no-op: the active run will drain this message itselfStart / stop as a context manager
start() constructs the InMemorySupervisor (it needs references to the
EventLogProtocol, InboxProtocol, Journal, and SchedulerProtocol) and the single Worker, then starts the
Worker's poll loop. async with calls start/stop for you.
async with Runtime() as rt: # -> start() : builds SupervisorProtocol + Worker, starts poll loop
await rt.register(agent)
await rt.submit(agent.id, msg)
... # Worker drives runs in the background
# -> stop() : cancels the poll loop and all in-flight agent tasksThe Worker — the run loop
What & why: The Worker is the engine that turns a queued run into a running
agent. It polls the Scheduler for leased runs, builds a RunContext, drains the
inbox, calls agent.run(...), and — win, lose, or cancel — files the paperwork
(append a terminal log entry, ack/nack messages, release the lease).
It lives at agents/runtime/worker.py.
A dispatcher hands the driver a job slip (the lease). The driver does the work, radios in periodically so the dispatch doesn't reassign the job (the heartbeat), and at the end files the trip report (the terminal log entry) and hands the slip back (the release). The driver never becomes the job — each job is its own asyncio Task.
The poll loop
_poll_loop runs every POLL_INTERVAL (0.05 s). Each tick it leases up to
capacity=10 runs and launches each as its own asyncio.Task. Many agents run
concurrently because each is an independent task; the lease capacity bounds how
many start per tick.
async def _poll_loop(self) -> None:
while self._running:
leases = await self._scheduler.lease(worker_id=self._worker_id, capacity=10)
for lease in leases:
agent = self._registry.get(lease.agent_id)
if agent is None:
continue # not registered yet — hold the lease, it expires + retries
task = asyncio.create_task(self._run_agent(lease, agent))
self._tasks[lease.run_id] = task
await asyncio.sleep(self.POLL_INTERVAL)One run, start to finish (_run_agent)
This is the heart of the page. For one leased run, the Worker:
- Builds a fresh
RunContext— wiring in all backends plus the agent's LLM client and aToolInvokerbuilt from the agent's declared tools (_build_tool_invoker). - Appends
run.startedto the EventLog iflast_seq < 0(fresh run). - Drains the inbox for this agent (up to 100 messages).
- Dispatches lifecycle hooks (
RUN_START) and runs through the agent's middleware pipeline if present, otherwise callsagent.rundirectly. - Heartbeats in the background every 15 s so a long LLM call never loses a
Postgres lease (the in-memory scheduler's
heartbeatis a no-op). - On success —
ackevery drained message, optionally clear run-scoped history (_maybe_clear_run_history), appendrun.completed,releasewithCOMPLETED, and record completion with the Supervisor (this is what wakes a parent waiting injoin). - On cancel (
asyncio.CancelledError/CancellationError) — appendrun.cancelled, releaseCANCELLED. - On any other exception — branch: a guardrail stop (
MiddlewareTermination) or budget stop (BudgetExhaustedError) is expected, so itacks the messages and logsrun.failedwith a friendly status; a realAgentCrashErrornacks the messages (so the run can be retried) and logs the crash.
async def _run_agent(self, lease, agent):
ctx = RunContext(meta=meta, event_log=..., journal=..., inbox=..., ...,
llm_client=getattr(agent, "model", None),
tool_invoker=self._build_tool_invoker(agent), agent=agent)
if (await self._event_log.last_seq(run_id)) < 0:
await self._event_log.append(run_id, RunLogEntry(..., kind="run.started"),
expected_seq=-1)
inbox_msgs = await self._inbox.drain(agent.id, max=100)
heartbeat_task = asyncio.create_task(_heartbeat()) # renews lease every 15s
try:
if middleware is not None:
await middleware.execute(ctx, lambda c: agent.run(c, inbox_msgs))
else:
await agent.run(ctx, inbox_msgs)
for msg in inbox_msgs:
await self._inbox.ack(agent.id, msg.id)
await self._event_log.append(run_id, RunLogEntry(..., kind="run.completed"), ...)
await self._scheduler.release(lease, status=RunStatus.COMPLETED)
await self._supervisor.record_completion(run_id, RunStatus.COMPLETED)
except (asyncio.CancelledError, CancellationError):
... # append run.cancelled, release CANCELLED
except Exception as exc:
... # guardrail/budget -> ack + run.failed ; crash -> nack + run.failed
finally:
heartbeat_task.cancel()
... # dispatch RUN_END hook, drop run from _tasks / _tokenscancel(run_id) marks the run CANCELLED in the scheduler, cancels its
asyncio Task, and trips its CancellationToken so an agent inside a long loop
bails at its next ctx.check(). If no task exists yet, it appends
run.cancelled to the log directly and records completion — so a cancel never
leaves a run dangling.
RunContext — the agent's toolkit and notebook
What & why: RunContext is the concrete ctx passed to agent.run(ctx, inbox). It satisfies the kernel's minimal AgentRunContext Protocol but offers
the full rich surface an agent author actually uses: ctx.llm(), ctx.tool(),
ctx.spawn(), ctx.join(), ctx.ask(), ctx.send(), ctx.emit(),
ctx.check(), and more. Every effectful call is journaled, so a replay never
re-bills the model or re-sends an email.
It lives at agents/runtime/context/ (a small package: journal.py, llm.py,
tool.py, messaging.py, supervision.py — not a single file).
ctx is a fresh toolkit handed to the agent for this one job: a phone to
call the model (llm), a set of wrenches (tool), a way to hire helpers
(spawn / join), and a notebook (the EventLog + Journal) where every action
is logged. If the job restarts, the agent re-reads the notebook instead of
re-doing the expensive work.
Hierarchical effect paths and the EffectCache
At-most-once used to be keyed by a flat _step_seq counter looked up in a
separate Journal backend. That's been replaced: the context now keeps a
scope stack (_path_stack) rather than a flat counter, and effect
lookups/records go through an EffectCache (agents/runtime/effect_cache.py)
folded straight from the EventLog's own effect.result entries — not a
separate Journal store. _alloc_path() allocates a hierarchical path
("0", "0.1", "1", …) so a tool's own internal journaled calls (e.g. a
nested ctx.uuid()) get stable paths of their own without desyncing sibling
effect ids, even when the outer call itself replays as a cache hit. The
generic helper is _journaled():
async def _journaled(self, kind, args, fn):
path = self._alloc_path()
effect_id = Effect.make_id(self.run_id, path, kind, args)
cached = self._lookup_effect(effect_id) # EffectCache — pure in-memory, no I/O
if cached:
if cached.status == "error":
raise RuntimeError((await self._resolve_effect_value(cached)).get("error", "journaled error"))
return await self._resolve_effect_value(cached) # HIT — do NOT re-run
self._enter_scope() # open a child path scope for fn()'s own calls
try:
result = await fn() # MISS — run the real effect
await self._record_effect(effect_id, "ok", result or {})
return result
except Exception as exc:
await self._record_effect(effect_id, "error", {"error": str(exc)})
raise
finally:
self._exit_scope()This is the same lookup -> execute -> record dance described in
Durability, just backed
by the EffectCache/EventLogProtocol rather than a Journal. ctx.llm() and ctx.tool()
inline the same pattern (so they can stream and serialize their own results)
rather than calling _journaled directly, but the contract is identical.
Values over 64KB are transparently offloaded to BlobStore and referenced by
artifact_ref, dereferenced lazily by _resolve_effect_value() only on a
genuine cross-process replay hit.
EffectCache.fold(event_log, run_id) reconstructs the whole cache from
the EventLog's effect.result entries once, when a run is leased (see
Worker._run_agent) — not on every _journaled() call. A crash mid-run
means the next process folds fresh from the same durable log and gets
the identical cache a live process would have had.
The capability surface
ctx call | What it does | Journaled? |
|---|---|---|
ctx.llm(messages, *, options) | Stream a model response, emit text.delta log entries, return LLMResponse | yes — replay never re-bills |
ctx.tool(name, **args) | Invoke a tool via the ToolInvoker, return InvocationResult | yes — at-most-once side effect |
ctx.spawn(child_agent, *, boot, supervision) | Spawn a child run, return a RunHandle (does not wait) | yes (in SupervisorProtocol) |
ctx.join(handle) | Suspend until the child reaches a terminal state, return its RunResult | suspend point |
ctx.cancel(handle, *, reason) | Cancel a child run and its whole subtree | — |
ctx.ask(target, msg, *, timeout) | Send and suspend until a reply / timeout / target failure, return AskOutcome | suspend point |
ctx.reply(to, result) | Signal the asker's run to complete its ask | — |
ctx.send(target, msg) | Fire-and-forget delivery + wake the target | — |
ctx.emit(topic, msg) | Publish to all followers of a topic | — |
ctx.follow(topic) / ctx.unfollow(topic) | Subscribe / unsubscribe this run | — |
ctx.status(handle) | One-shot peek at a run's progress (RunStatusSummary) — not a stream | — |
ctx.sleep_until_signal(name) / ctx.sleep_until(dt) | Suspend until a named signal / wall-clock time | suspend point |
ctx.now() / ctx.random() / ctx.uuid() | Journaled non-determinism — use these, not the stdlib equivalents | yes — replay-safe |
ctx.check() | Cooperative cancellation point — raises if cancelled / deadline exceeded | — |
A plain datetime.now() or uuid.uuid4() produces a different value on
replay, which corrupts the deterministic fold. The journaled helpers record
the first value and return it on every replay — your code becomes replay-safe
for free.
How ask and sleep actually suspend (Stage 0)
In Stage 0 the coroutine is never serialized — it stays alive as an asyncio
Task. So ctx.ask() and ctx.sleep_until_signal() suspend by awaiting an
asyncio.Event inside the InMemorySignalBus, the lightweight in-process
stand-in for the durable Wakeup mechanism. ask sets reply_to and
correlation_id, delivers to the target, then waits on a reply:{id} signal:
async def ask(self, target, msg, *, timeout, idempotency_key=None):
enriched = msg.model_copy(update={"reply_to": self.run_id, "correlation_id": cid})
await self._inbox.deliver(target_agent, enriched)
await self._scheduler.wake_agent(target_agent) # or wake_suspended(run)
try:
payload = await self._signal_bus.wait_for_signal(self.run_id, f"reply:{cid}",
timeout=timeout)
return AskOutcome(kind="replied", result=..., last_seq=0)
except asyncio.TimeoutError:
# peek target status -> timed_out vs target_failed vs target_cancelled
return AskOutcome(kind=kind, handle=handle, last_seq=last_seq)In production the suspended run is dropped from RAM entirely and resumed from
the EventLog when a real Wakeup arrives — so a three-hour wait costs nothing
(see SUSPENDED is the superpower).
The agent author's await ctx.ask(...) line is byte-for-byte unchanged.
The in-memory backends
Each backend is a single-process, single-event-loop implementation of one kernel
Protocol. None is crash-durable — that is the whole point of Stage 0: zero infra,
fast tests, identical surface. They all live in agents/runtime/backends/.
Each in-memory backend is a single office doing by hand what the production backend does at city scale. The office's front counter (the Protocol methods) is identical to the post office's, so when you swap in the real thing nobody has to learn a new counter.
| In-memory backend | Implements kernel Protocol | Production counterpart (infrastructure/runtime/) |
|---|---|---|
InMemoryEventLog | EventLogProtocol | EventLog (append-only table, (run_id, seq) PK) |
InMemoryInbox | InboxProtocol | Inbox (durable queue + dead-letter) |
InMemoryScheduler | SchedulerProtocol | Scheduler (SELECT … FOR UPDATE SKIP LOCKED + leases) |
InMemorySupervisor | SupervisorProtocol | Supervisor (ravi_run_tree + ravi_spawn_effects tables — shipped) |
InMemorySignalBus | SignalBusProtocol | SignalBus (ravi_signals table, exactly-once consume-based fencing — shipped) |
PushAllFanout | FanoutStrategy | (still in-memory — a push/pull hybrid for celebrity agents is unbuilt) |
InMemoryFollowGraph | FollowGraph | (still in-memory) |
Journal/InMemoryJournal (kernel/runtime/effects.py,
agents/runtime/backends/_journal.py) are still real and still used — as
Runtime's default and by InMemorySupervisor for spawn-id dedup. What
changed is RunContext's own at-most-once mechanism: it no longer talks
to a Journal at all. Effect-result durability (LLM/tool call dedup) now
comes from the EventLog itself (effect.result entries, folded into an
EffectCache per lease; see agents/runtime/effect_cache.py and the
"Hierarchical effect paths and the EffectCache" section above). The
production path drops Journal entirely: Supervisor uses its own
ravi_spawn_effects table for spawn dedup instead of a Journal
implementation, and there is no RedisJournal anymore — closing a real
gap the old TTL'd Redis store had (a run suspended past the TTL used to
come back to a journal miss on every effect, re-billing LLM calls,
re-running tools; the EventLog never expires).
infrastructure/runtime/factory.py::build_postgres_runtime injects
EventLog, Inbox, Scheduler,
SignalBus, and Supervisor — the full coordination core
is durable today, not just the four originally hardened. PushAllFanout
and InMemoryFollowGraph are still in-memory; because everything is
behind a Protocol, hardening those two is a drop-in swap later if a
single-process fanout/follow-graph ever becomes a bottleneck.
A one-line tour:
InMemoryEventLog— adict[run_id, list[RunLogEntry]].appendenforces optimistic concurrency (raisesConcurrentAppendErrorwhenexpected_seqdoesn't match the real tail),readyields a finite slice,tailyields then blocks on anasyncio.Eventfor new entries (this powers live SSE streaming).InMemoryInbox— per-agent message store with dedup byMessage.id, per-sender FIFO ordering, and a retry counter that dead-letters aftermax_retriesnacks. Holds theon_deliverwakeup hook the Runtime wires in.InMemoryScheduler— anasyncio.PriorityQueue(lower number = higher priority) plus status/lease/agent maps.enqueuecoalesces (a run already pending is not added twice),leasehands out leases with a 30 s expiry,heartbeatis a no-op (single process), andreleasere-enqueuesFAILEDruns per theirRunRetryPolicy. It ownsfind_run_for_agent— the wake-vs-spawn decider.InMemorySupervisor—spawnjournals the child run id via theEffectCache(so replay returns the same child and never duplicates it), delivers the boot message withnotify=False, and logschild.spawnedin the parent's EventLog.joinsuspends on a consume-basedchild:{run_id}signal (viaSignalBusProtocol) rather than blocking on anasyncio.Event;cancelrecurses through the subtree.InMemorySignalBus—run_id -> name -> (asyncio.Event, payload_box).signalfired before a run waits is buffered (delivered eagerly), so a signal is never lost.timerschedules anasyncio.sleepthat fires the__timer__signal. Exposes the non-Protocolwait_for_signalhelper used byaskandsleep_until_signal.PushAllFanout—publishsimplyasync for follower in graph.followers_of(topic): inbox.deliver(follower, msg). Fine for normal agents; a pull model for celebrity agents is unbuilt.InMemoryFollowGraph— twodefaultdicts (followers_ofandfollowing) keyed by"{type}/{source}";followis idempotent (a set),unfollowis safe on an absent subscription.
Where this lives
| Piece | Location |
|---|---|
Runtime facade (takes an optional supervisor param too) | agents/runtime/runtime.py |
Worker run loop | agents/runtime/worker.py |
RunContext (the journaled ctx) | agents/runtime/context/ (package: journal.py, llm.py, tool.py, messaging.py, supervision.py) |
EffectCache (replaces Journal for RunContext's own effect dedup) | agents/runtime/effect_cache.py |
Concrete CancellationToken | agents/runtime/cancellation.py |
InMemoryEventLog | agents/runtime/backends/_event_log.py |
InMemoryJournal (still used by InMemorySupervisor's spawn dedup, not by RunContext) | agents/runtime/backends/_journal.py |
InMemoryInbox | agents/runtime/backends/_inbox.py |
InMemoryScheduler | agents/runtime/backends/_scheduler.py |
InMemorySupervisor | agents/runtime/backends/_supervisor.py |
InMemorySignalBus | agents/runtime/backends/_signal_bus.py |
PushAllFanout | agents/runtime/backends/_fanout.py |
InMemoryFollowGraph | agents/runtime/backends/_follow_graph.py |
| The kernel Protocols these implement | kernel/runtime/ (contracts page) |
EventLog/InboxProtocol/SchedulerProtocol/SignalBusProtocol/SupervisorProtocol + build_postgres_runtime factory | infrastructure/runtime/ |
Next: Context, Compaction & Memory Backends — how an agent's conversation history is stored, trimmed, and summarised so a long run fits in the model's context window.