Multi-Tool Registry¶
This notebook builds a small tool registry — a dict keyed by tool
name — over several SimpleFunctionTool instances, then shows how to
dispatch an incoming ToolCall to the correct tool by name.
Coming up in Chapter 4: you'll build an
LLMAgentthat manages a collection of tools and routes each tool call the LLM makes to the right one automatically. The registry pattern you see here is exactly how it does that under the hood.
No LLM is involved — this is pure Chapter 2 tool mechanics.
Setting up the LLM¶
I recommend running these notebooks on Ollama Cloud. It serves much larger models than most laptops can run. The default cloud model is picked by benchmarking candidates on these notebooks, and an automated run checks every notebook against it each week. The notebook status page shows the current model and results.
- Create an API key at https://ollama.com/settings/keys.
- Export it before launching Jupyter:
export OLLAMA_API_KEY=<your key>. - That's all. When a notebook builds its LLM, it detects the key and uses Ollama Cloud.
Prefer to run locally? Install Ollama from https://ollama.com/download
and pull the model with ollama pull qwen3:14b. If no key is set, the
notebook starts the Ollama service for you, so there is no need to run
ollama serve. Local models are smaller, so your outputs will differ from
the recorded ones.
Prefer OpenAI or Anthropic?
- Install the extra:
uv sync --extra openaioruv sync --extra anthropic. - Export
OPENAI_API_KEYorANTHROPIC_API_KEYbefore launching Jupyter. If you forget, you will be prompted for it, which keeps the key out of the saved notebook. - Set
LLM_PROVIDER=openai(oranthropic), or passprovider="openai"tomake_llm(). A key on its own never switches providers, so one exported for unrelated work cannot reroute you.
The switch applies wherever a notebook builds its LLM with make_llm().
A notebook that constructs OllamaLLM directly stays on Ollama. Output on
gpt-5 or Claude will differ from the recorded outputs, and prompt-sensitive
examples (the ch09 evaluator pattern, ch08 supervised trajectories) may
behave noticeably differently.
# Uncomment the line below to install `llm-agents-from-scratch` from PyPI
# !pip install llm-agents-from-scratch
Defining the Tools¶
We define four simple utility functions and wrap each in a
SimpleFunctionTool.
import math
from llm_agents_from_scratch.tools import SimpleFunctionTool
def celsius_to_fahrenheit(celsius: float) -> float:
"""Convert a temperature from Celsius to Fahrenheit."""
return celsius * 9 / 5 + 32
def word_count(text: str) -> int:
"""Count the number of words in a string."""
return len(text.split())
def circle_area(radius: float) -> float:
"""Calculate the area of a circle given its radius."""
return round(math.pi * radius**2, 4)
def reverse_string(text: str) -> str:
"""Reverse the characters of a string."""
return text[::-1]
tools = [
SimpleFunctionTool(func=celsius_to_fahrenheit),
SimpleFunctionTool(func=word_count),
SimpleFunctionTool(func=circle_area),
SimpleFunctionTool(func=reverse_string),
]
for t in tools:
print(f" {t.name}")
celsius_to_fahrenheit word_count circle_area reverse_string
Building the Registry¶
A registry is just a dict keyed by tool name. You'll see this exact
one-liner again in Chapter 4 when we build the LLM agent.
tools_registry = {t.name: t for t in tools}
print("Registry keys:", list(tools_registry.keys()))
Registry keys: ['celsius_to_fahrenheit', 'word_count', 'circle_area', 'reverse_string']
Dispatching Tool Calls¶
Given a list of ToolCall objects, look up each tool by name and invoke it.
This is the core dispatch pattern that an LLM agent will later automate. For
now we drive it manually so the mechanics are fully visible.
from llm_agents_from_scratch.data_structures import ToolCall
tool_calls = [
ToolCall(
tool_name="celsius_to_fahrenheit",
arguments={"celsius": 100.0},
),
ToolCall(
tool_name="word_count",
arguments={"text": "the quick brown fox jumps over the lazy dog"},
),
ToolCall(
tool_name="circle_area",
arguments={"radius": 5.0},
),
ToolCall(
tool_name="reverse_string",
arguments={"text": "agents"},
),
]
for tc in tool_calls:
tool = tools_registry[tc.tool_name]
result = tool(tc)
print(f" {tc.tool_name}({tc.arguments}) → {result.content}")
celsius_to_fahrenheit({'celsius': 100.0}) → 212.0
word_count({'text': 'the quick brown fox jumps over the lazy dog'}) → 9
circle_area({'radius': 5.0}) → 78.5398
reverse_string({'text': 'agents'}) → stnega
Handling an Unknown Tool Name¶
When a tool name isn't in the registry, rather than raising an exception,
we should instead return a ToolCallResult with error=True so that the
information reaches the LLM. With that context the LLM can understand what
went wrong and decide what to do next, such as correcting a misspelled tool
name or choosing a different tool entirely. (You'll learn about LLM tool-calling
in the next chapter!)
import json
from llm_agents_from_scratch.data_structures import ToolCallResult
def dispatch(tc: ToolCall) -> ToolCallResult:
"""Dispatch a ToolCall, returning an error result for unknown tools."""
tool = tools_registry.get(tc.tool_name)
if tool is None:
return ToolCallResult(
tool_call_id=tc.id_,
content=json.dumps(
{
"error_type": "UnknownToolError",
"message": (
f"Tool '{tc.tool_name}' not found. "
f"Available: {list(tools_registry.keys())}"
),
},
),
error=True,
)
return tool(tc)
unknown_tc = ToolCall(
tool_name="get_weather",
arguments={"city": "London"},
)
result = dispatch(unknown_tc)
print(f"error: {result.error}")
print(f"content: {result.content}")
error: True
content: {"error_type": "UnknownToolError", "message": "Tool 'get_weather' not found. Available: ['celsius_to_fahrenheit', 'word_count', 'circle_area', 'reverse_string']"}