LangChainLangChain 1.4 · Python 3.10+
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
46 small wins to finish your pathNext lesson →

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

A free-text status is hard to count, so the ticket system accepts three words and the schema says so with Literal. Lesson 10's model copies the lookup phrase straight into the status, which no longer fits.

The ToolStrategy wrapper

python
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.

Exampleticket.py
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"]

Turning error handling off

To see the raw error first, wrap the class in ToolStrategy with error handling turned off.

python
from langchain.agents.structured_output import ToolStrategy

strict = ToolStrategy(Ticket, handle_errors=False)   # let the error stop the run
agent = create_agent(TicketModel(), tools=[lookup_order], response_format=strict)

The error, turned off

Asking about C40 sends "waiting for stock" as the status, which the Literal rejects.

Example
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?"}]})

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 hosted model reads it and corrects its call. This stand-in needs one more method.

This fix method is stand-in plumbing so the demo recovers deterministically. A real model rereads the returned error itself, so you can skip the details and keep the idea: the error goes back, the next answer is valid.

Exampleticket_model.py, a new method of TicketModel
    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")
        args = {"order_id": found.split(" ")[0], "status": status}
        return AIMessage("", tool_calls=[{"name": "Ticket", "args": args, "id": "call_retry"}])

Add a first line to decide that routes an error tool message to fix, and add import re at the top of the file.

Exampleticket_model.py, the first lines of decide
        if messages[-1].type == "tool" and messages[-1].text.startswith("Error"):
            return self.fix(messages)

fix pulls the allowed words out of the error, picks the one that appears in the lookup result, and calls Ticket again.

The question that now recovers

With error handling left on its default, the same question now recovers.

Example
agent = create_agent(TicketModel(), tools=[lookup_order], response_format=Ticket)
result = agent.invoke({"messages": [{"role": "user", "content": "Where is C40?"}]})

print(repr(result["structured_response"]))
print(result["messages"][-3].text[:70])

How the retry recovered

  • The ticket arrived with waiting, the allowed word found inside "waiting for stock".
  • Three messages from the end is the error the agent sent back: it names the bad value and asks the model to try again.
  • The first attempt is still in the conversation, so you can see a retry happened.

handle_errors False vs True

handle_errorsWhat happens on a bad value
FalseThe 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.
Watch out. 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.
Try it yourself
  • Ask about A17 and check which of the three words its status becomes.
  • Print result["messages"][-3].text in full and find the three allowed words in it.
  • Remove "waiting" from the Literal and see what fix chooses.

You understood something today that you didn't yesterday.