Agora
Durability

The durable runner

Run each turn as a replay-safe @adonis-agora/durable workflow — memoized LLM/tool steps, HITL approval that suspends on a signal and survives restarts, and delegation as a tracked child workflow. One config flag.

The agent loop was written against a hooks seam precisely so the same loop body can run in-process or as a durable workflow. The durable runner is now shipped: flip one flag and each turn becomes a replay-safe @adonis-agora/durable workflow — LLM turns and tool executions become memoized steps, a HITL approval suspends the run on a signal (resuming across a restart), and sub-agent delegation becomes a tracked child run.

Turn it on

config/agent.ts
export default defineConfig({
  model: () => aiSdkModel(openai('gpt-4o-mini')),
  store: 'lucid',
  stores: { lucid: stores.lucid() },
  actorResolver: new AuthActorResolver(),
  durable: true,
})

durable: true requires @adonis-agora/durable installed and configured (config/durable.ts). The provider resolves the durable WorkflowEngine from the container, registers the agent workflow on it, and swaps InlineAgentRunner for DurableAgentRunner.

Setting it is always safe

If the durable peer can't be wired — not installed, engine unresolvable, any wiring error — the provider logs a warning and falls back to the in-process (inline) runner rather than breaking boot. So durable: true never takes your app down; worst case it runs inline.

What each hook maps to

The runner supplies the loop's hooks; the durable versions map onto durable primitives:

HookInline runnerDurable runner
step(name, fn)calls fn() directlyctx.localStep(name, fn) — every LLM turn, tool execution, and persist/quota write is a memoized checkpoint; replay returns the saved result
awaitApproval(call)resolves an in-memory promisectx.waitForSignal('tool:<runId>:<callId>') — the run suspends on approval and resumes on the signal, surviving restarts
awaitAnswers(request)resolves an in-memory promise, on the same keythe same signal an approval waits on, under the question set's own id
openSink() (delegated run)the ancestor's sink via childSinkWriter, so the child cannot end() itthe same, keyed by sinkRunId
runAgent(name, task)nested in-process loopctx.child(AgentRunWorkflow, …) — delegation is a tracked child run streaming into the parent's sink
openSink()the in-process sinkthe run's own sink (top-level) or a child writer that forwards into the parent's stream
parallel(tasks)settleAllsettleAll — the same helper, because ctx.localStep takes its position on the call, before its first await: a batch launched in one tick occupies its positions in call order however the work settles
patched(id)omitted → every run takes the current shapectx.patched(id) — consumes a position for a fresh run, gives it back to one whose history predates the marker

Because every side effect goes through step, a recovered run returns completed steps' saved results instead of re-running them — no double writes, no re-streaming, stable ids. The day is stamped once by the runner so quota-by-day stays stable across a replay.

HITL that survives a deploy

Under the inline runner an action-tool approval resolves a promise in process memory — so it's single-replica and dies with the process. Under the durable runner the same approval is a durable signal namespaced tool:<runId>:<toolCallId>, delivered via runner.signal(...) (the POST /agent/tool-call/approve route). The run can wait minutes or a month across restarts and deploys, then resume exactly where it paused. One run's approval can never cross-resolve another's.

Suspend is a throw, not a return

The durable runner suspends by throwing a control-flow signal (WorkflowSuspended / ContinueAsNew) up through awaitApproval or ctx.child. The loop deliberately has no try/finally closing the sink, so a suspend doesn't cut the live token stream — an approved-then-resumed run keeps one continuous SSE stream. DurableAgentRunner.start returns the run id immediately even when the run suspends synchronously on its first step.

Answering a sub-agent

A sub-agent's HITL wait is a real wait, on its own run: tool:<childRunId>:<callId>. What used to be missing was not the path back — it was the id. A human is watching the top-level stream, because that is the only stream anyone subscribes to; the child forwards its frames there (that is what sinkRunId is for), and a form with no run id attached is a form the watcher can see and cannot answer.

So the id rides on the frame. Both parked-work frames carry it:

SSE eventPayloadAnswer with
event: approval{runId, id, toolName, input}POST /agent/tool-call/approve | /reject
event: elicitation{runId, id, request}POST /agent/tool-call/answer | /skip

runId is the run that is parked, not the stream it arrived on. For a top-level run the two are the same; for a delegated one they are not, and reading the run off the meta frame instead would signal the ancestor and leave the child suspended. decodeFrame in @adonis-agora/agent/client surfaces both as { type: 'approval' | 'elicitation', runId, toolCallId, … }, and drops a frame whose runId is missing rather than handing on one you cannot act on.

The approval frame is written from inside the persist:toolcall:<callId> checkpoint, so it spends no position of its own and a replay — which returns that checkpoint memoized — never re-posts a form for a decision already made. An ask gets the elicitation frame only; two frames for one parked call would be two forms.

The ownership check is unchanged: a child run's agent_run row records the same actor as its ancestor, so runOwner(childRunId) passes for the person who started the turn.

Both runners behave this way — the inline runner's nested loop parks on ${childRunId}:${toolCallId} and forwards into the ancestor's sink, mirroring sinkRunId. Which means a sub-agent whose approvals nobody will answer hangs, exactly as a top-level agent does: if a job or an unattended run delegates, keep human-approval tools off the sub-agent (tools: on its definition).

What a replay is allowed to read

A replay must reach the same checkpoint names in the same order as the run's history, so anything that steers the loop has to come from the journal, not from the process doing the replaying. The ToolRegistry is per-process state, so a call's kind is resolved inside persist:toolcall:<callId> and returned from that checkpoint: a resuming process reads the recorded kind instead of looking the tool up itself. A process that never declared the tool therefore still replays an action onto its approval signal, rather than reading it as a read and running it unapproved.

That lookup cannot live in a checkpoint of its own — it has to be this one, at this position, or a run already in flight would diverge on resume. A checkpoint written before the kind was returned carries nothing back, and only those runs consult the local registry.

The same rule governs the two places where the loop's shape changed. Batching a turn's tool calls hoists its persist:toolcall checkpoints ahead of the first execution, and bounding the history moved selection out of the history:window checkpoint — so each is gated on ctx.patched (agent:parallel-tools, agent:history-select) rather than on which version of the code happens to be replaying. A run recorded under the older shape reads false, gets its position back, and finishes on the shape its own journal holds. Once every such run has drained, the guard can go.

A refusal must not be answered with more checkpoints

When the engine refuses a position (NonDeterminismError, or WorkflowNondeterminismError from the remote replay context), the loop's tool catch and the workflow's outer catch re-throw it untouched instead of writing persist:toolfail / persist:run:fail. On a journal that has already diverged, each of those asks for a position the history cannot supply — so the recovery attempt raises its own refusal, and that second one is what the operator ends up reading: the wrong seq, and two checkpoint names neither of which is the disagreement. The workflow still settles the live stream, so a subscriber does not hang on a run the engine is about to fail.

isReplayIntegrityError(error) is exported from @adonis-agora/agent if you wrap the runner and need the same rule. It matches by class name, since the in-process engine and the remote replay context spell theirs differently.

Run lifecycle & cancellation

Each run (parent and every child) records its own agent_run row with a durable: true flag, tracked through running → completed | failed | cancelled. cancel(runId) cascades cancellation to children and settles the run row cancelled (first-terminal: a run that already completed stays put). This is the same lifecycle the governance read-model surfaces.

Advanced wiring

@adonis-agora/agent/durable exports the pieces the provider uses, for a custom host:

import {
  DurableAgentRunner,
  AgentRunWorkflow,
  registerAgentWorkflow,
  setDurableAgentContext,
} from '@adonis-agora/agent/durable'

setDurableAgentContext({ factory, store })
registerAgentWorkflow(engine)
const runner = new DurableAgentRunner(engine, store)

The SSE contract, the routes, and the governance surfaces are all unchanged — the durable runner is a drop-in behind the same AgentService.

On this page