LoopEngineBETA

Docs

Documentation

How LoopEngine works, from scaffold to durable approvals — agents, adapters, permission rules, and abilities.

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:

  1. Reason — call the model with the full message history, the system prompt, and every tool schema. Its response — text and any tool_use blocks — gets pushed as one assistant message, verbatim.
  2. Act — no tool_use blocks 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.
  3. Observe — every call's outcome (a real result, a denial, a pending marker) becomes a tool_result block, bundled into one message answering the assistant's tool_use batch. 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 messages array 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's tool_use message 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 by pendingId, it records resultsSoFar (every call in the batch that already ran for real) and what's still outstanding — enough to assemble the complete answering tool_result message once every outstanding item resolves, whatever the delay.
  • resumeAgent() re-enters the exact same runLoop(). Not a separate resume path — it's called with the assembled resolution as the starter message, picking the for (;;) back up exactly where a synchronous ask would have continued if it hadn't needed to pause. It also can't be told apart from a genuine crash by SessionStore itself — both leave the identical dangling-tool_use shape on resume, so a plain session load always injects a synthetic CRASH_RECOVERY_CONTINUATION note. Only resumeAgent knows 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.