Multi-Step Reasoning with Chained Tool Calls — PokéAPI¶
This notebook demonstrates the LLMAgent performing multi-step reasoning by chaining sequential tool calls.
We ask the agent to compare two Pokémon across a specific stat. To answer, it must call get_pokemon twice — once per Pokémon — then synthesise both results into a final answer.
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
from llm_agents_from_scratch.notebook_utils import make_llm
Defining the Tool¶
import json
import urllib.error
import urllib.request
from llm_agents_from_scratch.tools.simple_function import SimpleFunctionTool
def get_pokemon(name: str) -> str:
"""Look up a Pokémon by name and return its types and base stats."""
url = f"https://pokeapi.co/api/v2/pokemon/{name.lower().strip()}"
req = urllib.request.Request(
url,
headers={"User-Agent": "llm-agents-from-scratch/1.0"},
)
try:
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404: # noqa: PLR2004
raise ValueError(
f"Pokémon '{name}' not found. "
"Check the spelling and try again.",
) from e
raise
types = [t["type"]["name"] for t in data["types"]]
stats = {s["stat"]["name"]: s["base_stat"] for s in data["stats"]}
return json.dumps({"name": data["name"], "types": types, "stats": stats})
get_pokemon_tool = SimpleFunctionTool(func=get_pokemon)
Example 1 — Comparing Speed¶
The agent needs to look up both Pokémon before it can answer, making two sequential tool calls.
import logging
from llm_agents_from_scratch.logger import enable_console_logging
enable_console_logging(logging.INFO)
from llm_agents_from_scratch import LLMAgent
from llm_agents_from_scratch.data_structures import Task
llm = make_llm()
agent = LLMAgent(llm=llm, tools=[get_pokemon_tool])
task = Task(
instruction=(
"Which Pokémon has higher base speed: Pikachu or Gengar? "
"Use the get_pokemon tool for each — do not rely on prior knowledge."
),
)
result = await agent.run(task)
✓ Using Ollama Cloud (kimi-k2.7-code:cloud)
INFO (llm_agents_fs.LLMAgent) : 🚀 Starting task: Which Pokémon has higher base speed: Pikachu or Gengar? Use the get_pokemon tool for each — do not rely on prior knowledge.
INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: Which Pokémon has higher base speed: Pikachu or Gengar? Use the get_pokemon tool for each — do not rely on prior knowledge.
INFO (llm_agents_fs.TaskHandler) : 🛠️ Executing Tool Call: get_pokemon
INFO (llm_agents_fs.TaskHandler) : 🛠️ Executing Tool Call: get_pokemon
INFO (llm_agents_fs.TaskHandler) : ✅ Successful Tool Call: {"name": "pikachu", "types": ["electric"], "stats": {"hp": 35, "attack": 55, "defense": 40, "special-attack": 50, "special-def...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : ✅ Successful Tool Call: {"name": "gengar", "types": ["ghost", "poison"], "stats": {"hp": 60, "attack": 65, "defense": 60, "special-attack": 130, "spec...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: According to the tool results:
- **Pikachu** has a base Speed of **90**.
- **Gengar** has a base Speed of **110**.
**Gengar** has the...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : No new step required.
INFO (llm_agents_fs.LLMAgent) : 🏁 Task completed: According to the tool results:
- **Pikachu** has a base Speed of **90**.
- **Gengar** has a base Speed of **110**.
**Gengar** has ...[TRUNCATED]
print(result)
According to the tool results: - **Pikachu** has a base Speed of **90**. - **Gengar** has a base Speed of **110**. **Gengar** has the higher base Speed.
Example 2 — Comparing Attack and Defense¶
A second comparison task to reinforce the chaining pattern — this time across two different stats.
llm = make_llm()
agent = LLMAgent(llm=llm, tools=[get_pokemon_tool])
task = Task(
instruction=(
"Compare Bulbasaur and Charmander. "
"Which has higher attack and which has higher defense? "
"Use the get_pokemon tool for each — do not rely on prior knowledge."
),
)
result = await agent.run(task)
✓ Using Ollama Cloud (kimi-k2.7-code:cloud)
INFO (llm_agents_fs.LLMAgent) : 🚀 Starting task: Compare Bulbasaur and Charmander. Which has higher attack and which has higher defense? Use the get_pokemon tool for each — do not re...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: Compare Bulbasaur and Charmander. Which has higher attack and which has higher defense? Use the get_pokemon tool for each — do not...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : 🛠️ Executing Tool Call: get_pokemon
INFO (llm_agents_fs.TaskHandler) : 🛠️ Executing Tool Call: get_pokemon
INFO (llm_agents_fs.TaskHandler) : ✅ Successful Tool Call: {"name": "bulbasaur", "types": ["grass", "poison"], "stats": {"hp": 45, "attack": 49, "defense": 49, "special-attack": 65, "sp...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : ✅ Successful Tool Call: {"name": "charmander", "types": ["fire"], "stats": {"hp": 39, "attack": 52, "defense": 43, "special-attack": 60, "special-defe...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: Based on the tool results:
- **Bulbasaur**: Attack 49, Defense 49
- **Charmander**: Attack 52, Defense 43
So:
- **Higher Attack:** Ch...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : No new step required.
INFO (llm_agents_fs.LLMAgent) : 🏁 Task completed: Based on the tool results:
- **Bulbasaur**: Attack 49, Defense 49
- **Charmander**: Attack 52, Defense 43
So:
- **Higher Attack:**...[TRUNCATED]
print(result)
Based on the tool results: - **Bulbasaur**: Attack 49, Defense 49 - **Charmander**: Attack 52, Defense 43 So: - **Higher Attack:** Charmander (52 vs. 49) - **Higher Defense:** Bulbasaur (49 vs. 43)