3 · Tools and their schemas
What this step adds: the tools, their JSON schemas, and the registry that maps names to functions. The model can now ask for things. Nothing answers yet.
The new idea
Section titled “The new idea”In Part 1 the schema was generated for you — you passed the function object and the library read its docstring and type hints. Part 2 writes the schemas by hand, and the difference is worth understanding.
A tool schema is the JSON description of a function that gets sent to the model: its name, what it does, and what arguments it takes. It is the only thing the model ever sees. Your function body is invisible to it.
SCHEMAS = { "get_customer_profile": { "type": "function", "function": { "name": "get_customer_profile", "description": "Retrieve the customer's profile: home country, " "average transaction amount, and when their contact " "details were last changed.", "parameters": { "type": "object", "properties": { "customer_id": {"type": "string", "description": "e.g. CUST-1001"}, }, "required": ["customer_id"], }, }, }, # ... one entry per tool}
TOOLS = { "get_customer_profile": get_customer_profile, "check_device_fingerprint": check_device_fingerprint, "get_transaction_history": get_transaction_history, "search_similar_cases": search_similar_cases,}Why write them by hand
Section titled “Why write them by hand”Because the description is the interface, and you want to control it.
Auto-generation ties the model’s view of a tool to your docstring. That is convenient right up until you want to change how the model understands a tool without changing how a developer reads the code — or until a refactor renames a parameter and silently changes what the model gets told.
Writing them out makes the contract explicit and reviewable. In a regulated
setting that matters for a blunt reason: SCHEMAS is the complete, auditable
list of everything this system can do. Someone can read that dictionary and
know the answer to “what actions can this agent take?” without reading any
Python.
Notice TOOLS and SCHEMAS are separate dictionaries with the same keys. That
is the Part 1 registry idea, unchanged — the model returns a string, and only
a key that exists in TOOLS becomes a callable. A name that isn’t there gets an
error message back, not an execution.
The four tools
Section titled “The four tools”| Tool | Returns |
|---|---|
get_customer_profile |
Home country, average transaction amount, contact-change date |
check_device_fingerprint |
Whether this device is known to this customer, trust score |
get_transaction_history |
Recent transactions, average amount |
search_similar_cases |
Past cases with overlapping signals, and how they resolved |
They all read from the fixtures and return dictionaries. There is no cleverness in them at all, and that is deliberate: a tool should be the least interesting code in the system. Every bit of judgment you put inside a tool is judgment that becomes invisible to the audit trail.
Run it
Section titled “Run it”cd part2uv run --with ollama run.pyThe audit output now shows the model requesting tools:
ENRICHMENT | tool_call | get_customer_profile({'customer_id': 'CUST-1001'})And then nothing. No result line, because nothing runs the request.
Why it worked
Section titled “Why it worked”The gap between tool_call and a result is the same gap Part 1 opened up, and
it is worth seeing again at this scale. The model produced JSON describing a
call it would like made. Your code has not agreed to make it.
Everything a bank would want to insert — a permission check, a rate limit, a “does this analyst have access to this customer” test — goes in that gap. Which is why the next step is not simply “call the function”.
What’s still wrong
Section titled “What’s still wrong”The requests go nowhere. Next: the loop that answers them, which you already know — and one line inside it that Part 1 never needed.