Robust tools & structured I/O
An agent is only as reliable as the tools you hand it — and most agent failures in the wild are tool failures, not model failures. This advanced chapter is about making the tools themselves production-grade: writing schemas the model can actually understand, validating every argument the model sends before you execute (it can and will hallucinate one), pulling reliable structured JSON back out, and orchestrating parallel vs dependent calls without race conditions. You will finish able to design a tool that is clear, safe, and hard to misuse — the difference between a demo and something you can leave running.
pydantic or JSON-schema for validation and a provider's structured-output mode for JSON — we point at those but keep the runnable versions stdlib-only. Any numbers shown are illustrative.Learning objectives
- Design a tool schema the model can use correctly — because the description IS the prompt it reads.
- Validate and coerce every tool argument before executing; treat model args as untrusted input.
- Get reliable structured JSON out of a model, and parse/repair/re-ask when it doesn't conform.
- Tell independent tool calls (run together) from dependent ones (must be sequenced).
- Handle tool results well: truncate large output, return errors as structured data, stay idempotent.
- Apply tool-level safety — least privilege, allow-lists, dry-run/confirm, timeouts.
1 · Tool design — the schema is the prompt
Here is the single most important idea in this chapter: the tool's name and description are not documentation for you — they are the prompt the model reads to decide whether, when, and how to call it. A vague schema makes the model guess; a precise one makes the right call obvious. Good tool design is prompt engineering aimed at the model's tool-selection step.
| Principle | Bad | Good |
|---|---|---|
| Clear, specific name | do_lookup, handler | get_order_status — verb + noun, one job. |
| Description says WHEN to use it | “looks stuff up” | “Return the status of ONE order by id. Use when the user asks where an order is or if it shipped.” |
| Minimal params | Ten optional flags “just in case”. | Only what the job needs; fewer params = fewer wrong guesses. |
| Typed params | q: string (of what?) | order_id: string with a description and format. |
| Example in the schema | none | “Example: get_order_status(order_id='A-1001').” |
To make “what the model sees” concrete, here we render the same tool two ways and print the text the model actually consumes. The bad version is untyped and unexplained; the good one is unambiguous:
schema.py# The tool SCHEMA is the prompt the model reads to decide when/how to call it.
# A vague name and a fuzzy description make the model guess. Here we render
# the same tool two ways and show what the model effectively "sees".
bad = {
'name': 'do_lookup',
'description': 'looks stuff up',
'parameters': {'q': 'string', 'opt': 'string'}, # untyped, unexplained
}
good = {
'name': 'get_order_status',
'description': ('Return the current status of ONE order by its id. '
'Use when the user asks where an order is or if it shipped. '
"Example: get_order_status(order_id='A-1001')."),
'parameters': {
'order_id': {'type': 'string', 'required': True,
'description': "Order id like 'A-1001'."},
},
}
def render(schema):
lines = [f"{schema['name']}: {schema['description']}"]
for pname, pinfo in schema['parameters'].items():
if isinstance(pinfo, dict):
req = 'required' if pinfo.get('required') else 'optional'
lines.append(f" - {pname} ({pinfo['type']}, {req}): {pinfo['description']}")
else:
lines.append(f' - {pname} ({pinfo}, ?): ?')
return '\n'.join(lines)
print('WHAT THE MODEL SEES (bad):')
print(render(bad))
print('\nWHAT THE MODEL SEES (good):')
print(render(good))
WHAT THE MODEL SEES (bad):
do_lookup: looks stuff up
- q (string, ?): ?
- opt (string, ?): ?
WHAT THE MODEL SEES (good):
get_order_status: Return the current status of ONE order by its id. Use when the user asks where an order is or if it shipped. Example: get_order_status(order_id='A-1001').
- order_id (string, required): Order id like 'A-1001'.
tools / function-calling schema — the fields differ slightly, but the discipline is identical.2 · Input validation — never trust the model's args
The model chooses your tool's arguments, and it can get them wrong: a number as a string, an out-of-range value, a missing field, or an argument it simply hallucinated. Your tool boundary is an untrusted input boundary exactly like an HTTP request handler. Validate and coerce every argument before you execute; reject clearly so the error can flow back as a tool result the model can fix.
Real code uses pydantic or a JSON-schema validator here. To keep this runnable offline we use a small stdlib schema-dict validator that checks types, coerces a stringified int, applies range/choice rules, and fills defaults:
validate.py# A validate-and-coerce wrapper: NEVER trust the model's tool args.
# Real code uses pydantic / JSON-schema; this is a stdlib stand-in that
# checks types, ranges, and required fields, coercing where it safely can.
SCHEMA = {
'city': {'type': str, 'required': True},
'days': {'type': int, 'required': True, 'min': 1, 'max': 14},
'units': {'type': str, 'required': False, 'default': 'celsius',
'choices': ['celsius', 'fahrenheit']},
}
class ToolArgError(Exception):
pass
def validate(args, schema):
clean = {}
for name, rule in schema.items():
if name not in args:
if rule.get('required'):
raise ToolArgError(f'missing required arg: {name!r}')
clean[name] = rule.get('default')
continue
val = args[name]
want = rule['type']
# coerce a stringified number the model may have emitted ('3' -> 3)
if want is int and isinstance(val, str):
try:
val = int(val)
except ValueError:
raise ToolArgError(f'{name!r} must be an int, got {val!r}')
if not isinstance(val, want):
raise ToolArgError(f'{name!r} must be {want.__name__}, got {type(val).__name__}')
if 'min' in rule and val < rule['min']:
raise ToolArgError(f'{name!r}={val} below min {rule["min"]}')
if 'max' in rule and val > rule['max']:
raise ToolArgError(f'{name!r}={val} above max {rule["max"]}')
if 'choices' in rule and val not in rule['choices']:
raise ToolArgError(f'{name!r}={val!r} not in {rule["choices"]}')
clean[name] = val
return clean
# The model emitted these tool args. Note: days is the STRING '3' (it happens),
# and there is no 'units' key at all.
model_args = {'city': 'Berlin', 'days': '3'}
print('clean:', validate(model_args, SCHEMA))
# Now a hallucinated / out-of-range arg the model made up:
bad = {'city': 'Berlin', 'days': 99}
try:
validate(bad, SCHEMA)
except ToolArgError as e:
print('rejected:', e)
clean: {'city': 'Berlin', 'days': 3, 'units': 'celsius'}
rejected: 'days'=99 above max 14
The good args are cleaned: the string '3' becomes the int 3, and the missing units gets its default. The bad call is rejected with a message specific enough that the model — or a human — can correct it. Coerce what is safe, reject what is not, and always default the optional.
3 · Structured outputs — reliable JSON out of the model
Often you need the model to return machine-readable JSON, not prose — to route it, store it, or feed it to another tool. Left to freeform text, a model will wrap JSON in a code fence, add “Sure! Here is…”, or drift from your keys. Two real levers fix this: (1) provider structured-output / JSON mode that constrains generation to a schema, and (2) a parse → validate → repair/re-ask loop on your side. Use both; the loop below is the safety net.
Here we simulate two model replies (fixed strings). The first is a typical first-try failure — valid intent wrapped in a code fence, so json.loads chokes. On failure we would re-ask with the error appended; the second reply is clean and passes:
structured.py# Getting reliable JSON out of a model. Real APIs offer schema-constrained /
# 'structured output' modes; here we SIMULATE the model's reply as a fixed
# string, then parse + validate it and RE-ASK once if it doesn't conform.
import json
EXPECTED = {'sentiment': str, 'score': float, 'topics': list}
def validate_json(text, expected):
"""Parse text as JSON and check it matches the expected shape.
Returns (ok, value_or_error)."""
try:
obj = json.loads(text)
except json.JSONDecodeError as e:
return False, f'not valid JSON: {e.msg}'
if not isinstance(obj, dict):
return False, 'top level is not an object'
for key, typ in expected.items():
if key not in obj:
return False, f'missing key: {key!r}'
if not isinstance(obj[key], typ):
return False, f'{key!r} should be {typ.__name__}'
return True, obj
# Two SIMULATED model replies. The first is what a model often does on the
# first try: wraps JSON in prose / a code fence. The second is clean (the
# reply after we re-ask with a stricter instruction).
reply_1 = 'Sure! Here is the analysis:\n```json\n{"sentiment": "negative"}\n```'
reply_2 = '{"sentiment": "negative", "score": 0.82, "topics": ["latency", "billing"]}'
def ask_model(attempt):
return reply_1 if attempt == 0 else reply_2
result = None
for attempt in range(2):
ok, val = validate_json(ask_model(attempt), EXPECTED)
print(f'attempt {attempt}: ok={ok} -> {val}')
if ok:
result = val
break
# repair step: on failure we re-ask the model with the error appended
# so it can correct itself. Here attempt 1 returns clean JSON.
print('final:', result)
attempt 0: ok=False -> not valid JSON: Expecting value
attempt 1: ok=True -> {'sentiment': 'negative', 'score': 0.82, 'topics': ['latency', 'billing']}
final: {'sentiment': 'negative', 'score': 0.82, 'topics': ['latency', 'billing']}
4 · Parallel vs dependent tool calls
A model may request several tool calls at once. Some are independent — “get the weather” and “get the user profile” have nothing to do with each other and can run together. Others are dependent — “summarize the user's recent orders” needs the user's id first. Running dependents out of order gives you a race or a wrong answer; running independents one-at-a-time is just slow.
You don't need real async to reason about this. Model each call as a node with a list of dependencies and compute parallel groups: every call in a group has all its dependencies satisfied by an earlier group. (This is a topological sort — the same idea a build system uses.) We simulate execution and print the groups:
deps.py# A tiny dependency resolver. Some tool calls are INDEPENDENT (can run
# 'together'); some DEPEND on another's output and must be sequenced.
# No real async here — we simulate execution and show the parallel GROUPS.
# Each tool call: an id, the tool, and the ids it depends on.
calls = [
{'id': 'A', 'tool': 'get_user', 'deps': []},
{'id': 'B', 'tool': 'get_weather', 'deps': []}, # independent of A
{'id': 'C', 'tool': 'get_orders', 'deps': ['A']}, # needs the user
{'id': 'D', 'tool': 'summarize', 'deps': ['B', 'C']}, # needs both
]
def plan_groups(calls):
"""Return a list of GROUPS; every call in a group can run in parallel
because all its deps were satisfied by an earlier group (Kahn-style)."""
by_id = {c['id']: c for c in calls}
done, groups = set(), []
remaining = list(by_id)
while remaining:
ready = [cid for cid in remaining
if all(d in done for d in by_id[cid]['deps'])]
if not ready:
raise ValueError('cycle or missing dependency')
ready.sort() # deterministic order within a group
groups.append(ready)
done.update(ready)
remaining = [c for c in remaining if c not in done]
return groups
groups = plan_groups(calls)
for i, g in enumerate(groups, 1):
kind = 'parallel' if len(g) > 1 else 'single'
print(f'group {i} ({kind}): {", ".join(g)}')
group 1 (parallel): A, B
group 2 (single): C
group 3 (single): D
Group 1 runs A and B together (both have no deps). C waits for A; D waits for both B and C. In a real system you'd launch each group with a thread pool or async gather and only advance when it finishes. The resolver also catches cycles — if two calls depended on each other, ready would be empty and we raise instead of looping forever.
5 · Tool result handling
What comes back from a tool matters as much as what goes in. Three habits keep the loop healthy: truncate or summarize large results before feeding them back (a 50 KB blob wastes context and buries the signal), return errors as structured data the model can reason about (not a raw stack trace or a silent empty string), and make retried tools idempotent so a re-run doesn't act twice.
results.py# Handling tool RESULTS before they go back to the model:
# (1) truncate/summarize large output,
# (2) return errors as STRUCTURED results the model can reason about,
# (3) idempotency so a retried tool doesn't act twice.
MAX_CHARS = 80
def wrap_result(tool, ok, data):
"""Normalize every tool result into the same small dict the model reads."""
if not ok:
return {'tool': tool, 'status': 'error', 'error': data}
text = str(data)
if len(text) > MAX_CHARS:
text = text[:MAX_CHARS] + f'... [truncated, {len(str(data))} chars total]'
return {'tool': tool, 'status': 'ok', 'result': text}
# A large successful result gets truncated before it wastes the context budget:
big = 'customer record ' * 200
print(wrap_result('db_query', True, big))
# A failure becomes a structured error the model can act on (retry / apologize):
print(wrap_result('charge_card', False, 'gateway timeout after 30s'))
# Idempotency: a retried tool with the same key must NOT run its effect twice.
_seen = {}
def run_once(key, effect):
if key in _seen:
return {'status': 'cached', 'result': _seen[key]}
_seen[key] = effect()
return {'status': 'ran', 'result': _seen[key]}
charge = lambda: 'charged $50'
print(run_once('order-42', charge)) # first call runs the effect
print(run_once('order-42', charge)) # retry is served from cache, no double charge
{'tool': 'db_query', 'status': 'ok', 'result': 'customer record customer record customer record customer record customer record ... [truncated, 3200 chars total]'}
{'tool': 'charge_card', 'status': 'error', 'error': 'gateway timeout after 30s'}
{'status': 'ran', 'result': 'charged $50'}
{'status': 'cached', 'result': 'charged $50'}
Notice the shape: every result — success, truncated, or error — comes back in the same small dict, so the model always knows how to read it. The idempotency key means the second run_once returns the cached result with status: 'cached' and never charges twice, which is exactly what you want when an agent retries a flaky call.
{'status': 'error', 'error': …}, and return it. Now the model can reason: retry, try a different tool, or tell the user honestly. Structured errors turn a crash into a recoverable step.6 · Sandboxing — safe tool execution
Tools are where an agent touches the real world — and where it can do real damage: delete files, spend money, hit production. The model is a probabilistic component driving those levers, so you put the safety in the tool layer, not in a hope that the model behaves. Four tool-level controls carry most of the weight (deeper human-in-the-loop and org security come later, in 4.4 and 4.5):
| Control | What it does | Example |
|---|---|---|
| Least privilege | A tool can do only its one job — no shared god-credential. | A read tool gets read-only DB creds; it physically cannot write. |
| Allow-list | Constrain what the tool may touch (paths, hosts, accounts). | delete_file may only act under /tmp/. |
| Dry-run / confirm | Destructive actions are simulated until explicitly confirmed. | Default dry_run=True; a real delete needs confirm=True too. |
| Timeouts | Every tool has a wall-clock cap so one hung call can't stall the agent. | A network tool aborts after N seconds and returns a structured error. |
Here is a declarative policy gate that enforces the first three (timeouts are a wrapper concern we note rather than simulate, since the labs are deterministic). Every tool call passes through guard(); an unknown tool, a path outside its allow-list, or a destructive action left in dry-run is refused:
sandbox.py# Tool-execution safety. A tool can delete files, spend money, or hit prod.
# Give each tool LEAST PRIVILEGE, gate destructive actions behind a confirm /
# dry-run, and allow-list what it may touch. (Timeouts noted in prose.)
# Declarative policy: what each tool is allowed to do.
POLICY = {
'read_file': {'destructive': False, 'paths': ['/data/']},
'delete_file': {'destructive': True, 'paths': ['/tmp/']},
'send_email': {'destructive': True, 'paths': []},
}
class Denied(Exception):
pass
def guard(tool, target=None, *, confirm=False, dry_run=True):
rule = POLICY.get(tool)
if rule is None:
raise Denied(f'tool {tool!r} is not on the allow-list')
# path allow-list: the target must sit under an allowed prefix
if rule['paths'] and target is not None:
if not any(target.startswith(p) for p in rule['paths']):
raise Denied(f"{tool!r} may not touch {target!r} (allowed: {rule['paths']})")
# destructive actions must be confirmed AND not left in dry-run
if rule['destructive'] and not (confirm and not dry_run):
return {'tool': tool, 'target': target, 'action': 'SKIPPED (needs confirm, not dry-run)'}
return {'tool': tool, 'target': target, 'action': 'EXECUTED'}
# 1. safe read under an allowed path:
print(guard('read_file', '/data/report.csv'))
# 2. destructive delete but still a dry-run -> not executed:
print(guard('delete_file', '/tmp/scratch.log', confirm=True, dry_run=True))
# 3. same delete, confirmed and dry-run off -> executes:
print(guard('delete_file', '/tmp/scratch.log', confirm=True, dry_run=False))
# 4. path outside the allow-list -> denied:
try:
guard('delete_file', '/etc/passwd', confirm=True, dry_run=False)
except Denied as e:
print('DENIED:', e)
# 5. a tool nobody granted -> denied:
try:
guard('run_shell', 'rm -rf /')
except Denied as e:
print('DENIED:', e)
{'tool': 'read_file', 'target': '/data/report.csv', 'action': 'EXECUTED'}
{'tool': 'delete_file', 'target': '/tmp/scratch.log', 'action': 'SKIPPED (needs confirm, not dry-run)'}
{'tool': 'delete_file', 'target': '/tmp/scratch.log', 'action': 'EXECUTED'}
DENIED: 'delete_file' may not touch '/etc/passwd' (allowed: ['/tmp/'])
DENIED: tool 'run_shell' is not on the allow-list
Denied. That is the whole game: default-deny, grant narrowly, and make destructive actions prove intent (confirm) and prove they're real (not dry-run). A model that hallucinates rm -rf / hits a wall, not your disk.The model sends your set_temperature tool {'celsius': '250'}. What should happen before anything executes, and why?
Show answer
'250' to an int, then range-check it — 250°C is almost certainly out of a safe band, so reject with a specific error that goes back as a tool result. The model chooses arguments and can be wrong or out of range; the tool boundary is untrusted input and must defend itself.Two tool calls: get_exchange_rate and convert(amount, rate). Can they run in parallel?
Show answer
convert depends on the output of get_exchange_rate (the rate), so it must be sequenced after it. Only calls with no dependency on each other's output — like two unrelated lookups — belong in the same parallel group.🪜 Practice — harden the tools until they're hard to misuse beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
In validate.py, add a required lang string arg to SCHEMA with no default. Re-run and confirm the good call now fails until you supply it.
Show solution
Add'lang': {'type': str, 'required': True}. The first validate(model_args, …) now raises missing required arg: 'lang' because model_args has no lang. Add 'lang': 'en' to model_args and it passes again.
Extend the validator so a str like '0.82' is coerced to float when the rule's type is float, mirroring the int case.
Show solution
Add a branch:if want is float and isinstance(val, str): val = float(val) inside a try/except that raises ToolArgError on failure. Now a stringified score validates cleanly.
In structured.py, write a strip_fence() that removes a leading “Sure! …” prefix and ```json/``` fences, then feed reply_1 through it before parsing. Does attempt 0 now parse?
Show solution
Regex-strip the fence and any prose before the first{: e.g. take the substring from the first { to the last }. After stripping, reply_1 parses to {'sentiment': 'negative'} — valid JSON, but it still fails schema (missing score/topics), which is the correct outcome and shows why you validate the shape, not just the syntax.
In deps.py, make A depend on D (so A↔D form a cycle via C/D) and confirm plan_groups raises instead of looping forever. Explain the check that catches it.
Show solution
Set A's deps to['D']. Now no call is ever ready once the acyclic ones are gone, ready is empty while remaining is not, and the guard raises cycle or missing dependency. That empty-ready-but-work-left condition is the classic topological-sort cycle test.
In results.py, replace blunt truncation with a summarize() that, for a large result, returns a short synopsis (e.g. line count + first line) instead of a character slice. When is summarizing better than truncating?
Show solution
Computelines = text.splitlines() and return e.g. f'{len(lines)} lines; first: {lines[0]}'. Summarizing preserves the meaning the model needs (how big, what kind) where a character slice might cut mid-token and lose the gist — better for logs, query results, or search hits.
Combine sections 2, 5, and 6: write execute(tool, args) that validates args, runs guard(), executes only if allowed, and wraps the outcome with wrap_result() — so a bad arg, a denial, and a success all return the same result shape the model reads.
Show solution
Chain them:try: clean = validate(args, schema); g = guard(tool, clean.get('target')); … except (ToolArgError, Denied) as e: return wrap_result(tool, False, str(e)). Every path — validation failure, policy denial, success — now yields a uniform {'tool', 'status', …} dict. That single funnel is what a production tool dispatcher looks like.
Context: Your team is exposing an internal delete_snapshot(id) tool to an agent that customers can prompt. A teammate wants to ship it with just the happy path.
Your task: Write a short design note (6–9 sentences) arguing for the guardrails this tool needs before it goes near production, grounded in this chapter's six ideas.
Requirements:
- State that the schema/description must make clear it is destructive and one-snapshot-only, so the model doesn't over-call it.
- Require argument validation — reject an id that doesn't match the expected format rather than deleting whatever string arrives.
- Apply least privilege + allow-list: the tool's credentials can only delete snapshots, only in permitted accounts.
- Gate the action behind dry-run/confirm and give it a timeout; return the outcome as a structured result, and make it idempotent on the snapshot id so a retry can't double-delete.
- Name which control you'd consider non-negotiable and why.
💡 Hint: You don't need working code — this is about the reasoning. The next chapters (4.4 guardrails/observability, 4.5 production orchestration + human-in-the-loop) build the org-level and workflow-level safety on top of these tool-level ones.
✓ Checkpoint — you can move on when you can…
- A tool's name and description are the prompt the model reads — clear, specific, typed, with an example.
- Never trust the model's args: validate, coerce what's safe, reject what isn't, and default the optional.
- Get structured JSON via provider JSON-mode plus a parse → validate → repair/re-ask loop.
- Split independent tool calls (run in parallel groups) from dependent ones (must be sequenced).
- Handle results well: truncate/summarize large output, return structured errors, stay idempotent.
- Put safety in the tool layer: least privilege, allow-lists, dry-run/confirm, timeouts — default-deny.