Validation errors, and the retry
Validation is the check a schema runs on the model's answer, and by default create_agent sends any error back to the model as a tool message so it can try again.
Last updated: 27 Sep, 2026 · LangChain 1.4
TicketModel, a small stand-in model written below, not on Groq. The point is what happens when a model sends a value the schema rejects, and a real model will not send a bad value on cue. The stand-in always makes the same mistake, so the error and the retry show up every run.A free-text status is hard to count, so the ticket system accepts three words and the schema says so with Literal. A model that copies the lookup result, "waiting for stock", straight into the status no longer fits.
The ToolStrategy wrapper
from langchain.agents.structured_output import ToolStrategy
strategy = ToolStrategy(Ticket, handle_errors=True) # True (default) sends errors back to the model
agent = create_agent(model, tools=[...], response_format=strategy)Tightening the schema with Literal
Tighten the schema so the status must be one of three words.
from typing import Literal
from pydantic import BaseModel
class Ticket(BaseModel):
"""A support ticket for one order."""
order_id: str
status: Literal["shipped", "waiting", "unknown"]A stand-in that copies the lookup text
TicketModel is a chat model built like the ShopModel in the BaseChatModel lesson. It follows a fixed script: look the order up, then copy the lookup text into Ticket. Start with the imports, its type name and bind_tools, which create_agent calls.
import re
from langchain.chat_models import BaseChatModel
from langchain.messages import AIMessage
from langchain_core.outputs import ChatGeneration, ChatResult
class TicketModel(BaseChatModel):
@property
def _llm_type(self):
return "ticket"
def bind_tools(self, tools, **kwargs):
return self # its script already knows the tool names_generate picks the next tool call from the last message. After the customer's question it asks for lookup_order; after the lookup result it calls Ticket with that text as the status; after an error it calls fix, shown in the retry section.
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
last = messages[-1]
if last.type == "human": # 1. look the order up
order_id = re.findall(r"\b[A-Z]\d+\b", last.text)[0]
call = {"name": "lookup_order", "args": {"order_id": order_id}, "id": "call_lookup"}
elif last.text.startswith("Error"): # 3. the retry, further down
call = self.fix(messages)
else: # 2. copy the lookup text into Ticket
order_id, status = last.text.rstrip(".").split(" ", 1)
call = {"name": "Ticket", "args": {"order_id": order_id, "status": status}, "id": "call_ticket"}
return ChatResult(generations=[ChatGeneration(message=AIMessage("", tool_calls=[call]))])fix pulls the allowed words out of the error message, picks the one that appears in the lookup result, and calls Ticket again. A real model does this by reading the error itself; this method is only how the stand-in imitates that.
def fix(self, messages):
error = messages[-1].text
allowed = re.findall(r"'(\w+)'", error.split("Input should be")[1].split("[")[0])
found = next(m.text for m in messages if m.type == "tool" and m.name != "Ticket")
status = next((word for word in allowed if word in found), "unknown")
return {"name": "Ticket", "args": {"order_id": found.split(" ")[0], "status": status}, "id": "call_retry"}The lookup tool
This lesson's agent answers order questions with lookup_order, the tool built in Tools: a function the model can call. Add it below the models.
from langchain.tools import tool
ORDERS = {"A17": "shipped on 3 March", "C40": "waiting for stock"}
@tool
def lookup_order(order_id: str) -> str:
"""Look up an order's shipping status by its id, such as A17."""
status = ORDERS.get(order_id)
return f"{order_id} {status}." if status else f"{order_id} is not an order we have."The raw error, with handling off
To see the raw error first, turn error handling off with handle_errors=False. Asking about C40 makes TicketModel send "waiting for stock" as the status, which the Literal rejects.
from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy
strict = ToolStrategy(Ticket, handle_errors=False)
agent = create_agent(TicketModel(), tools=[lookup_order], response_format=strict)
agent.invoke({"messages": [{"role": "user", "content": "Where is C40?"}]})pydantic_core._pydantic_core.ValidationError: 1 validation error for Ticket
status
Input should be 'shipped', 'waiting' or 'unknown' [type=literal_error, input_value='waiting for stock', input_type=str]
For further information visit https://errors.pydantic.dev/2.13/v/literal_error
The above exception was the direct cause of the following exception:
ValueError: Failed to parse data to Ticket: 1 validation error for Ticket
status
Input should be 'shipped', 'waiting' or 'unknown' [type=literal_error, input_value='waiting for stock', input_type=str]
For further information visit https://errors.pydantic.dev/2.13/v/literal_error
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "main.py", line 7, in <module>
agent.invoke({"messages": [{"role": "user", "content": "Where is C40?"}]})
langchain.agents.structured_output.StructuredOutputValidationError: Failed to parse structured output for tool 'Ticket': Failed to parse data to Ticket: 1 validation error for Ticket
status
Input should be 'shipped', 'waiting' or 'unknown' [type=literal_error, input_value='waiting for stock', input_type=str]
For further information visit https://errors.pydantic.dev/2.13/v/literal_error.
During task with name 'model' and id 'cf16aa2d-ff6f-467a-2fba-612c35de0a1c'With handle_errors=False the Pydantic error ends the run. It names the field, the value the model sent, and the three that were allowed.
Reading the error and trying again
handle_errors defaults to True: the error goes back to the model as a tool message, and the loop continues. A real model reads it and corrects its call; TicketModel does the same through its fix method. Leave it on its default and the same question recovers. Print the message types, the tool call in each AI message, and the error sent back in between.
from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy
agent = create_agent(TicketModel(), tools=[lookup_order], response_format=ToolStrategy(Ticket))
result = agent.invoke({"messages": [{"role": "user", "content": "Where is C40?"}]})
print(repr(result["structured_response"]))
print([m.type for m in result["messages"]])
for message in result["messages"]:
if message.type == "ai":
print(message.tool_calls[0]["name"], message.tool_calls[0]["args"])
error = next(m for m in result["messages"] if m.type == "tool" and m.text.startswith("Error:"))
print(error.text)Ticket(order_id='C40', status='waiting')
['human', 'ai', 'tool', 'ai', 'tool', 'ai', 'tool']
lookup_order {'order_id': 'C40'}
Ticket {'order_id': 'C40', 'status': 'waiting for stock'}
Ticket {'order_id': 'C40', 'status': 'waiting'}
Error: Failed to parse structured output for tool 'Ticket': Failed to parse data to Ticket: 1 validation error for Ticket
status
Input should be 'shipped', 'waiting' or 'unknown' [type=literal_error, input_value='waiting for stock', input_type=str]
For further information visit https://errors.pydantic.dev/2.13/v/literal_error.
Please fix your mistakes.How the retry recovered
- The message types show the whole run: the question, the lookup and its result, the first
Ticketcall and the error sent back for it, then the correctedTicketcall and the tool message that ended the loop. - The first
Ticketcall sent "waiting for stock"; the retry sentwaiting, the allowed word found inside it, and that is the ticket that came back. - The error, three messages from the end, names the field, the bad value and the three allowed words, and ends by asking the model to fix its mistake.
handle_errors False vs True
| handle_errors | What happens on a bad value |
|---|---|
False | The validation error is raised and the run stops |
True (default) | The error goes back to the model as a tool message and the loop continues |
When to handle validation errors
- Letting a model recover from a value your schema rejects without crashing the run.
- Failing fast in tests, where you want a bad value to raise instead of retry.
handle_errors=True only helps if the model reads the returned error and changes its next call. A model that sends the same value again loops until it hits the call limit, so keep the schema's message clear about what is allowed.Related
- Previous: Structured output with response_format
- Next: Several tool calls at once
- Reference: Structured output
- Ask about A17 and check which of the three words its status becomes.
- Print
result["messages"][-2].tool_callsand find the id of the corrected call. - Remove
"waiting"from theLiteraland see which wordfixchooses for C40.
You understood something today that you didn't yesterday.