What you'll learn: how the tool-calling loop works for one read-only tool, how to validate the model's arguments before anything runs, and how to turn a failure into an error the model can correct.
At a glance
Figure 1. The model proposes, the code validates and runs
How to read this: the model only proposes; the Orchestrator and everything to its right is ordinary code that validates, reads and reports.
- A tool call is a proposal. The model never runs anything.
- The argument schema is the contract: code validates every call first.
- A tool runs with the caller's access rights.
- A validation failure goes back to the model as text it can act on.
- Every loop has a limit.
Scenario
Composite scenario, not a real company. A Lumen Industrial Supply sales engineer asks the copilot about a specific product, and the answer needs its full record. The team gives the model one tool that reads a record by product code. In testing, the model sends vs-100 instead of LUM-VS-100. Nothing crashes and nothing is invented, because the code checks the argument first.
What you will be able to do
- Define a read-only tool with a Pydantic argument model and expose its schema.
- Validate a call and return an error that names the failed rule without echoing the input.
- Write a bounded loop that runs a tool and feeds the result back.
- Map the capstone's loop onto
tool_useandtool_resultmessages.
Prerequisites
Calling a provider gives the complete contract this loop calls. Structured outputs and schema validation explains the repair retry it reuses. For tool design and MCP, read Agents, tools and MCP.
Core ideas
Who does what in one tool call
Predict first. The model proposes get_product_spec with the argument {"sku": "vs-100"}. The real code is LUM-VS-100. What does your code hand back: an exception, an empty record, or text? Does it contain vs-100?
Answer
Text, and it does not contain vs-100. The code returns Invalid arguments: sku: String should match pattern '^LUM-[A-Z]{2}-\d{3}
#39;: the field and the rule, not the submitted value, which may hold private text.
| Step | Who | What happens |
|---|---|---|
| 1. Propose | Model | Names a tool and gives arguments |
| 2. Validate | Code | Checks the name against a registry and the arguments against a schema |
| 3. Authorize | Code | Uses the caller's visibility, so a hidden product looks like a missing one |
| 4. Run | Code | Reads the record; a failure becomes an error result |
| 5. Report | Code | Sends the result or error back; the model picks the next step |
The schema is the contract
The capstone's tool takes one argument; the pattern is the defense against a malformed code:
Verified: capstone/fde-product-discovery-copilot/app/workflow/tools.py
class GetProductSpecArgs(BaseModel):
sku: str = Field(pattern=r"^LUM-[A-Z]{2}-\d{3}quot;)
Verified: capstone/fde-product-discovery-copilot/app/workflow/tools.py
def get_product_spec(args: GetProductSpecArgs, ctx: ToolContext) -> dict[str, Any]:
product = ctx.catalog.get(args.sku, ctx.visibilities)
if product is None: # same answer for "does not exist" and "not allowed"
raise LookupError(f"No product {args.sku} found.")
return {**_summary(product), "specs": product.specs}
The same Pydantic model validates the call and produces the JSON Schema you show the model. A tool definition is a name, a description and an input_schema, which model_json_schema() returns:
Verified: capstone/fde-product-discovery-copilot/lessons/M2-L5/m2_l5_one_tool.py
def tool_definition(tool: Tool) -> dict[str, Any]:
"""A tool as the Messages API describes it: name, description and a JSON Schema for the input."""
return {"name": tool.name, "description": tool.description, "input_schema": tool.args_model.model_json_schema()}
The provider also documents a strict: true option that keeps calls matching the schema. Use it, and still validate in code: a schema checks shape, not who is asking, and limits such as limit at most 10 are yours to enforce.
Validate, run, never raise
Verified: capstone/fde-product-discovery-copilot/lessons/M2-L5/m2_l5_one_tool.py
def instructive_error(err: ValidationError) -> str:
"""Where the problem is and which rule failed, so the model can correct it. Never the submitted value."""
return "Invalid arguments: " + "; ".join(f"{'.'.join(map(str, e['loc']))}: {e['msg']}" for e in err.errors())
def validated_call(tool: Tool, raw_args: dict[str, Any], ctx: ToolContext) -> ToolResult:
try:
args = tool.args_model.model_validate(raw_args)
except ValidationError as err:
return ToolResult(False, error=instructive_error(err))
try:
return ToolResult(True, data=tool.run(args, ctx))
except Exception as err: # a failing tool must not end the loop
return ToolResult(False, error=f"{type(err).__name__}: {err}")
validated_call has three outcomes and none raises: valid arguments produce data; invalid arguments produce text naming the field and rule; a failing tool (an unknown or hidden SKU, say) produces LookupError: .... The capstone's ToolRegistry.call names only the field; this version adds the rule, as the provider advises.
The error text goes into the next prompt, so it is part of your prompt-injection surface. Build it from your own schema messages, never from user or document text.
Two ways to carry the same loop
The capstone asks the model, in plain text, to reply with a JSON decision, and validates it as PlanDecision: