Validate a raw model tool call against its schema and retry once on a violation.
An agent runtime receives a raw tool call emitted by the model. Nothing may execute before it is validated.
Constraints:
- The tool name must be a registered tool; unknown names are rejected.
- Arguments must match the tool's declared schema — required fields present, types correct, no unknown fields.
- On a violation, return an error observation the model can read and allow at most one retry.
- Standard library only.
import json
TOOLS = {
"get_weather": {"required": {"city": str}, "optional": {"units": str}},
}
def handle_tool_call(raw: str, ask_model) -> dict:
# `ask_model(error_text)` returns a new raw call string.
# TODO: parse, validate against TOOLS, dispatch on success;
# on violation return an error observation and retry at most once.
...
Complete the implementation.
Parse the raw call, validate the tool name and every argument against the declared schema, dispatching only on success. On a violation, feed the error back as an observation, let the model retry once, then fail cleanly — never a partial call.
- ✗Dispatching a call whose arguments were never checked against the schema
- ✗Retrying forever instead of bounding the retries and failing cleanly
- ✗Returning a bare failure with no error text, so the model cannot correct itself
- →Why must the validation error text reach the model rather than only the logs?
- →Where would you bound retries so a bad call cannot burn the whole step budget?
Validation happens before anything executes: the name first, then required fields and types, then the absence of unknown fields. The error text goes back to the model as an observation — that is the step that gives it a chance to correct itself.
import json
def _validate(call: dict) -> str | None:
name = call.get("name")
spec = TOOLS.get(name)
if spec is None:
return f"unknown tool {name!r}; registered: {sorted(TOOLS)}"
args = call.get("arguments") or {}
allowed = {**spec["required"], **spec["optional"]}
for field, typ in spec["required"].items():
if field not in args:
return f"missing required argument {field!r}"
if not isinstance(args[field], typ):
return f"argument {field!r} must be {typ.__name__}"
for field in args:
if field not in allowed:
return f"unknown argument {field!r}"
return None
def handle_tool_call(raw: str, ask_model) -> dict:
for attempt in range(2):
try:
call = json.loads(raw)
except json.JSONDecodeError as exc:
error = f"call is not valid JSON: {exc.msg}"
else:
error = _validate(call)
if error is None:
return dispatch(call["name"], call.get("arguments") or {})
if attempt == 0:
raw = ask_model(error)
return {"status": "rejected", "error": error}
Exactly one retry is allowed, so a bad call cannot eat the step budget. A partially valid call is never dispatched — the handler returns a rejection carrying the reason.