The agent loop
One turn is model → tools → model, bounded by maxSteps, with a usage row appended every time the model is called.
A turn is the unit of work behind one chat message: the model responds, the loop runs whatever tools it asked for, and feeds the results back — repeating until the model stops calling tools or a step ceiling is hit. Everything else in the library (governance, cost, HITL) hangs off this one iteration.
model → tools → model
Each pass through the loop is one step:
- The model is called with the system prompt, the thread's messages so far, and the tools the actor
is allowed to see. This is one
ModelProvider.runTurncall. - A usage row is appended — unconditionally, whether or not the model asked for a tool.
- If the model returned zero tool calls, the loop breaks — the turn is done.
- Otherwise, each requested tool call is resolved by its
kind, and the results are fed back to the model as the next step's input.
Same loop, whether or not a step is dispatched
This is the loop from its own point of view: step 1 (the model call) and step 4's tool executions
happen exactly as described whether they run in-process or — under the durable runner's default
dispatchedSteps: true — as routed steps that can execute on a different worker than the one
running the turn. The loop's hooks (step, dispatchLlm, dispatchTool) are what change
underneath it; the model → tools → model shape above doesn't. See
Architecture for what changes cross-process when a step is
dispatched, and Runners for the dispatchedSteps flag itself.
read vs action
A tool call's kind decides whether it runs unattended or waits for a human — see
Tools for the full decorator surface:
kind | What happens | Use for |
|---|---|---|
read | Auto-executes immediately, same step | Lookups, queries — anything side-effect-free |
action | Loop suspends for an explicit approve/reject before execute runs | Anything that mutates, spends, sends, or deletes |
agent | Synthesized, never hand-authored — delegates to a named sub-agent | Multi-agent orchestration |
A rejected action call doesn't throw — its result ({ rejected: true, reason }) is fed back to the
model like any other tool result, so the model can adapt within the same turn instead of failing it.
Bounded by maxSteps
The loop is a plain bounded iteration, not a "runs until done" open loop:
for (let i = 0; i < maxSteps; i += 1) {
// one model call, then its tool calls
}maxSteps comes from the agent that runs the turn — set it per agent with @Agent({ maxSteps }) —
and defaults to 8 when omitted. It exists to stop a
model that keeps calling tools from running away with an unbounded turn — hitting the ceiling ends
the turn with whatever text the last step produced.
Every turn appends a usage row
Step 2 above isn't conditional on tool calls: every model call writes a row to the usage ledger, with the actor, thread, model id, and token counts — the raw material the cost and quota machinery rolls up. A turn with three internal steps writes three usage rows, not one.
Optional: follow-up suggestions after the loop ends
With AgentModule.forRoot({ followUps: true }), once the loop breaks (step 3 above) the turn makes
one more model call — outside the maxSteps-bounded loop — asking for short follow-up questions.
The result lands on the assistant message's followUps field and writes its own usage row
(purpose: 'follow_ups'). It's off by default: the loop's job is answering the turn; follow-ups are
an opt-in, separately-costed extra. See Cost & Governance.
Where to go next
Architecture
The library is mechanism — the loop, tool registry, RBAC, quota, cost, audit, HITL, streaming; your domain is policy you supply through a fixed set of SPIs.
Runners
One AgentRunner SPI, two implementations — inline runs in-process by default, durable is an opt-in that checkpoints every step and turns HITL approval into a real suspend.