DEV Community

Cover image for Strict Tool-Result Schemas: The Guardrail Agents Need Most
Gabriel Anhaia
Gabriel Anhaia

Posted on

Strict Tool-Result Schemas: The Guardrail Agents Need Most


You spent a week getting the tool schemas right. The model knows exactly what shape of arguments each tool wants. Every call the model makes is validated before it runs. You feel good about it.

Then a tool returns a 200 with a body that is half an HTML error page. Or a null where you promised a list. Or a stack trace the upstream service logged into its own JSON field. That output goes straight back into the context window as a tool result. The model reads it, gets confused, calls the tool again with a worse guess, gets another bad result, and now you have a loop that is technically succeeding at the API layer and accomplishing nothing.

Everyone guards the input side of tool calling. Almost nobody guards the output side. The tool result is the one piece of text in the whole loop you do not control the model's reaction to, and it is the one piece you let through unchecked.

The asymmetry nobody fixes

Tool calling has two seams. The model writes arguments, your code reads them. Your code writes a result, the model reads it. Both are JSON crossing a boundary between a probabilistic thing and a deterministic thing.

The first seam gets all the attention. You define a JSON schema, the SDK enforces it, a bad argument gets rejected before the tool runs. Anthropic's tool-use and OpenAI's function-calling both let you attach an input schema and validate against it.

The second seam gets nothing. The tool runs, returns whatever it returns, and you append that string to the message list. If the tool's contract says it returns {"temp_c": number, "conditions": string} and it actually returned {"error": "rate limited"}, the model finds out by reading it. You have handed an unverified payload to the one consumer in your system that will hallucinate a plan around it.

The fix is the mirror image of input validation. Schema-on-return. You decide the shape of every tool result before it re-enters context, and you enforce that shape the same way you enforce arguments.

Define the result schema next to the tool

The tool already declares the shape of its arguments. Have it declare the shape of its result too. Keep both next to the handler so they cannot drift apart.

from dataclasses import dataclass
from typing import Callable
from pydantic import BaseModel


class WeatherResult(BaseModel):
    temp_c: float
    conditions: str


@dataclass
class Tool:
    name: str
    handler: Callable
    result_model: type[BaseModel]


def get_weather(city: str) -> dict:
    # pretend this calls a flaky upstream
    return {"temp_c": 18.0, "conditions": "clear"}


weather_tool = Tool(
    name="get_weather",
    handler=get_weather,
    result_model=WeatherResult,
)
Enter fullscreen mode Exit fullscreen mode

The result_model is the contract. The handler can return whatever it returns; the contract is what you promise the model will see. Those two things are not the same, and pretending they are is the bug.

Validate on the way back

Wrap every tool call so the result passes through its schema before it becomes a message. Pydantic does the parsing; you decide what happens when parsing fails.

from pydantic import ValidationError


def run_tool(tool: Tool, args: dict) -> dict:
    try:
        raw = tool.handler(**args)
    except Exception as exc:
        return {
            "ok": False,
            "stage": "execution",
            "detail": f"tool raised: {exc}",
        }

    try:
        valid = tool.result_model.model_validate(raw)
    except ValidationError as exc:
        return {
            "ok": False,
            "stage": "schema",
            "detail": exc.errors(include_url=False),
            "raw": str(raw)[:500],
        }

    return {"ok": True, "data": valid.model_dump()}
Enter fullscreen mode Exit fullscreen mode

Two failure stages, kept separate. Execution failure means the tool threw. Schema failure means the tool returned, but returned a shape you never agreed to. The model needs to tell those apart, because the recovery is different. A thrown exception might be transient. A wrong shape from a 200 response usually is not.

Note the raw truncation. When a schema check fails you want a sample of what actually came back, capped, so the model has a clue without you dumping a 40KB HTML error page into the context window. That dump is exactly the garbage you are trying to keep out.

Repair before you reject

A failed schema check does not always mean you throw the result away. Some failures are mechanical and safe to fix in code, before the model ever sees them. Reserve rejection for what you genuinely cannot fix.

Repair the cases where the intent is obvious:

  • A number arrived as a string ("18.0" instead of 18.0). Coerce it.
  • A single object arrived where the schema wants a list of one. Wrap it.
  • Extra fields the schema does not know about. Drop them.
  • A known alias (temperature_c vs temp_c). Map it.
def repair(raw: dict, model: type[BaseModel]) -> dict | None:
    fixed = dict(raw)

    aliases = {"temperature_c": "temp_c"}
    for old, new in aliases.items():
        if old in fixed and new not in fixed:
            fixed[new] = fixed.pop(old)

    for name, field in model.model_fields.items():
        if field.annotation is float and isinstance(
            fixed.get(name), str
        ):
            try:
                fixed[name] = float(fixed[name])
            except ValueError:
                pass

    try:
        model.model_validate(fixed)
        return fixed
    except ValidationError:
        return None
Enter fullscreen mode Exit fullscreen mode

If repair returns a value, the result is clean and the model never knows there was a problem. If it returns None, you reject. Repair is deterministic Python; it does not call the model and does not cost a token. That is the whole point. You fix what you can prove, and you escalate the rest.

The line to hold: repair shape, never repair meaning. Coercing "18.0" to 18.0 is shape. Inventing a conditions value because the field was missing is meaning, and the moment you do that you have started lying to your own agent. A missing required field is a reject, every time.

What the model sees when you reject

When you reject, the tool result that goes back into context is a clean, structured error. Not the raw garbage. Not a silent empty string. A short message the model can act on.

import json


def to_tool_result(call_id: str, outcome: dict) -> dict:
    if outcome["ok"]:
        return {
            "type": "tool_result",
            "tool_use_id": call_id,
            "content": json.dumps(outcome["data"]),
        }

    return {
        "type": "tool_result",
        "tool_use_id": call_id,
        "content": (
            f"Tool result rejected at {outcome['stage']} "
            f"stage. The output did not match the expected "
            f"shape. Do not retry with the same arguments; "
            f"either adjust the call or proceed without "
            f"this data."
        ),
        "is_error": True,
    }
Enter fullscreen mode Exit fullscreen mode

The wording matters. "Do not retry with the same arguments" is the instruction that breaks the loop. Without it, the model reads a vague error, assumes bad luck, and fires the identical call again. You have seen this. The model is not stubborn; it just has no signal that repeating itself is pointless. Give it the signal.

Why this stops the feedback loop

Picture the loop with no return-side guard. Tool returns garbage. Garbage enters context. Model conditions its next move on garbage. Next move is worse. Each pass compounds the last because the context window is now polluted with output that never should have been there. This is garbage-in, garbage-amplified.

The return-side schema is a filter at the one chokepoint where you can still stop it. Bad output either gets repaired into good output, or gets replaced by a bounded, honest error that tells the model to change course. Either way, what enters the context window has a shape you agreed to. The model never reasons over a payload you would not have approved by hand.

This pairs with the circuit breaker pattern. The breaker counts failures and trips a tool that keeps failing. Schema-on-return is what produces a clean failure signal for the breaker to count. A tool that returns a 200 full of HTML looks like a success to a naive breaker. Run it through the schema first and it is correctly classified as a failure, which is the thing the breaker needed to know.

Where to put it

One wrapper around tool dispatch, applied to every tool, no exceptions. The temptation is to validate only the tools that talk to flaky upstreams and skip the ones that hit your own database. Skip that instinct. Your own database returns null on a missing row, your own service ships a migration that renames a field, and the tool you trusted is the one that poisons the context next quarter.

The cost is one Pydantic model per tool and one validation call per result. Against the cost of an agent that loops on garbage for a day and a half before anyone notices, that is not a trade you have to think about.

Next move

Take one agent you run today. List its tools. How many have a declared, enforced result schema? For most teams the honest answer is zero, because the SDK only nudged you toward the input side. Add a result_model to one tool this afternoon and route its output through a validate-repair-reject wrapper. Watch the next week of traces for that tool. The rejections you see are the garbage that used to enter your context window unannounced.


If this was useful

The AI Agents Pocket Guide covers the patterns around this one: validating both seams of a tool call, structured failure signals, and the recovery moves that pair with each. The chapter on tool design treats the result contract as seriously as the argument contract, which is the shift this post is arguing for.

AI Agents Pocket Guide

Top comments (0)