The Agent Loop
core/run-agent.ts's runAgent() is the actual ReAct loop, driven by
one AgentConfig (see Configure an Agent) — a
single function you can read top to bottom, no chain DSL, no hidden
control flow.
Six provider adapters under core/model-calls/ — anthropic,
openai, deepseek, gemini, glm, kimi — normalize every model to
the same internal ModelContentBlock shape, so tool calls and thinking
blocks look identical regardless of provider. openai/deepseek/
kimi/glm/gemini all reuse the same OpenAI-Chat-Completions-
compatible request translation, just pointed at each provider's own
base URL; anthropic is its own implementation.
What ReAct actually means here
Each turn is one iteration of Reason → Act → Observe, and
runLoop()'s for (;;) is nothing more than that cycle repeated until
one of a few stop conditions:
- Reason — call the model with the full message history, the
system prompt, and every tool schema. Its response — text and any
tool_useblocks — gets pushed as one assistant message, verbatim. - Act — no
tool_useblocks at all means the model reasoned its way to a final answer with nothing left to do:loop:done, turn over. Otherwise, every requested call goes through ActAuth's gate (allow/ask/deny/durably-pending) and, for the allowed ones,ToolLane's scheduler actually runs them. - Observe — every call's outcome (a real result, a denial, a
pending marker) becomes a
tool_resultblock, bundled into one message answering the assistant'stool_usebatch. Loop back to step 1 — the model now sees what actually happened and reasons again from there.
Stops happen at exactly three points instead of that cycle continuing:
a genuine final answer (no more tool_use), a human denial
(loop:denied), or config.maxTurns (default 25) reached without
either.
Durable loop
"Durable" means two specific guarantees, not a vague sense of
reliability: a paused turn survives the process exiting entirely (a
webhook-triggered run nobody's watching, resolved minutes or days
later), and it does so without holding a request, a promise, or a
connection open for the whole wait. Both come from one design choice —
runLoop() keeps no state of its own between calls. Everything it
needs lives somewhere durable instead:
- The
messagesarray is the only state.SessionStore.withSession()loads it, the loop mutates a local copy, and the result is durably appended before returning. Nothing about an in-progress turn exists only in this process's memory. - A paused turn's dangling message is already on disk. When a call
lands in the pending bucket, the loop stops with
stopReason: 'pending_approval'/'pending_question'— the assistant'stool_usemessage was pushed to session history like any other message, so it's already durable the moment this function returns, not something a later resolve call has to reconstruct from scratch. TurnCheckpoint(core/durable-approvals.ts) carries what's still missing. Keyed bypendingId, it recordsresultsSoFar(every call in the batch that already ran for real) and what's stilloutstanding— enough to assemble the complete answeringtool_resultmessage once every outstanding item resolves, whatever the delay.resumeAgent()re-enters the exact samerunLoop(). Not a separate resume path — it's called with the assembled resolution as the starter message, picking thefor (;;)back up exactly where a synchronousaskwould have continued if it hadn't needed to pause. It also can't be told apart from a genuine crash bySessionStoreitself — both leave the identical dangling-tool_useshape on resume, so a plain session load always injects a syntheticCRASH_RECOVERY_CONTINUATIONnote. OnlyresumeAgentknows better, by the fact that it's being called at all with a real resolution in hand — it strips that synthetic message back out before continuing, deliberately reusing crash recovery's own shape rather than adding a second one next to it.
See Human in the Loop for the full design this
enables — notification channels, and how a batch with a mix of
allowed/denied/pending calls actually resolves — and Session
Persistence for SessionStore itself.
Loop events
core/loop-events.ts defines the full typed event stream every turn
emits — the one thing every adapter, the Admin UI, and the browser
client all consume instead of raw provider output. A variant's type
doubles as the wire event name (adapters/http.ts's SSE stream writes
event: <type> verbatim). Two families:
Emitted by runAgent() itself |
When |
|---|---|
budget:check |
Once per turn, before every model call. |
assistant:text |
The model's "I'll do X" preamble alongside a tool call — the only live signal of assistant text mid-turn. |
actauth:decision |
One per requested tool call, once the gate resolves it. |
tool:started / tool:result |
A call is about to run / has resolved — matched by the model's own tool_use id. |
skill:loaded |
A Skill meta-tool call resolved to a real skill body. |
prompt:compaction |
The prompt was rejected as too large and got compacted before retrying. |
loop:done |
A genuine final answer — no more tool_use blocks. |
loop:max_turns / loop:denied |
Stopped without a final answer: turns exhausted, or a human denied a call. |
loop:pending_approval / loop:pending_question |
At least one call is durably pending; any already-approved sibling in the same batch still ran. |
Synthesized by adapters/http.ts around a turn |
When |
|---|---|
session |
First event of every turn — echoes back the session id a caller can resume with later. |
approval:pending / question:pending |
A live (not durable) ask decision or question, spread verbatim from actauth/ask_user's own payload. |
done |
The turn is over, covering a genuine finish and every synthetic stop reason alike — the one event every caller can treat as "nothing more is coming." |
error |
Something failed after the response was already committed — surfaced in-band since it's too late for an HTTP status code. |
See Channels for how this stream actually reaches a caller over each of the three channels, and Protocol for the wire-level SSE framing.