Session 4. Bounded tools — Thu 17 Sep

A tool is a contract

From text to action

Sessions 2 and 3 got a model to produce text you could parse. A tool is the next step: the model asks, your code decides and executes. That one word, executes, is why this session is about boundaries rather than capabilities.

@dataclass(frozen=True)
class Tool:
    name: str
    description: str
    run: Callable[..., str]

Three fields, and each one is doing a job. name is what the model asks for. description is the model's entire manual — it never reads your source. run is the function you wrote and you control.

The registry

tools = build_tools(documents, client)
for tool in tools.values():
    print(f"{tool.name:24} {tool.description}")
search_documents         Search the corpus for passages relevant to a query (max_results capped at 5).
get_document_metadata    Return title, source, tags, and length for a known doc_id.
summarize_document       LLM-summarize one document by doc_id (read-only).

Read those three lines as a contract, because that is what they are. The cap is stated. The precondition ("a known doc_id") is stated. The read-only promise is stated. A model that picks the wrong tool usually read a description that described nothing.

The narrow schema

A schema is narrow when there is exactly one way to call the tool correctly and the wrong ways are refusable on sight.

Wide Narrow
search(**kwargs) search_documents(query: str, max_results: int = 3)
"search anything, return everything" one corpus, at most five hits
read_file(path) get_document_metadata(doc_id) from a known set
fetch(url) one allow-listed host, https only

The last two rows are the same move twice: replace a free-text address with an identifier from a set you control. A doc_id cannot be ../../.env. A base currency validated as three uppercase letters cannot be a query-string injection. Argument shapes are your first security control, and they cost nothing at runtime.

Read-only first

Every tool today reads, searches, summarizes, or converts. Nothing writes, spends, or mutates. That is not timidity, it is blast radius: an injected instruction into a read-only system is an incident, into a write-capable one a breach. Session 13 builds the grown-up version of the same idea behind MCP.

The four-clause contract you write today

list_documents(tag=None) is exercise 1, and its contract is four clauses, because a contract is not "what it returns when everything goes right":

Call Behaviour
list_documents() every doc_id, one per line
list_documents(tag="retrieval") only documents carrying the tag
list_documents(tag="nope") ToolError naming the valid tags
list_documents(tag="") ToolError; validate at the boundary

Two happy paths, two refusals. ch04-e1 checks all four, and the two refusals are the half everyone forgets.

The same seam in a framework

Three optional scripts sit in this folder. They are the framework contrast, not the path this session teaches — read them after the lab, and notice how little of the boundary the framework carries for you.

Script What it shows
langchain_template.py init_chat_model — LangChain's version of the provider seam you built in session 2
langchain_api_intent.py an API operation name turned into an intent description: the description field above, written by a model
langchain_api_intent_gradio.py the same prompt behind a UI, one source of truth, two surfaces

Each one skips cleanly without a key and without LangChain installed. Neither the seam nor the generated description decides what a tool may do — the cap, the refusals and the allow-list in the next lesson still belong to you.