Skip to content

Structured Output

Structured output makes the agent's final answer conform to a Pydantic schema you supply. The agent runs its normal tool loop, then a terminal pass validates the result against your model and returns a typed instance — no free-text parsing, no hand-rolled "respond" tool.

Quick start

from pydantic import BaseModel, Field
from symfonic.agent import SymfonicAgent, FrameworkConfig

class PersonInfo(BaseModel):
    name: str = Field(description="Full name")
    age: int = Field(description="Age in years")

agent = SymfonicAgent(config=FrameworkConfig(), model_provider=provider)

resp = await agent.run(
    "John Smith is a 30-year-old software engineer.",
    response_model=PersonInfo,
)

person = resp.structured          # -> PersonInfo(name='John Smith', age=30)
print(person.name, person.age)
print(resp.final_response)         # raw text answer is still present

Pass any Pydantic BaseModel subclass as response_model. The validated instance lands on AgentResponse.structured; the model's prose answer stays on AgentResponse.final_response, so you get both.

How it works

Structured output is a post-loop extraction pass, not a change to the react loop:

  1. The agent runs to completion exactly as it would without a schema — tools, memory hydration, metacognition, and consolidation all behave identically.
  2. The engine hands the final conversation to symfonic.agent.structured.extract_structured_output, which binds your schema onto the chat model via LangChain's with_structured_output.
  3. The provider fills the schema (tool-calling on Anthropic / Bedrock Converse / OpenAI-compatible gateways, JSON mode where offered). The result is validated into a typed instance.

Because it runs after the loop, structured output composes with everything else — tools can run in the same call, and the schema never perturbs which tools the model sees mid-loop.

Works with any provider that can do structured output

response_model is provider-neutral. It works with the Anthropic, OpenAI, Google, Bedrock (any model family), and OpenRouter providers — anything whose underlying BaseChatModel implements with_structured_output. See Model Providers.

Error handling — fail loud, not silent

You opted in by passing a schema, so a failure is surfaced, never swallowed:

from symfonic.agent import StructuredOutputError

try:
    resp = await agent.run(query, response_model=PersonInfo)
except StructuredOutputError as exc:
    # exc.code == "unprocessable" (maps to HTTP 422 in the FastAPI layer)
    ...

StructuredOutputError is raised when:

  • the provider's chat model cannot do structured output, or
  • the model's output fails schema validation.

When response_model is not passed, AgentResponse.structured is None and behaviour is unchanged.

Guidance

  • Describe every field. Field(description=...) is the instruction the model reads. Vague field names produce vague extractions.
  • Keep schemas flat where you can. Deeply nested unions are harder for models to fill reliably; validate-and-retry at the application layer if you need strict guarantees.
  • The prose answer is still there. Use final_response for a human-readable message and structured for the machine-readable payload in the same turn.