Structured output¶
A "return JSON" prompt is not a reliable contract. With output_type, lovia
parses and validates the final answer, applies the configured repair strategy,
and raises a clear error if the result still does not match.
Keep the default str when humans are the only consumers. Set output_type
when downstream code needs stable fields, types, or constraints. External side
effects belong in approved Tools, not output validation.
from pydantic import BaseModel
from lovia import Agent, Runner
class Brief(BaseModel):
title: str
bullets: list[str]
agent = Agent(name="summarizer", model="<model>", output_type=Brief)
result = await Runner.run(agent, "Summarize lovia for a Python developer.")
print(result.output.title) # typed access — result.output is a Brief
Accepted types¶
Anything lovia can build a JSON Schema for and coerce into:
- Pydantic models (the richest option: constraints, custom validators)
- dataclasses and
TypedDicts - plain types and containers —
list[str],dict[str, int],Literal[...], unions,int,bool, ... str— the default, meaning free-form text with no parsing at all
The model may still call tools on the way; the contract applies to the final message that ends the run.
Per-run override¶
output_type on the agent is the default; a run can override it:
The override is run-wide: after a handoff, the target
agent inherits it too (without an override, each agent uses its own
declared output_type).
How the schema reaches the model¶
Two strategies, chosen automatically per provider:
- Native — providers with structured-output support (
OpenAI response_format, Anthropic's output format) receive the JSON Schema in the request and enforce it server-side. - Prompt fallback — for everything else, lovia appends an "Output format" block to the system prompt instructing the model to reply with a single schema-shaped JSON document. It lives in the system prompt (not a synthetic tool) so the requirement stays visible regardless of context length or tool count.
Either way, parsing is lenient before it is strict: the raw text is tried as-is, then with markdown code fences stripped, then as the first balanced JSON object/array embedded in surrounding prose. Only then is the parsed document validated against your type.
Repair¶
When the final message fails to parse or validate, the agent's
output_repair policy decides what happens next:
True(default) — the runner appends a corrective user prompt (quoting the validation error) and lets the model try once more. A second failure raises.False— fail fast: raiseOutputValidationErrorimmediately.- An
OutputRepairStrategy— your own policy:
class PatientRepair:
def build_prompt(self, exc, attempt):
if attempt > 3:
return None # give up: the error is re-raised
return f"Attempt {attempt} failed: {exc}. Reply with only the JSON."
agent = Agent(..., output_type=Brief, output_repair=PatientRepair())
build_prompt receives the OutputValidationError and the 1-based
attempt number; returning None stops retrying. Each repair consumes a
normal turn (it counts toward max_turns and the budget).
OutputValidationError carries raw (a snippet of what the model actually
said) and output_type_name — enough to debug a persistent mismatch from
logs alone.
Sharp edges¶
output_type=strmeans "no contract", not "validate it's a string". Everything is a string then; repair never triggers.- Schema complexity costs accuracy. Deeply nested unions and
open-ended
dict[str, Any]fields degrade model compliance long before they break the parser — flat, explicit models with field descriptions validate best. - An empty reply goes through repair too (there's nothing to parse).
If you see repair loops ending in
OutputValidationErrorwith emptyraw, checkfinish_reason— it's usuallymax_tokenstruncation, not disobedience.
See also¶
- Agents — where
output_typeandoutput_repairlive - Providers & models — which providers get the native path
- Example:
04_structured_output.py