Chapter 4 — The LLMAgent class¶
Setup Instructions¶
To ensure you have the required dependencies to run this notebook, you'll need to have our llm-agents-from-scratch framework installed on the running Jupyter kernel. To do this, you can launch this notebook with the following command while within the project's root directory:
uv run --with jupyter jupyter lab
Alternatively, if you just want to use the published version of llm-agents-from-scratch without local development, you can install it from PyPi by uncommenting the cell below.
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
Examples¶
Example 1: Instantiating an LLMAgent¶
from llm_agents_from_scratch import LLMAgent
llm = make_llm()
llm_agent = LLMAgent(
llm=llm,
)
✓ Using Ollama Cloud (deepseek-v4.1-flash)
llm_agent.tools
[]
Example 2: Demo usage of add_tool()¶
from llm_agents_from_scratch.tools import SimpleFunctionTool
def add_one(x: int) -> int:
"""A dummy tool for adding one to the supplied number."""
return x + 1
tool = SimpleFunctionTool(func=add_one)
llm_agent.add_tool(tool)
llm_agent.tools
[<llm_agents_from_scratch.tools.simple_function.SimpleFunctionTool at 0x70f00e98e660>]
Example 3: The Hailstone LLM Agent¶
LOGGING_ENABLED = True
import logging
from llm_agents_from_scratch.logger import enable_console_logging
if LOGGING_ENABLED:
enable_console_logging(logging.INFO)
Define the Hailstone tool¶
This is an adapted version of the Hailstone tool from Chapter 2. Since LLMs have been pretrained on a corpus that includes information on the Hailstone sequence, they may rely on their parametric knowledge to perform the task rather than using the provided tool.
One way to force tool-calling is to obfuscate the function details and omit any mention of the Hailstone sequence. This ensures our demonstration shows the LLM agent actually using tools.
from pydantic import BaseModel
from llm_agents_from_scratch.tools import PydanticFunctionTool
class AlgoParams(BaseModel):
"""Params for next_number."""
x: int
def next_number(params: AlgoParams) -> int:
"""Generate the next number of the sequence."""
if params.x % 2 == 0:
return params.x // 2
return 3 * params.x + 1
# convert our Python function to a BaseTool
tool = PydanticFunctionTool(next_number)
Define our backbone LLM¶
llm = make_llm()
✓ Using Ollama Cloud (deepseek-v4.1-flash)
Define the LLMAgent¶
from llm_agents_from_scratch import LLMAgent
llm_agent = LLMAgent(
llm=llm,
tools=[tool],
)
The Hailstone Task¶
from llm_agents_from_scratch.data_structures import Task
instruction_template = """
You are given a tool, `next_number`, that generates the next number in the
sequence given the current number.
Start with the number x={x}.
<rules>
CALL `next_number` on the current number x
STOP AND WAIT for the result.
REPEAT this step-by-step process until the number 1 is reached.
FINAL RESULT: When you receive the number 1, provide the complete sequence you
observed from start to finish (including the starting number x and ending number
1).
</rules>
<warnings>
NEVER fabricate or simulate tool call results
NEVER make multiple tool calls in one response
STOP and WAIT - ALWAYS wait for the actual tool response before deciding next
steps
</warnings>
""".strip()
Running the Task¶
number = 4
sequence = [4, 2, 1] # correct Hailstone sequence
task = Task(
instruction=instruction_template.format(x=number),
)
handler = llm_agent.run(task, max_steps=5)
INFO (llm_agents_fs.LLMAgent) : 🚀 Starting task: You are given a tool, `next_number`, that generates the next number in the
sequence given the current number.
Start with the number ...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: You are given a tool, `next_number`, that generates the next number in the
sequence given the current number.
Start with the numb...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : 🛠️ Executing Tool Call: next_number
INFO (llm_agents_fs.TaskHandler) : ✅ Successful Tool Call: 2
INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: I need to make the following tool-calls:
{
"id_": "17259c76-9a5c-45d5-9550-7f47ba25c61d",
"tool_name": "next_number",
"argu...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : 🧠 New Step: Execute the next tool call: use tool `next_number` with argument x=2, then stop and wait for the actual tool result before deciding the ne...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: Execute the next tool call: use tool `next_number` with argument x=2, then stop and wait for the actual tool result before decidin...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : 🛠️ Executing Tool Call: next_number
INFO (llm_agents_fs.TaskHandler) : ✅ Successful Tool Call: 1
INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: The tool returned 1, which means we've reached the end of the sequence.
The complete sequence observed from start to finish:
**4 → 2 →...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : No new step required.
INFO (llm_agents_fs.LLMAgent) : 🏁 Task completed: The tool returned 1, which means we've reached the end of the sequence.
The complete sequence observed from start to finish:
**4 → ...[TRUNCATED]
handler.done()
False
# number of sub-steps taken
handler.step_counter
1
The TaskResult¶
Upon successful task execution, the final TaskResult object is set as the result for the TaskHandler (an asyncio.Future).
result = handler.result() if not handler.exception() else str(handler.exception())
print(result)
The tool returned 1, which means we've reached the end of the sequence. The complete sequence observed from start to finish: **4 → 2 → 1**
# Alternative execution style (if you don't want/need the handler)
# result = await llm_agent.run(task, max_steps=5)
The Rollout¶
The rollout attribute of the TaskHandler sheds light on the steps that the LLMAgent took to perform its task.
print(handler.rollout)
=== Task Step Start ===
💬 assistant: My current instruction is 'You are given a tool, `next_number`, that generates the next number in the
sequence given the current number.
Start with the number x=4.
<rules>
CALL `next_number` on the current number x
STOP AND WAIT for the result.
REPEAT this step-by-step process until the number 1 is reached.
FINAL RESULT: When you receive the number 1, provide the complete sequence you
observed from start to finish (including the starting number x and ending number
1).
</rules>
<warnings>
NEVER fabricate or simulate tool call results
NEVER make multiple tool calls in one response
STOP and WAIT - ALWAYS wait for the actual tool response before deciding next
steps
</warnings>'
💬 assistant: I need to make the following tool call(s):
{
"id_": "4851a8b8-b42f-4df2-b192-484f6772a13e",
"tool_name": "next_number",
"arguments": {
"x": 4
}
}.
🔧 tool: {
"tool_call_id": "4851a8b8-b42f-4df2-b192-484f6772a13e",
"content": "2",
"error": false
}
💬 assistant: I need to make the following tool call(s):
{
"id_": "17259c76-9a5c-45d5-9550-7f47ba25c61d",
"tool_name": "next_number",
"arguments": {
"x": 2
}
}.
=== Task Step End ===
=== Task Step Start ===
💬 assistant: My current instruction is 'Execute the next tool call: use tool `next_number` with argument x=2, then stop and wait for the actual tool result before deciding the next step.'
💬 assistant: I need to make the following tool call(s):
{
"id_": "4964aed2-c139-49d4-8e07-687d4d51fca6",
"tool_name": "next_number",
"arguments": {
"x": 2
}
}.
🔧 tool: {
"tool_call_id": "4964aed2-c139-49d4-8e07-687d4d51fca6",
"content": "1",
"error": false
}
💬 assistant: The tool returned 1, which means we've reached the end of the sequence.
The complete sequence observed from start to finish:
**4 → 2 → 1**
=== Task Step End ===