promptdojo_

Validate tool inputs — when the model invents arguments — step 3 of 9

Three checks every validator does

A real Pydantic model encodes three things the runtime checks before your code sees the input. Hand-rolled or framework-generated, the shape is the same:

1. Required fields

Did every required field arrive? args["q"] raises KeyError if not, so check up front and return a clean error message instead.

2. Types

Is each present field the expected Python type? isinstance(value, expected) covers most cases — str, int, float, bool, list, dict. For more elaborate types (list[int], optional fields, nested models) production code uses Pydantic's full type system; the bare-metal version covers the cases you actually see in tool inputs.

3. Value constraints

Even when the type is right, the value can be wrong. page_size: int doesn't catch page_size: 99999999. Encode the constraint (1 ≤ page_size ≤ 100) and check it.

In Pydantic, this is Annotated[int, Field(ge=1, le=100)] or a @field_validator. In the hand-rolled version above, it's a lambda that returns (ok, message).

What makes validation worth doing

Validation isn't free. It runs on every tool call. The reason it's non-negotiable in production: the alternative is debugging the agent loop from a stack trace five frames deep, while the user is waiting. A model that called your tool with the wrong field name would otherwise crash silently inside the tool's first DB query — your trace shows "DatabaseError on row 0" and you'd waste an hour chasing the wrong layer.

With validation, the trace says tool_use search → invalid args: missing required field q. You see exactly where it went wrong, and critically: the model sees it too, in the next tool_result block, and tries again with corrected input.

That's the load-bearing piece. Validation isn't just a guard rail — it's a teaching channel back to the model. Return the error as the tool result, the model usually self-corrects on the next turn. Without it, you crash, lose context, and the user starts over.

Where this fits in the loop

for block in response.content:
    if block.type == "tool_use":
        verdict = validate(block.input, TOOL_SCHEMAS[block.name])
        if verdict["ok"]:
            output = TOOLS[block.name](**verdict["args"])
        else:
            output = f"VALIDATION ERROR: {verdict['error']}"
        tool_results.append({
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": output,
        })

The shape of the loop doesn't change. One validate() call between "the model said run this tool" and "we ran the tool." If the verdict is bad, the tool never runs and the model gets the error message instead.

read, then continue.