Structured output
Constrain a turn's answer to a schema, and get the validated value back alongside the prose.
Every answer this library produced was free text, so the only way to get a typed value back out of a turn was to declare a TOOL whose whole job was to receive it.
AgentLoopDeps.outputSchema takes any Standard Schema — Zod, Valibot,
ArkType:
const deps: AgentLoopDeps<{ city: string; tempC: number }> = {
// …
outputSchema: z.object({ city: z.string(), tempC: z.number() }),
}
const { text, object } = await runAgentLoop(deps, input, hooks)
// ^ the prose ^ { city: 'Recife', tempC: 21 }, validatedThe validated value comes back as object on the run's result, typed when the loop is called
directly, and is recorded on the assistant message as a synthetic auto-executed structured_output
tool call — the same device inject-mode retrieval uses, so it persists and renders without a store
gaining a column.
It is declared on the agent, not per request, because a schema is a live object and
AgentRunInput crosses a JSON boundary on its way into a durable workflow.
How it composes with tool calling: a separate formatting pass, always
The turn runs its model→tools iteration exactly as it would without a schema. Once a step comes back
with no tool calls, one extra non-streamed call (structured:<step>:<attempt>, tools: [],
outputSchema set) restates that answer as the schema.
Most providers cannot serve a response format and a tool set in the same request. Skipping the pass
for an agent that happens to have no tools would be cheaper and is deliberately not done: that
decision would read the tool registry of whichever process is replaying, which is how a resumed run
ends up asking for a checkpoint position its history has no room for. So the pass is unconditional,
and it costs one model call per turn, billed as its own structured_output usage row.
A translation, not a second answer
The pass is shown the question and the answer — not the turn's whole transcript. It is a translation of the answer, and its own instruction tells the model to use only what the conversation already contains, so handing it the transcript again would roughly double what a turn with a schema costs, at no discount: the pass swaps the system block for the schema instruction, and the system block is the prompt cache's prefix.
The answer it restates is the one that survived the output gate, never the model's
raw reply — and the question comes off the processed prompt, so a question an InputProcessor
masked cannot reappear in the clear here.
outputFromTranscript: true restores the whole transcript for an agent whose answer genuinely cannot
be restated from its own words — one that reports on rows a tool returned and names only their total
in the prose.
An answer that fails the schema is a defined outcome
Up to outputRepairAttempts further calls (default 1) re-ask with the previous attempt's validation
issues attached. After that the run fails with a StructuredOutputError carrying the issues, the text
that failed them, and the attempt count. Bounded because a model that cannot satisfy a schema usually
cannot satisfy it on the fourth try either, and every attempt is billed. outputRepairAttempts: 0
fails on the first invalid reply.
The provider constrains, the loop validates
aiSdkModel maps the schema onto streamText's output: Output.object(...) so the provider
constrains generation, and passes its parsed value back — but the loop validates it regardless. "The
provider says it matched" is not the same claim as "it matches", and a provider that ignored the
schema has to fail where the failure is repairable rather than downstream. An adapter that cannot
constrain generation at all still works: the loop reads the JSON out of the reply text, fences and
lead-in prose included (extractJson is exported for the same job).
If an output processor rewrote the reply, the provider's parsed object describes text that no longer exists — so the gated text is re-parsed rather than trusted.
Determinism
Every attempt is its own checkpoint with its own usage row, so a suspend between two attempts resumes
on the reply the first one got rather than paying for a third. Validation itself sits outside the
checkpoints, on the same footing as HistoryWindow.select: its inputs are the schema and a reply a
checkpoint already holds, so every replay reaches the same verdict — and the same number of attempts
— without a position of its own.
A consumer who declares no outputSchema sees no new checkpoint, no extra call, and no change to the
loop's checkpoint sequence.
Input and output processors
A seam on each side of the model call — rewrite the prompt going out, gate the answer coming back.
Transient tool retries
Retry a tool's own invocation in place when it hits a classified-transient DB error (deadlock, lock-wait timeout, serialization failure) — bounded, replay-safe, and on by default, while business failures stay one-shot.