Skip to content

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:

result = await Runner.run(agent, "Return a launch checklist.", output_type=list[str])

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: raise OutputValidationError immediately.
  • 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=str means "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 OutputValidationError with empty raw, check finish_reason — it's usually max_tokens truncation, not disobedience.

See also