# Agent loops

## What it does

Agent loops make the agent's per-run turn-control flow a replaceable strategy without forking the runtime. The runtime owns provider calls, retry, abort, store appends, redaction, and event emission; a loop only orchestrates those shared primitives through a `LoopContext`. The default `singleShotLoop` is the former inline turn loop extracted verbatim — optional drain pending steers → assemble → generate → append assistant message → optional tool dispatch → next turn (continue while steers remain even if the provider returned no tool calls). `generateValidateReviseLoop` is the first alternative loop: generate → parse → validate → revise up to a budget.

Loops are opt-in. When no `loop` is configured, the runtime runs `singleShotLoop` and behavior is bit-for-bit with the pre-loop runtime.

- `singleShotLoop` — default; one-or-more provider turns with bounded tool rounds.
- `generateValidateReviseLoop(opts)` — factory returning a generate→validate→revise loop parameterized by host callbacks (`validator`, optional `parser`/`repairer`, `maxRevisions`).
- `resolveLoop(options, config)` — resolves `RunOptions.loop` (wins) over `AgentConfig.loop`, mapping `AgentLoopOptions` to a built-in strategy and passing through a custom `AgentLoopStrategy` instance.

The `Artifact*` contracts (`ArtifactValidation`, `ArtifactContext`, `ArtifactParseResult<T>`, `ArtifactParser<T>`, `ArtifactValidator<T>`, `ArtifactRepairer<T>`) are generic over a host-defined type `T`. Prism threads `T` through parser→validator→repairer; it never instantiates `T`. No domain control-flow vocabulary (`workflow`/`node`/`step`) appears in these contracts — the seam stays generic.

## When to use it

Use the default `singleShotLoop` implicitly whenever you call `session.run()` — no configuration needed. Opt into `generateValidateReviseLoop` when a run should produce an artifact that must satisfy a host-supplied schema before it is considered complete (e.g. structured output, a validated JSON document, a generated file passing lint) and the host wants Prism to drive the revision turns.

Do not use a loop to re-implement provider calls, retry, abort, store, or event emission — those stay runtime-owned and are exposed to the loop only through `LoopContext`. Artifact-loop tools stay disabled by default; opt into bounded calls only for host-registered, least-privilege lookup tools that must inform an artifact candidate.

## Inputs / request

```ts
import {
  createAgent,
  generateValidateReviseLoop,
  singleShotLoop,
  resolveLoop,
  type AgentLoopStrategy,
  type AgentLoopOptions,
  type LoopContext,
  type ArtifactValidator,
  type ArtifactParser,
  type ArtifactRepairer,
  type ArtifactValidation,
  type ArtifactContext,
  type ArtifactParseResult,
} from "@arnilo/prism";
```

Per-run and per-agent loop selection (RunOptions wins):

```ts
// AgentConfig.loop pins a loop for the agent/session.
const agent = createAgent({
  model,
  provider,
  // optional default loop for this agent:
  loop: { strategy: "single-shot", toolConcurrency: 4 },
});

// RunOptions.loop overrides per request.
await session.run(input, {
  loop: {
    strategy: "generate-validate-revise",
    validator: hostValidator,
    parser: hostParser,       // optional; default treats assistant text as the value
    repairer: hostRepairer,  // optional; default stringifies validation.errors[].message
    maxRevisions: 3,         // optional; default 3
    toolCalls: "bounded",    // optional; default "disabled"; uses limits.maxToolRounds
    structuredOutputTiming: "final-turn-only", // optional; default "every-turn"
  },
});

// Custom loop escape hatch (a host-provided AgentLoopStrategy instance):
await session.run(input, { loop: myCustomLoop });
```

`AgentLoopOptions` is the discriminated union:

```ts
type AgentLoopOptions =
  | {
      readonly strategy: "single-shot";
      /** Independent tool calls per turn run concurrently up to this limit. Default `1`. */
      readonly toolConcurrency?: number;
    }
  | {
      readonly strategy: "generate-validate-revise";
      readonly validator: ArtifactValidator<unknown>;
      readonly parser?: ArtifactParser<unknown>;
      readonly repairer?: ArtifactRepairer<unknown>;
      readonly maxRevisions?: number;
      /** Default "disabled". "bounded" dispatches sequentially up to limits.maxToolRounds. */
      readonly toolCalls?: "disabled" | "bounded";
      readonly structuredOutput?: StructuredOutputOptions;
      readonly structuredOutputMode?: "native" | "artifact-loop";
      /** Default "every-turn". "final-turn-only" omits schema while tools may run. */
      readonly structuredOutputTiming?: "every-turn" | "final-turn-only";
    };
```

Host callback contracts (all generic over host `T`):

| Contract | Shape |
| --- | --- |
| `ArtifactParser<T>` | `(text: string, ctx: ArtifactContext) => ArtifactParseResult<T> \| Promise<...>` — parse assistant text to a typed value. Empty/whitespace-only call-free text is rejected before the parser (`metadata.reason: "parse_error"`, message `no artifact text in model output`) so thinking-only/reasoning-only turns cannot succeed via the identity parser. A parse failure (`ok: false` or missing `value`) consumes revision budget exactly like a validation failure: the repairer receives `value: undefined` plus a synthetic failure (`errors[0].message` = the parse error, `metadata.reason: "parse_error"`), and budget exhaustion ends with terminal `artifact_failed`. |
| `ArtifactValidator<T>` | `(value: T, ctx: ArtifactContext) => ArtifactValidation \| Promise<...>` — return `{ ok: true }` or `{ ok: false, errors }`. |
| `ArtifactRepairer<T>` | `(value: T \| undefined, failure: ArtifactValidation, ctx: ArtifactContext) => AgentInput \| Promise<...>` — build the revision follow-up input. |
| `ArtifactValidation` | `{ ok: boolean; errors?: readonly { path?: string; message: string }[]; metadata?: ... }`. |
| `ArtifactContext` | `{ sessionId, runId, turn, signal, metadata }` — passed to every callback. |
| `ArtifactParseResult<T>` | `{ ok: boolean; value?: T; error?: string }`. |

Optional steer hooks on `LoopContext` (0.0.11): `hasPendingSteers?()` / `applyPendingSteers?()`. Hosts/custom loops that omit them keep pre-steer behavior; built-in loops drain at turn start.

`LoopContext` (what the runtime builds for the loop each run):

| Field | Purpose |
| --- | --- |
| `sessionId`, `runId`, `metadata`, `signal` | Run identity and abort. |
| `history: Message[]` | Live mutable history — the loop pushes assistant and repair messages directly. |
| `input`, `inputMessages`, `maxToolRounds`, `toolConcurrency` | First-turn input, redacted input messages, tool-round budget, and per-turn parallel dispatch limit (`toolConcurrency` default `1`). |
| `assemble(nextInput, toolResults?)` | Wraps `assembleProviderInput()` with resolved skills/tools/context/system prompt/provider options. |
| `generate(request)` | Wraps provider request policies + `provider_request` middleware + `generateWithRetry()`; returns `ProviderTurnResult`. |
| `dispatchToolCall(call)` | Wraps `dispatchToolCall()` with resolved registry/middleware/permission/redactor/validate. |
| `appendMessage(message)` | Appends to the store under the run (redacted). |
| `emit(event)` | Emits a redacted `AgentEvent`. |

## Durable runs

`RunOptions.runState` supports the built-in loop options (`single-shot` and `generate-validate-revise`) and custom strategies that opt into durable state. `single-shot` is durable via the runtime's pending-call mechanism and carries no loop-local state. `generate-validate-revise` snapshots `{ attempts, artifactPhase, savedSchema, pendingHistory }` at `revision: "1"`. A custom `AgentLoopStrategy` must declare both snapshot hooks or durable configuration rejects it with `AgentLoopStateError` (`ERR_PRISM_LOOP_NOT_DURABLE`) before any provider call:

| Member | Purpose |
| --- | --- |
| `revision?: string` | Host-authored loop revision. Joins the durable-run fingerprint, so a loop change without a `definitionRevision` bump fails closed on resume. |
| `snapshot?(): JsonValue` | Capture loop-local resumable state at suspension. Must be JSON-compatible; core redacts it and bounds it inside the durable run-state envelope (`maxStateBytes`, depth 32). A non-JSON value fails the run with `ERR_PRISM_LOOP_SNAPSHOT`. |
| `restore?(snapshot): void` | Rehydrate from the captured snapshot; must throw on drift. Called once before `run(ctx)` on resume. Also available as `ctx.restoredLoopState`. |

The snapshot is stored as `loopState: { name, revision, snapshot }` on the durable run state and cleared when the run reaches a terminal status. On resume, a name/revision mismatch between the stored `loopState` and the resolved strategy fails closed (`ERR_PRISM_LOOP_REVISION`), and the fingerprint check independently rejects any loop drift. Suspension occurs only before an input provider call or immediately before a tool side effect; completed provider turns remain in `SessionStore` history and are not repeated after `resumeAgentRun()`.

A strategy returned by `generateValidateReviseLoop()` is safe to reuse across sequential runs. Its built-in state is scoped to `(sessionId, runId)`; a new non-restored run resets attempts, artifact phase, saved schema, and pending repair messages, while a restored run keeps the checkpointed state. Arbitrary custom strategies are not cloned or reset automatically.

## Turn policy

`RunOptions.turnPolicy` (`TurnPolicyOptions`) lets a host end a run **cleanly** at a provider-turn boundary — after the previous turn's tool results are persisted, before the next provider request (the same point `checkpointPolicy: "every-turn"` checkpoints at). This is the "stop when the agent has done enough" seam: an investigation that should stop at the first plan paint, a desk that stops after N turns, a policy that stops once a tool budget is spent.

```ts
await session.run("Investigate the churn spike", {
  turnPolicy: {
    // Clean turn cap: reaching it stops the run instead of failing it.
    maxTurns: 4,
    // Consulted once per boundary; a stop ends the run as `succeeded`.
    stop: (ctx) =>
      ctx.turns >= 1 && ctx.toolCalls >= 1
        ? { action: "stop", reason: "l1-first-plan-paint" }
        : { action: "continue" },
  },
});
```

| `TurnBoundaryContext` field | Meaning |
| --- | --- |
| `sessionId`, `runId` | Run correlation. |
| `turn` | 1-based index of the provider turn this boundary precedes. |
| `turns` | Provider turns already completed (`turn - 1`; `0` at the first boundary). |
| `toolCalls` | Host tool calls dispatched so far in this run. |
| `usage` | Run-total usage so far, when the provider reported any. |
| `metadata` | Run metadata (never prompt text, tool arguments, or results). |

A `stop` decision is a **clean terminal outcome**, not an error or a limit breach: the run returns `status: "succeeded"` with `stopReason: "host_policy"` and `stopDetail` (the host's `reason`, ≤256 UTF-8 bytes, redacted). The same pair rides `agent_finished.finishReason`/`stopDetail`, the finish `RunRecord`, and the projected `ExecutionTimeline`. `turnPolicy.maxTurns` is a *clean* cap: it reports `stopReason: "turn_limit"` and, unlike a `limits.maxTurns` breach, never throws `AgentRunLimitError`. A run overlay may only narrow `limits.maxTurns` — widening throws before the first provider turn.

A policy stop stays **resumable**: with `runState: { checkpointPolicy: "every-turn" }` the terminal state keeps the run frontier, so `resumeAgentRun(..., { decision: "continue" })` continues from the boundary. Steers queued before the stop are already in the session history and reach the resumed leg exactly once. A `turnPolicy.maxTurns` stop is the exception — resuming it would re-stop on the first boundary. Resumed runs carry no `turnPolicy` (resume options are not run options), so a continued leg runs to its natural end unless the host stops it again.

The callback is synchronous and bounded, and is never called when `turnPolicy` is omitted: a run without a policy keeps its exact request stream. A callback that throws or returns a malformed decision fails the run closed with `ERR_PRISM_TURN_POLICY` (the boundary makes no provider call and the checkpoint stays fail-closed); a stopped run is never recorded as failed.

## Outputs / response / events

`AgentLoopStrategy.run(ctx)` returns `Promise<Usage | undefined>` as a fallback for custom loops. Core runtime independently accumulates every usage-bearing provider turn in O(turns), persists scoped turn/run rows, and emits `agent_finished` with the aggregate.

Events during a loop run are the existing `AgentEvent`s (`turn_started`, `message_started`, `message_delta`, `message_finished`, `turn_finished`, tool-execution events when the loop dispatches tools, `error` on real failures). Both built-in loops emit `turn_started` before each provider turn, `message_finished` for every assistant draft, and `turn_finished` after the assistant draft is appended. First-turn input is appended to live history once, matching the already-persisted user message.

Validation-failure-triggering-a-revision is **not** an `error` event — it is recoverable, like `tool_execution_blocked`. In bounded artifact mode, a tool-calling provider response emits normal assistant/tool lifecycle events, skips artifact parsing/validation, then the next turn sees its persisted result. `generateValidateReviseLoop` emits artifact events only for call-free candidates: `artifact_validation_started` → `artifact_validation_finished` → (`artifact_revision_started`)* → `artifact_finished` | `artifact_failed`. A request beyond `maxToolRounds` executes nothing and emits terminal `artifact_failed` with `result.metadata.reason === "tool_round_limit"`; see [Agent events § Artifact event ordering](agent-events.md#artifact-event-ordering). `singleShotLoop` emits zero artifact events. Real failures stay on the `error` channel. Session runs using `generate-validate-revise` resolve `succeeded` only after `artifact_finished`; terminal `artifact_failed` (including empty/thinking-only parse exhaustion) fails the run with `AgentRunError` (`error.code` from `result.metadata.reason`, e.g. `parse_error`).

A loop has no path to credentials, provider objects, or unredacted secrets. `LoopContext.generate` receives the already-policy-applied, middleware-run, redacted request; `LoopContext.emit` runs through `redactAgentEvent` with the active `SecretRedactor`.

## Request/response example

```ts
// Default single-shot run (no loop configured).
await session.run("Summarize the schema.");
```

```ts
// Generate-validate-revise with a host schema validator.
import { createAgent, type ArtifactValidator } from "@arnilo/prism";

const validator: ArtifactValidator<unknown> = (value, _ctx) =>
  typeof value === "string" && value.length > 0
    ? { ok: true }
    : { ok: false, errors: [{ message: "empty artifact" }] };

await session.run("Write a one-line release note.", {
  loop: { strategy: "generate-validate-revise", validator, maxRevisions: 3 },
});
```

## Implementation example

```ts
import { createAgent, createMockProvider, providerTextDelta, providerDone, type ArtifactValidator, type ArtifactParser, type ArtifactRepairer } from "@arnilo/prism";

// Host owns the schema shape T. Prism never instantiates it.
interface JsonDoc { readonly title: string; readonly body: string }

const parser: ArtifactParser<JsonDoc> = (text) => {
  try {
    const value = JSON.parse(text) as JsonDoc;
    return { ok: true, value };
  } catch (error) {
    return { ok: false, error: error instanceof Error ? error.message : "parse failed" };
  }
};

const validator: ArtifactValidator<JsonDoc> = (value) =>
  value.title && value.body
    ? { ok: true }
    : { ok: false, errors: [{ path: value.title ? "body" : "title", message: "missing field" }] };

const repairer: ArtifactRepairer<JsonDoc> = (_value, failure) => ({
  role: "user",
  content: [{ type: "text", text: `Fix these: ${failure.errors?.map((e) => e.message).join("; ")}` }],
});

const agent = createAgent({
  model: { provider: "mock", model: "demo" },
  provider: createMockProvider([
    providerTextDelta(JSON.stringify({ title: "ok", body: "rev1" })),
    providerDone(),
  ]),
});

await agent.createSession().run("Produce the JSON doc.", {
  loop: { strategy: "generate-validate-revise", validator, parser, repairer, maxRevisions: 3 },
});
```

A custom loop is a plain object; pass it directly as `AgentLoopStrategy`:

```ts
import { type AgentLoopStrategy } from "@arnilo/prism";

const twoShotLoop: AgentLoopStrategy = {
  name: "two-shot",
  async run(ctx) {
    await ctx.generate(await ctx.assemble(ctx.input));
    // ...orchestrate further turns via ctx primitives only...
    return undefined;
  },
};
await session.run(input, { loop: twoShotLoop });
```

## Extension and configuration notes

- `RunOptions.loop` wins over `AgentConfig.loop`; when neither is set the runtime uses `singleShotLoop`. This mirrors the other `RunOptions` overrides (`redactor`, `validate`, `activeSkills`).
- `{ strategy: "single-shot" }` resolves to the exported `singleShotLoop`; `{ strategy: "generate-validate-revise", ... }` is mapped by `resolveLoop()` to `generateValidateReviseLoop(opts)`. An unknown `strategy` throws before the first turn. Passing an `AgentLoopStrategy` instance bypasses the options form entirely (custom-loop escape hatch).
- The loop is resolved once per run inside `RuntimeAgentSession.run()`, after the usual setup (provider/skills/tools resolution, history rebuild, model-change entry, input append, auto-compaction). The runtime's outer try/catch/finally, run-exclusivity, abort bridging, and subscriber close remain in place around `loop.run(ctx)`.
- `LoopContext.assemble(nextInput, toolResults?)` accepts an optional tool-result accumulator so `singleShotLoop` can pass its loop-local results. Bounded artifact tools append results directly to shared history, then assemble the next turn with empty new input; no second transcript path exists.
- `limits.maxToolRounds` bounds both `singleShotLoop` and opt-in bounded artifact tool rounds across the whole run. Artifact mode always dispatches sequentially, regardless of `toolConcurrency`; all dispatches still use existing registry/filter/permission/validator/middleware/redactor/ledger guards.
- `maxRevisions` (default 3) counts only failed call-free artifact candidates. Bounded artifact runs make at most `1 + maxRevisions + maxToolRounds` provider turns. A tool-round limit is terminal and returns last usage after `artifact_failed`; it does not throw.
- A revision cycle appends one assistant draft and one repair user message per revision to the session store, so store entries reflect every attempted draft. The original user input is stored once by the runtime and pushed into loop history once on the first turn. Repair messages are assembled as the next provider `nextInput` and only pushed into live history after that revision request has been generated, so the model never receives a duplicated repair instruction.

## Security and performance notes

- Loops have no path to credentials, provider objects, or unredacted secrets. `LoopContext.generate` consumes an already-redacted request; `LoopContext.emit` runs through `redactAgentEvent` with the active `SecretRedactor`; `LoopContext.appendMessage` appends a redacted entry.
- `ArtifactValidation.errors[].message` may echo model text — `artifact_*` event payloads flow through the same `redactAgentEvent` path as other `AgentEvent`s (see [Agent events](agent-events.md)).
- `generateValidateReviseLoop` makes at most `1 + maxRevisions + maxToolRounds` provider turns when bounded tools are enabled (otherwise `maxRevisions + 1`); it cannot loop forever. Each revision costs one provider turn plus one store append.
- Bounded artifact tool calls run sequentially through `dispatchToolCall` (permission + validation + execute); their assistant call and result are persisted before the next provider request. `singleShotLoop` retains its bounded parallel worker pool and original call-order transcript behavior.
- In a parallel single-shot batch, the worker pool stops claiming calls after the first dispatch error or abort, waits for every already-claimed worker with `Promise.allSettled`, then persists rows in call order before rethrowing the first failure: real results for calls that finished, an error row carrying the failure for the call that threw, and a `tool_call_not_dispatched` row for calls the batch never started. A stopped batch therefore never ends the run with `tool_call` ids that have no `tool_result` (providers reject such a history on the next turn); run-level suspension errors (`AgentRunSuspended`, `ERR_PRISM_DELEGATION_SUSPENDED`, `ERR_PRISM_LOOP_*`) are the exception — their resume machinery appends the real result, so the failed call gets no synthetic row. Already-claimed side effects may finish and are not rolled back; successful batches still append results in original call order. The round-level `chargeToolRound` approval gate runs before workers, so approval suspension starts no worker.
- The loop is a plain object/factory; no class hierarchy, no background work, no extra dependencies. `LoopContext` is a single object literal of bound arrows built once per run.
- The host-domain-free boundary is guarded by tests: `src/` imports no host-domain package, and the `Artifact*`/`AgentLoop*`/`LoopContext` contracts contain no `workflow`/`node`/`step` field names. Hosts supply their own schema; no host domain type is imported by `src/`.

## Guardrails

Built-in loops and custom loops that use `LoopContext.generate()` / `LoopContext.dispatchToolCall()` inherit runtime guardrails. Provider output is checked before a loop appends assistant content; tool stages remain in shared dispatch. Do not call providers or `ToolDefinition.execute()` directly if guardrail enforcement is required; see [Guardrails](guardrails.md).

## Related APIs
- [Agent/session runtime](agent-session-runtime.md): `RuntimeAgentSession.run()` builds the `LoopContext` and delegates to the resolved loop.
- [Agent events](agent-events.md): the `artifact_*` event variants and ordering emitted by `generateValidateReviseLoop`.
- [Structured output](structured-output.md): the `ArtifactParser<T>`/`ArtifactValidator<T>`/`ArtifactRepairer<T>` seam (host-defined `T`, Prism never instantiates it) and a host schema→`ArtifactValidation` mapping example.
- [Public contracts](public-contracts.md): `AgentLoopStrategy`, `AgentLoopOptions`, `LoopContext`, `ProviderTurnResult`, and the `Artifact*` contracts.
- [Input and prompt assembly](input-and-prompt-assembly.md): `assembleProviderInput()`, the primitive behind `LoopContext.assemble`.
- [Tools](tools.md): `dispatchToolCall()`, the primitive behind `LoopContext.dispatchToolCall`.
- [Compaction and retry policies](compaction-and-retry.md): `generateWithRetry()` and retry/compaction primitives the loop never re-implements.
- [Context and skills](context-and-skills.md): per-run skill/tool resolution feeding `LoopContext.assemble`.
