Enforce structured output with a hook
Coming soonAn agent turn does not always end with a response that matches your response_format=. One turn
can contain many model calls, and the model output is not deterministic.
This is a problem in programmatic usage, for example, a pipeline parses the response into a Pydantic model and stores the result. A response with a different shape stops the pipeline.
To prevent this, use pattern.hook.StructuredOutputHook. The hook validates the final response
against your model. If the response is not valid, the hook sends the error to the agent, and the
agent corrects its response.
The hook
import json
from typing import ClassVar, override
from pydantic import BaseModel, ValidationError
from mistralai.vibe.harness.core import protocol
from mistralai.vibe.harness.runtime.capabilities import (
PostLlmCallHookAccept,
PostLlmCallHookInvocation,
PostLlmCallHookRetry,
PreAgentTurnHookContinue,
PreAgentTurnHookInvocation,
)
from mistralai.vibe_agents.sdk.authoring.contracts import Hook, HookContext
__all__ = ["StructuredOutputHook"]
_ERROR = "StructuredOutputHook subclasses must define output_model as a Pydantic BaseModel class"
class StructuredOutputHook(Hook):
"""Require every final model response to match a Pydantic model."""
output_model: ClassVar[type[BaseModel]]
_schema: ClassVar[str]
def __init_subclass__(cls, **kwargs: object) -> None:
super().__init_subclass__(**kwargs)
output_model = cls.__dict__.get("output_model")
if not isinstance(output_model, type) or not issubclass(output_model, BaseModel):
raise TypeError(_ERROR)
cls._schema = json.dumps(output_model.model_json_schema(), indent=2)
def __init__(self) -> None:
if type(self) is StructuredOutputHook:
raise TypeError(_ERROR)
super().__init__()
@override
async def pre_agent_turn(
self,
ctx: HookContext,
request: PreAgentTurnHookInvocation,
) -> PreAgentTurnHookContinue:
del ctx
instruction = (
"Return the final answer as one JSON object that matches the JSON Schema below. "
"Do not wrap the JSON in Markdown.\n\n"
f"JSON Schema:\n{type(self)._schema}"
)
return PreAgentTurnHookContinue(
user_content=[*request.user_content, protocol.TextContentBlock(text=instruction)]
)
@override
async def post_llm_call(
self,
ctx: HookContext,
invocation: PostLlmCallHookInvocation,
) -> PostLlmCallHookAccept | PostLlmCallHookRetry:
del ctx
parts = invocation.candidate.message.content
if any(isinstance(part, protocol.ModelToolCallPart) for part in parts):
return PostLlmCallHookAccept()
text = "".join(part.text for part in parts if isinstance(part, protocol.TextContentBlock))
try:
type(self).output_model.model_validate_json(text)
except ValidationError as error:
feedback = (
"Your previous answer did not match the required JSON Schema. "
"Return only a corrected JSON object.\n\n"
f"Validation error:\n{error}\n\n"
f"JSON Schema:\n{type(self)._schema}\n\n"
f"Previous answer:\n{text}"
)
return PostLlmCallHookRetry(feedback=[protocol.TextContentBlock(text=feedback)])
return PostLlmCallHookAccept()pre_agent_turn adds the JSON Schema to the user message. post_llm_call runs after each model
call. It accepts a tool call, because the turn is not finished. It validates a final response. If
the validation fails, it returns PostLlmCallHookRetry with the error, the schema and the previous
response. Each retry uses one iteration of max_iterations.
Usage
Subclass the hook and set output_model. Put the subclass last in hooks=[...].
import asyncio
from pydantic import BaseModel
from mistralai.agents import agents, pattern
class City(BaseModel):
city: str
country: str
class CityOutputHook(pattern.hook.StructuredOutputHook):
output_model = City
class LandmarkArgs(BaseModel):
name: str
@agents.tool(name="lookup_landmark", description="Find where a landmark is.", input_schema=LandmarkArgs)
async def lookup_landmark(args: LandmarkArgs) -> dict[str, str]:
return {"landmark": args.name, "location": "Champ de Mars, 7th arrondissement, Paris, France"}
async def main() -> None:
agent = agents.Agent(
model="mistral-medium-3-5",
name="structured-output-hook-example",
instructions="Use lookup_landmark to find where a landmark is.",
max_iterations=6,
harness=agents.Harness(tools=[lookup_landmark], hooks=[CityOutputHook()]),
)
result = await agent.run("Which city and country contain the Eiffel Tower?", response_format=City)
if not isinstance(result.output, City):
raise RuntimeError("The run did not produce a parsed City output")
print(result.output.model_dump())
if __name__ == "__main__":
asyncio.run(main())See also
- Structured output: the
response_format=argument. - Custom hooks: hook points, order and state.