Skip to content

The agent run loop

The agent (@grundlag/agent) is one of the platform’s consumers: it runs a language model against a set of actions and lets the model call them as tools. This page describes the loop it runs, how actions become tools, and why the entire state of a run lives on the Prompt.

A run is turn-based. Each turn is one model completion; the run keeps taking turns until the model has nothing more to ask for.

  1. Assemble the context. The system prompt plus the conversation (prompts) are turned into chat messages. Each prompt’s user content becomes a user message; each prior output becomes an assistant message, a tool call with its result, or a rendered item.
  2. Take a model turn. The run calls client.chat.completions.create with stream: true and the available tools. Text arrives incrementally and is emitted as output:delta events while being accumulated into a text output.
  3. Execute the tool calls. If the model requested tools, the run executes each one by calling the matching action’s execute, then records the return value (or error) on the tool call’s output entry.
  4. Loop. The recorded results are now part of the conversation, so the run takes another turn. The model sees what the tools returned and decides what to do next.
  5. Complete. When a turn requests no tools, the loop ends. The run emits completed and resolves with status: 'completed'.

Steps 2–4 repeat as many times as the model needs. A simple question resolves in one turn; a task that gathers data through several tools takes several.

The agent does not define a separate notion of a tool. It takes the same core Action objects every consumer uses and adapts them for the model. For each entry in the actions map it converts the action’s Zod input schema to a JSON Schema with z.toJSONSchema, and emits an OpenAI function tool whose name is the map key, whose description is the action’s description, and whose parameters are that schema:

{
type: 'function',
function: {
name, // the actions-map key
description: action.description,
parameters, // z.toJSONSchema(action.input)
},
}

When the model calls a tool, the run looks the name up in the actions map and invokes that action’s execute — the identical execute a provider wrote once and everything else consumes. This is why an action written for the HTTP API works unchanged as an agent tool: the agent is just another caller. See Architecture and Define actions.

The run holds almost no state of its own. Everything it produces — streamed text, tool calls with their inputs and results, emitted items, and any interrupts — is appended to the output array of the active Prompt. The transcript is the state.

This has a deliberate consequence: a run is reconstructable. Because run.state is derived entirely from the prompt, a brand-new AgentRun built over the same (persisted) prompts reports the same position the original did — ready, or interrupted on a specific tool call. You can persist the prompts, restart the process, rebuild the run, and continue exactly where you left off, without the runtime having kept anything in memory. This is what makes streaming (react as it happens) and persistence (reconstruct later) two views of the same data rather than separate mechanisms. See Stream output and react to events.

Tool execution in step 3 is where a run can pause. If an action throws ActionInterrupt instead of returning, the run stops looping and unwinds: it records the interrupt on the tool call, emits interrupted, and resolves with status: 'interrupted' rather than completed. The suspended tool call is omitted from the model’s context — there is no dangling call to report — so the conversation stays valid.

Because the suspension is written onto the prompt, it survives persistence like everything else. You later answer it with resume (re-run the action with your answer as its resumption) or reject (record a failure the model sees), and the loop picks back up. See Handle interrupts for the mechanics and Interrupts for the cross-cutting model.