Structured Output¶
Pass a pydantic model to execute() and the return value is a validated instance of that model:
from pydantic import BaseModel
from xun import Agent
class StoryModel(BaseModel):
title: str
content: str
story = (
Agent()
.initialize()
.instruct("Write a short story within 100 words.")
.execute(schema=StoryModel) # Result[StoryModel, ErrorInfo]
.unwrap() # StoryModel
)
print(f"Title: {story.title}")
A user message must exist first
Conversation.set_response_schema() attaches the schema to the trailing user message,
and with no user message it raises ValueError: No user message to attach a response schema to.
So instruct() must come before execute(schema=…).
Three layers of constraint¶
flowchart TD
A["execute(schema=M)"] --> L1["Prompt injection<br/>appends the JSON Schema after the user message"]
L1 --> L2{"Tools registered for this turn?"}
L2 -- No --> P["Also passes server-side response_format<br/>strict=False"]
L2 -- Yes --> S["Prompt injection only"]
P --> R["Loop ends"]
S --> R
R --> V["json_repair fix + M.model_validate"]
V -- Fails --> E["ErrorEvent + Result.Err(ErrorInfo)"]
V -- Passes --> OK["Result.Ok(instance of M)"]
| Layer | Strength | Description |
|---|---|---|
| Prompt injection | Always active | Appends a prompt plus the output of model_json_schema() after the user message |
response_format |
Soft constraint | Set only when no tools are registered; with tools present, forcing JSON makes the model skip tool calls. Even when it is set, it is strict: False |
| Post-hoc validation | Hard constraint | json_repair.loads repairs the JSON first, then model_validate; on failure an ErrorEvent is emitted and the error is raised, ending as Result.Err |
The second layer is deliberately soft: as soon as tools are available, response_format and tool calls interfere with each other,
so the framework lets prompt injection carry the main constraint and leaves "always conforming" to the validation in the third layer.
What happens when validation fails¶
A validation failure never degrades into a string silently:
res = agent.instruct(...).execute(schema=StoryModel)
if res.is_err():
print(res.unwrap_err().error) # Looks like: Failed to parse result into <class 'StoryModel'>: ...
To retry, call execute() again, or loosen the model definition.
Using it together with hooks¶
The before_execution hook receives the execution arguments themselves, so you can change schema or max_iterations in place,
or set takeover_result to take over the turn entirely:
from xun import HookArgs
def stub(args: HookArgs.BeforeExecutionArgs) -> None:
if args.schema is not None:
args.takeover_result = '{"title": "stub", "content": "…"}'
agent.hooks.before_execution.add(stub)
Once takeover_result is set: the model is not called, no other hooks or events inside execution are triggered, and nothing is written to the conversation history;
but the content is still validated against the schema, and invalid content fails the turn.
Tool return values are not structured output¶
execute(schema=…) constrains the final answer at the end of the turn. A tool's own return value takes a different path:
it must be JSON-serializable, and exceptions are wrapped into Result.Err by except_safe, see Tool Setup.