Chapter 4: Giving Models Tools
Intake has the O-1001 case open. Before Outreach can draft a status email to Jordan, ShopOps must load the live order, customer profile, and shipment record from systems that own those facts — not from whatever the model remembers from the prompt.
Without typed tools, the model guesses. It invents tracking numbers, skips preferred_channel, and drafts against facts that were never loaded. Policy cannot check required fields that never entered case state.
Intake → get_customer + get_order → facts in case state → Outreach draft_email
Tools are the agent’s actuators: named operations with schemas the model can see and the runtime can enforce. This chapter adds the tool registry — spec, handler, side-effect flags, scopes — so proposals stay grounded in real reads and reviewable writes.
First principles
A tool is a typed capability: one named operation with a contract the model can see and the runtime can enforce.
| Piece | Role |
|---|---|
| Name | Stable identifier in the action vocabulary |
| Description | Discoverability text for the model |
| Parameters | JSON-Schema-ish contract |
| Handler | Code that returns an Observation |
| Side-effect flag | Read vs write (feeds Ch 5) |
| Scopes | Authz vocabulary (feeds Ch 5 / 27) |
For O-1001, get_customer and get_order return evidence. draft_email produces a reviewable artifact. send_email changes the outside world. Those are different capabilities, even when they are all represented as tool calls.
The tool registry maps name → spec → handler. The spec is the shared contract: it tells the model how to ask and tells the runtime how to validate and dispatch.
Two error classes to keep separate:
- Model errors — wrong tool, wrong args, invented fields.
- Execution errors — handler/env failures (timeouts, 404s).
Both must become observations the loop can consume, not uncaught exceptions that kill the worker mid-case. The agent can then retry, choose another read, or escalate from an explicit state.
Two practical consequences follow:
- Natural-language argument blobs are not a production interface; use typed fields that validation can reject.
- More tools do not automatically add capability. They also add selection errors, permissions to protect, and behavior to test.
Concrete example
Registry with get_customer, list_order_notes, get_order, draft_email, … Intake lookup runs before any contact draft in the scripted policy from Ch 1.
Diagram
Registry dispatch:
sequenceDiagram
participant P as Policy / model
participant R as ToolRegistry
participant H as Handler
participant L as Loop / F
P->>R: validate_args(name, args)
alt unknown or invalid
R-->>P: error + known_tools
else ok
R->>H: handler(state, args)
H-->>R: Observation
R-->>L: Observation
end
Notice: validation happens before side effects.
Implementation
PYTHONPATH=. python examples/ch04_tools.py
shopops/tools/registry.py:
@dataclass
class ToolSpec:
name: str
description: str
parameters: dict[str, Any]
handler: Handler
side_effect: bool = False
scopes: frozenset[str] = field(default_factory=frozenset)
class ToolRegistry:
def register(self, spec: ToolSpec) -> None: ...
def validate_args(self, name: str, args: dict) -> str | None: ...
def schemas_for_model(self) -> list[dict]: ...
Unknown tool returns a useful error:
unknown_tool:teleport; known=['draft_email', 'get_order', ...]
draft_email requires body: string. Missing required fields fail closed.
Side-effect awareness starts here: get_customer is read; send_email and add_order_note are writes. Chapter 5 will refuse to treat those equally.
Walkthrough: profile before draft
For O-1001, the teaching policy refuses to draft until get_customer has returned. That is not politeness; it is grounding. Without the tool, the model happily invents “Alex” and a wrong order total. With the tool, State.customer_name becomes "Jordan Lee" via (F), and the draft references real fields.
What the model sees (via schemas_for_model) should match what handlers accept. Drift between the two is a class of production bug: you update the handler, forget the schema, and the model keeps sending obsolete arguments that validate against the old contract in docs but fail at runtime — or worse, validate loosely and do the wrong thing.
Discoverability is an API design problem
Tool descriptions are UX for a stochastic client. Good descriptions:
- State preconditions (“call after case is loaded”)
- State side effects (“does not send; returns draft body only”)
- Disambiguate siblings (“use this for order total, not for proposed refund amounts”)
Bad descriptions:
- Marketing fluff (“powerfully retrieves customer essence”)
- Overlap without contrast
- Secret requirements not listed in
required
If two tools cannot be distinguished from their schemas alone, merge them or rename until a careful engineer — and thus a careful model — can choose.
Observations over exceptions
Across the loop boundary, prefer:
Observation(ok=False, error="missing_required:body")
over raising KeyError inside the worker. Raises are fine inside handlers if the registry catches and translates them; uncaught raises skip (F), skip checkpoints, and leave the FSM lying.
Failure modes
Ambiguous twins. get_user vs get_customer vs fetch_account_profile — the model oscillates.
Schema lies. Description says “returns email” but handler never does; the model fabricates.
Exception leakage. Handler raises; worker dies; checkpoint incomplete (Ch 8).
Argument soup. Passing prose blobs instead of typed fields ("amount": "about four hundred").
Tool sprawl. Fifty tools in one agent; selection accuracy collapses; debug cost explodes.
Production considerations
- Generate model-facing schemas from the same
ToolSpecobjects handlers use — one source of truth. - Include examples in descriptions sparingly; prefer crisp contracts.
- Version tool schemas; pin versions in traces.
- Return structured errors (
missing_required,type_error,unknown_tool). - Count tool selection mistakes in evals (Part VI), not only final email quality.
- Prefer a small tool surface per agent/module; split by permission domain later (Part V / X).
Chapter summary
- Tools are actuators; schemas and descriptions are program text.
- Registry = name → schema → handler.
- Validate before execute; convert failures to observations.
- Separate model errors from execution errors.
- Mark side effects early.
- Ambiguity and sprawl are design bugs.
- Intake before contact draft for O-1001.
- Code:
shopops/tools/registry.py,examples/ch04_tools.py.
Exercises
- Register two deliberately ambiguous tools (
get_order/fetch_owed_amount); log which the mock policy picks. - On unknown tool, always return
known_toolsin the observation error string; unit-test it. - Add typed errors for wrong JSON types (string where integer required).
- Remove half the tools from the registry and list which ShopOps flows still work — practice least surface.
References
- OpenAI / Anthropic tool-calling / function-calling documentation. [VERIFY SOURCE for current API shapes]
- JSON Schema specification — https://json-schema.org/ [VERIFY SOURCE for draft version you implement]
- Yao et al., 2022. ReAct. arXiv:2210.03629 — acting via tools as a research pattern.