Agora
Concepts

The agent loop

The provider-agnostic agent turn — the model→tools→approval→model state machine, the hooks seam that makes it replay-safe, and the inline vs durable runners that drive it.

At the center of the library is runAgentLoop — one provider-agnostic function that drives a single agent turn. It is the same body whether the turn runs in-process or as a durable workflow. Understanding it is the fastest way to understand everything else.

The turn, step by step

A turn is an iteration, capped at maxSteps (default 8). Each pass asks the model for a reply; if the model requests no tools, the turn is done; otherwise the loop runs each requested tool and feeds the results back to the model for another pass.

Quota check (pre-model). If a quota store is configured, the loop checks the actor's daily budget before the first model call. Over budget → the turn throws QuotaExceededError and no tokens are spent. See Quota & cost.

Persist the user message, load history. The user's text is appended to the thread (stamped with the run that wrote it), then the message history is loaded and mapped into neutral model messages. With a historyWindow configured, only what fits its ceiling rides into the turn — see Bounding the history.

Model turn. The effective system prompt is resolved (agent base prompt, then persona prompt wrapping it), the offered tools are computed for this actor and persona, and model.runTurn({ system, messages, tools, sink }) streams text deltas to the live sink and returns the assembled text plus any requested tool calls and usage.

Account for usage. The turn's token usage (and the provider's real cost when a gateway reports it) is recorded to the ledger, and the daily quota is bumped.

Run the tools. For each requested call the loop branches on the tool's kind:

  • read — recorded as auto_executed, then invoked immediately.
  • action — recorded as pending_approval, then the loop awaits a human decision. On approve it runs; on reject it records the rejection and feeds a rejection result back to the model.
  • agent — delegation. The loop runs another named agent (never a handler) and feeds its answer back. See Personas & agents.

Every tool invocation re-checks the actor's role and re-validates the input against the tool's schema before the handler runs.

When every call in the turn is a read, their invocations run concurrently — the turn costs the slowest call instead of their sum. Only the invocations overlap; recording each call and persisting each result stay sequential in call order. See Concurrent tool calls.

Loop or finish. Tool results are appended as a synthetic message and the loop runs another model pass. When a model turn requests no tools, the loop closes the live stream, derives a thread title on the first turn, and returns the final text.

The hooks seam

runAgentLoop takes injected dependencies (model, store, registry, rolesPolicy, quota?, the resolved systemPrompt, and a pre-computed day) plus a small AgentLoopHooks object. The hooks are the entire difference between running in-process and running durably:

HookInline runnerDurable runner
step(name, fn)calls fn() directlywraps fn in a durable checkpoint (ctx.localStep) so replay returns the cached result
awaitApproval(call, ctx)resolves a pending in-memory promiseawaits a durable signal (ctx.waitForSignal) that survives restarts
awaitAnswers(request, ctx)resolves a pending in-memory promise, on the same keyawaits the same durable signal an approval does
openSink() (delegated run)the ancestor's sink, wrapped so the child cannot end() itthe same, keyed by sinkRunId
openSink()opens the in-process token sinkopens a shared sink keyed by run id
runAgent(name, task)runs a nested in-process loopstarts a durable child workflow (ctx.child)
parallel(tasks)settleAll — nothing records a position, so reads simply overlapsettleAll — every task is launched in one tick, so ctx.localStep pins the block in call order
patched(id)omitted, so every run takes the current shapectx.patched — a run recorded under an older loop shape keeps replaying against it

Both runners ship — the durable one is opt-in via durable: true. See The durable runner.

Why every side-effect goes through step()

Persisting a message, calling the model, invoking a tool, bumping the quota — each is wrapped in hooks.step(name, fn). Inline, that wrapper is a no-op passthrough. Under a durable runner, the same wrapper makes each side-effect a replay-safe checkpoint: on recovery, completed steps return their saved result instead of running again — no double writes, no re-streaming, stable ids. The loop was written once, for both worlds.

Determinism & replay-readiness

The loop body is deliberately deterministic so the durable runner can recover a suspended turn by replaying it. The day (YYYY-MM-DD) is stamped once by the runner and passed in, rather than read inside the loop, so quota-by-day stays stable across a replay. Nothing in the body calls Date.now() or Math.random() directly on a control path — those live in step-wrapped side effects. This is why HITL is modeled as a hook the runner supplies: inline it's a promise, durably it's a signal, and the loop doesn't care which.

The ToolRegistry is held to the same rule. A call's kind is looked up inside its persist:toolcall:<callId> checkpoint and returned from it, so a replay reads the kind out of the journal rather than out of whatever registry the resuming process happens to hold. Delegation's authorization and input-validation gates run in that same checkpoint, for the same reason: their verdict is what decides whether the call is persisted auto_executed or failed.

Why the registry can't be read from the body

The registry is per-process state, and the kind decides control flow — action suspends on an approval signal, everything else records a step. Resolved in the body, a process whose registry lacks the tool reads undefined, falls back to read, and asks for a tool: checkpoint where the history holds the approval signal. That is a non-determinism refusal on resume — and an approval-gated action about to run with nobody's approval.

Concurrent tool calls

A turn is eligible for overlapping only when the journaled kind of every call is read. An action suspends on a human approval — that wait is a decision, not I/O, and reserving an invocation position for a call that may yet be rejected spends a position the rejected branch never fills. An agent delegation is ctx.child, whose parallel form is the runtime's own ctx.all.

What makes the overlap safe is when a checkpoint position is handed out: ctx.localStep takes it on the call, before its first await. So launching every invocation in one synchronous tick — which is exactly what settleAll does — fixes the tool: block in call order however the tools then settle. The parallel hook exists so a runner that assigns positions anywhere else can simply not supply it and stay sequential.

parallel also has to wait for every task to settle rather than fail fast: a durable runner unwinds a turn by throwing, and a sibling abandoned part-way through its own step is a tool nobody ever runs. When one does throw, nothing is persisted for the siblings that finished — their persist:toolexec would land at the position the resume computes for an earlier call's.

Bounding the history

Without a historyWindow, every message the store holds rides every turn: a long-lived thread grows until the provider rejects the request, and pays for the whole transcript until then. SlidingWindowHistory keeps the newest messages that fit a message count, a token budget, or both, and can fold what it left out into a leading system summary via summarizeWithModel(model) — recorded as a summary usage row, so bounding context cost never becomes spend nothing accounts for.

The window's two halves run in deliberately different places. select is a pure function called outside any checkpoint — safe because its input already is one (load:thread's cached result), so a replay reaches the same split. That is what lets a window exist without moving a single position. summarize calls a model, so it runs inside history:summarize: a resumed run reads back the summary the suspended attempt produced instead of prompting with a different one.

Two shapes, one journal

Both of these changed which checkpoints a turn writes, so both are gated on hooks.patchedagent:parallel-tools and agent:history-select. The gate consumes a position for a fresh run and gives it back to a run whose history predates it, so a turn that suspended under the older shape finishes on that shape. A deployment that configures no window never reaches the history gate at all, and a turn with fewer than two calls never reaches the tools one.

The read a turn makes

A turn needs the thread's last few messages, its title, and whether it was ever answered. getThread hands it the transcript instead — every message row, every attachment and every tool output the thread ever recorded — and load:thread then journals what it loaded, so a long thread pays for its whole history on every turn and again on every replay. On a 50-turn thread whose turns each ran a 50 KB tool, that is ~2.6 MB to send four messages, 99% of it tool output no model call would see.

A store may therefore implement ThreadTurnReader.loadThreadForTurn({ threadId, messageLimit }), and LucidAgentStore does: the newest messageLimit rows ordered by the database, projected to the columns a model turn reads. The loop probes for the method structurally, so a store that offers none still answers through getThread, bounded in process — and the payload load:thread records is identical either way, which is what keeps a deployment's choice of store out of a run's checkpoints.

The bound handed to the store is the window's own maxMessages, and two ceilings deliberately name none:

  • A window that summarizes. summarize is handed what select dropped, and a read bounded to what select keeps drops nothing — the turn would fold an empty summary into a prompt missing the messages it stands in for.
  • A ceiling that is not a row count. No row count follows from a token budget: one message can be four tokens or forty thousand. Omitting maxMessages costs only the bound on the read; the prompt is identical.

hasAssistantMessage is answered over the whole thread, never the window — it decides a thread-start intake, and a window holding only the user's last questions belongs to a conversation that has still been answered.

The suspend detail

There is deliberately no try/finally closing the sink around the loop. A durable runner suspends by throwing through the stack at awaitApproval; a finally would then close the live stream on every suspend. The stream is closed only on normal completion — so a suspended-then-resumed run keeps one continuous token stream.

Inline runner (shipped)

InlineAgentRunner is the default (durable: false). start(input) mints a run id and kicks off runAgentLoop in the background, returning immediately so the HTTP handler can begin piping SSE. HITL decisions are delivered by signal(runId, toolCallId, decision), which resolves a pending promise keyed ${runId}:${toolCallId} — so one run's approval can never satisfy another's. A failed run surfaces the error on the live stream and closes it, so a subscriber is never left hanging.

Delegation runs a nested in-process loop on a transient sub-thread, and that nested run parks on a human exactly as a top-level one does — under its own run id, with its frames forwarded into the top-level ancestor's stream so a human can see and therefore answer them. See Answering a sub-agent, which is the same on the durable runner.

The inline runner is single-replica: pending approvals and the token sink live in process memory. Horizontal scaling and crash-survival are the job of the durable runner (and the Redis sink for the stream).

Diagnostics

The loop publishes structural lifecycle events — run started, assistant message, tool call (executed / rejected / failed), delegation, run finished — on the agora:agent:* diagnostics channel. When @adonis-agora/diagnostics is installed they're captured (and can surface in Telescope); when it isn't, they cost nothing. Toggle with emitDiagnostics in the config.

On this page