↑ Contents Chapter 4 of 12

Chapter 4: Tools β€” Giving the Agent Hands

TomΓ‘s is helping his aunt Rosa run her small bookshop. Rosa has a spreadsheet of 2,000 books β€” title, author, price, stock. A customer walks in and asks, "Do you have anything by Ursula K. Le Guin under €15?" Rosa opens the spreadsheet, scrolls, filters by author, then by price. Thirty seconds. The customer smiles. TomΓ‘s watches this and thinks: an agent could do that. The agent would read the question, search the spreadsheet, and answer. But here's the thing β€” the agent can't search a spreadsheet on its own. It's a brain. It needs a tool. A function that can query the inventory. "TΓ­a Rosa," he says, "I'm going to give your shop an agent. But first I need to give it hands."

Why This Matters

By the end of this chapter, you'll be able to design a tool from scratch β€” write the function, describe it so the LLM knows when to use it, handle errors gracefully, and let the agent choose between multiple tools on its own. You'll also understand the one design principle that separates tools that work from tools that confuse the model: the description is for the LLM, not for a human.

A Tool Is Just a Function

Let's strip away the mystery. A tool is a Python function. It takes arguments, returns a result. That's it. The "tool" part isn't special β€” it's the description you write alongside it that lets the LLM know it exists and when to call it.

The function def search_books(...) takes args, returns string This is just code. The description name, what it does, what args it needs This is what the LLM reads. +
A tool = a function + a description. The function does the work; the description tells the brain when to use it.

In Chapter 3, our calculator tool was a function called calculate that took an expression string. Now let's build a real one for Rosa's bookshop: search_books.

Building a Real Tool

Step 1: Write the function

# A simple in-memory book inventory for our demo BOOKS = [ {"title": "The Left Hand of Darkness", "author": "Ursula K. Le Guin", "price": 12.50, "stock": 3}, {"title": "A Wizard of Earthsea", "author": "Ursula K. Le Guin", "price": 9.99, "stock": 5}, {"title": "Dune", "author": "Frank Herbert", "price": 14.00, "stock": 2}, {"title": "The Dispossessed", "author": "Ursula K. Le Guin", "price": 15.50, "stock": 0}, ] def search_books(author: str = "", max_price: float = None) -> str: """Search the book inventory by author and/or max price.""" results = BOOKS if author: results = [b for b in results if author.lower() in b["author"].lower()] if max_price is not None: results = [b for b in results if b["price"] <= max_price] if not results: return "No books found." # Return a readable string the LLM can work with lines = [f"- {b['title']} by {b['author']}, €{b['price']}, {b['stock']} in stock" for b in results] return "\n".join(lines)
// The function does the real work. It returns a STRING β€” always. The LLM reads strings, not objects.
Note Tools return strings, not Python objects. The LLM reads the result as text β€” that's all it understands. If your tool returns a list or a dict, stringify it first (JSON is a good format). The tool's job is to turn real-world data into text the brain can reason about.

Step 2: Describe it for the LLM

Now the crucial part. The description is not documentation for a human developer. It's instructions for the LLM β€” telling it what this tool does, when to use it, and what arguments to provide. Write it like you're explaining it to a smart intern who has never seen your codebase.

tools = [ { "type": "function", "function": { "name": "search_books", "description": ( "Search the bookshop inventory. Use when a customer asks about " "books by author or price. Returns matching books with title, " "author, price, and stock count." ), "parameters": { "type": "object", "properties": { "author": { "type": "string", "description": "Author name to filter by, partial match. Optional." }, "max_price": { "type": "number", "description": "Maximum price in euros. Optional." } }, "required": [] } } } ]
// The description tells the LLM WHEN to use it. The parameter descriptions tell it WHAT to provide.

The Description Is Everything

Here's the principle that will save you hours of debugging: the LLM decides whether to call a tool based entirely on the description. It can't read your function body. It can't infer what the tool does from its name alone. It reads the description string and decides: "Is this relevant to what the user asked?"

Compare two descriptions for the same function:

# Bad description β€” the LLM won't know when to use it "Search books." # Good description β€” the LLM knows exactly when and how "Search the bookshop inventory. Use when a customer asks about " "books by author or price. Returns matching books with title, " "author, price, and stock count."

With "Search books," the LLM might use it when someone asks "search for the word 'books' in this essay" β€” wrong tool, wrong time. With the good description, it knows: this is for bookshop inventory queries. The description is the LLM's only map of what the tool does. Make it precise.

Watch it! The most common tool failure is a vague description. If your agent isn't calling a tool when it should β€” or calling the wrong tool β€” check the description first. Nine times out of ten, the LLM simply didn't understand what the tool was for. Fix the description, not the code.
Sharpen your pencil

Here's a function for a tool that sends an email. Write a good description for it β€” one that tells the LLM when to use it and what each argument means. Then compare with the answer below.

def send_email(to: str, subject: str, body: str) -> str

A good answer: "Send an email to a recipient. Use when the user explicitly asks to send, email, or forward a message. 'to' is the email address, 'subject' is the email subject line, 'body' is the full email content." β€” Note the word "explicitly." You don't want the agent sending emails on a whim.

Multiple Tools: The Agent Chooses

Here's where it gets fun. An agent can have many tools, and the LLM decides which to use based on the user's request. Let's give Rosa's agent two tools: search_books and calculate (for totalling up a customer's order).

# The tool dispatch β€” same pattern as Chapter 3, now with two tools def run_tool(name: str, args: dict) -> str: if name == "search_books": return search_books(**args) elif name == "calculate": return calculate(**args) else: return f"Unknown tool: {name}"

Now watch what happens with different questions. The agent picks the right tool on its own:

# Question 1 User: "Do you have anything by Le Guin under €15?" Agent THINK: "This is a book search. I'll use search_books." Agent ACT: search_books(author="Le Guin", max_price=15) Agent OBSERVE: "- A Wizard of Earthsea by Ursula K. Le Guin, €9.99, 5 in stock\n" "- The Left Hand of Darkness by Ursula K. Le Guin, €12.50, 3 in stock" Agent THINK: "I have the answer. Two books match." Agent: "Yes! I found two: A Wizard of Earthsea (€9.99) and The Left Hand of Darkness (€12.50)." # Question 2 User: "If I buy both of those, what's the total?" Agent THINK: "9.99 + 12.50. I should use the calculator." Agent ACT: calculate("9.99 + 12.50") Agent OBSERVE: "22.49" Agent: "Both together would be €22.49."
// The agent chose search_books for the first question, calculate for the second. Nobody told it which to use.

The agent didn't need an if-statement saying "if the question is about books, call search_books." The LLM read both tool descriptions, understood the user's intent, and picked the right one. This is the autonomy from Chapter 1, now with real consequences.

The LLM sees all tool descriptions and picks search_books "Search inventory..." calculate "Evaluate math..." send_email "Send an email..." LLM reads the question and decides which tool fits
All three tools are available. The LLM picks based on the user's question and the tool descriptions.

When Tools Fail: Error Handling

Tools fail. A database is down. An API returns an error. The LLM passes bad arguments. The question isn't if β€” it's when, and what you do about it.

The good news: the agent loop is self-correcting by design. If a tool returns an error message, that message goes back into the conversation as the tool result. The LLM reads "Error: connection refused" and can decide what to do β€” retry, try a different tool, or tell the user something went wrong.

# A tool that might fail β€” and handles it gracefully def search_books(author: str = "", max_price: float = None) -> str: try: # ... search logic ... if not results: return "No books found matching your search." return "\n".join(lines) except Exception as e: return f"Search failed: {e}. Please try again or rephrase."
// Return a helpful error string. The LLM reads it and adapts. Never let a tool crash silently.
Watch it! Never let a tool raise an unhandled exception inside the agent loop. If it does, the whole loop crashes and the user gets a traceback. Catch errors, turn them into readable strings, and return those. The LLM can work with "Error: database unreachable" β€” it can't work with a Python stack trace dumped into the void.
There Are No Dumb Questions
Q: How many tools can an agent have?
A: Technically, dozens. Practically, the more tools you give the LLM, the harder it is for it to pick the right one β€” every tool description is extra text it has to reason over. Start with the fewest tools that get the job done. If you find yourself adding a 15th tool, consider whether some should be combined, or whether you actually need a multi-agent setup (Chapter 9).
Q: Can a tool call other tools? Like, can search_books internally call calculate?
A: A tool is just a function β€” it can call whatever Python code you want. But be careful: if a tool calls another tool that calls the LLM, you've got nested loops, and things get complicated fast. Keep tools simple and single-purpose. Let the agent loop orchestrate; let tools execute.
Q: What if the LLM calls a tool I didn't define?
A: It can't. The LLM can only call tools you described in the tools list. It can't invent a tool. If it tries to call something that doesn't exist, your else: return f"Unknown tool: {name}" branch catches it, and the error goes back to the LLM, which will try something else.

Where People Come Unstuck

Mistake #1: Returning objects instead of strings

Your tool returns a dict, a list, a custom object. The LLM gets... a stringified Python repr like {'title': 'Dune', 'price': 14.0}. It can sort of read it, but it's messy and error-prone. Always return a clean, human-readable string. If you need structure, use JSON β€” but plain text is usually better for the LLM to reason about.

Mistake #2: Vague descriptions

We've hit this twice now because it's the #1 cause of tool problems. "Search books" is not a description. "Search the bookshop inventory by author or price, returning matching titles with stock counts" is. The LLM only knows what you tell it.

Mistake #3: Tools that do too much

A tool called do_everything that searches books, calculates totals, sends emails, and updates inventory. The LLM can't reason about when to use it because it does everything and nothing. Keep tools single-purpose. One tool, one job. The agent loop handles combining them.

Brain Power

Rosa's bookshop needs three more tools. For each, sketch out: the function signature, a good description, and one scenario where the agent would use it.

1. add_to_cart β€” adds a book to a customer's order.
2. check_stock β€” returns how many copies of a specific title are in stock.
3. place_order β€” finalises a cart and returns an order number.

Think about: what arguments does each need? How would you describe each so the LLM knows when to use it? Which of these might the agent chain together β€” and in what order?

Chapter Summary

  • A tool is a Python function plus a description. The function does the work; the description tells the LLM when to use it.
  • Tools always return strings. The LLM reads text, not objects. If you need structure, use JSON or plain readable text.
  • The description is the most important part of a tool. It tells the LLM what the tool does and when to use it. Write it for the LLM, not for a human developer.
  • An agent can have multiple tools. The LLM reads all the descriptions and picks the right one based on the user's request β€” no if-statements needed.
  • Tools fail, and that's fine. Return error messages as strings; the LLM reads them and adapts. Never let a tool crash the loop silently.
  • Keep tools single-purpose. One tool, one job. Let the agent loop orchestrate; let tools execute.
Chapter Challenge

The Bookshop Agent. Combine the agent loop from Chapter 3 with the search_books and calculate tools from this chapter. Run it against Rosa's inventory with this conversation:

1. "Do you have any books by Le Guin under €15?"
2. "What about Frank Herbert?"
3. "If I buy one Le Guin and one Herbert, what's the total?"
4. "And how many of each are left in stock?"

Watch the agent pick the right tool for each question, chain the results together, and answer naturally. Then add one more tool β€” check_stock(title) β€” and watch it learn to use it for question 4 without you changing the loop. That's the power of tools: add one, and the agent just gets smarter.

← Previous Next β†’