In Post #2 we gave the LLM tools and ran the loop manually — one tool call, get the result, done. That works, but it's still us doing the driving. A real agent drives itself.
That's what this post is about. We're going to build something that takes a task, figures out what it needs to do, calls the right tools, and keeps going until it has an answer — all without us stepping in between.
The difference between a chatbot and an agent
A chatbot responds to one message at a time. You ask, it answers, and that's the end of the interaction. An agent is different — it has a task to complete, and it keeps working until that task is done.
The simplest way to see this: give an agent a question that requires multiple steps to answer.
"What is the population of the capital of the country that hosted the 2020 Olympics, divided by 1000?"
A chatbot either guesses or gives up. An agent works through it:
Step 1: search("2020 Olympics host country") → Japan
Step 2: search("capital of Japan") → Tokyo
Step 3: search("population of Tokyo") → ~13.96 million
Step 4: calculate("13960000 / 1000") → 13960
Final Answer: 13960
No human in the loop between steps. The agent chained four tool calls on its own.
The ReAct pattern
The loop that makes this possible is called ReAct — short for Reasoning and Acting. It was published by Google in 2022 and it's still the foundation that most agent systems are built on.
Thought: what do I need to do?
│
▼
Action: call a tool
│
▼
Observation: tool result
│
▼
Thought: do I have the answer yet?
├── No → loop back
└── Yes → Final Answer
Each iteration gets added to the conversation history, so the agent always has the full picture of what it's tried and what it's learned before deciding the next step.
Stopping conditions
An agent needs to know when to stop — without that, it just runs forever. There are two ways it stops.
The natural way is when the model decides it has enough information and produces a final answer instead of another tool call. You detect that in code and exit the loop.
The safety net is max_iterations — a hard cap on how many steps the agent can take. If it hasn't finished by then, you stop it and handle the incomplete state. Always add this. It's the single most important guardrail in agent code.
Setup
pip install ollama chromadb
Use qwen2.5 for these exercises — it handles tool selection reliably:
ollama pull qwen2.5
Exercise 1 — ReAct agent from scratch
Let's build the loop manually so every piece is visible:
import ollama
def calculate(expression: str) -> str:
try:
return str(eval(expression))
except Exception as e:
return f"Error: {e}"
def search(query: str) -> str:
# Simulated search — swap with a real API in production
knowledge = {
"capital of france": "Paris",
"capital of japan": "Tokyo",
"capital of australia": "Canberra",
"population of paris": "2.1 million in the city, 12 million in greater Paris",
"population of tokyo": "approximately 13.96 million in the city",
"population of canberra": "approximately 460,000",
"2020 olympics host": "Japan hosted the 2020 Summer Olympics in Tokyo",
"eiffel tower height": "330 metres including the antenna",
"python creator": "Python was created by Guido van Rossum",
}
query_lower = query.lower().strip()
for key, value in knowledge.items():
if key in query_lower or query_lower in key:
return value
return "No information found for that query."
TOOLS = {"calculate": calculate, "search": search}
tools = [
{
"type": "function",
"function": {
"name": "calculate",
"description": "Perform arithmetic. Use for any math calculation.",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "A Python math expression e.g. '(4 * 7) + 3'"}
},
"required": ["expression"]
}
}
},
{
"type": "function",
"function": {
"name": "search",
"description": "Look up factual information: geography, people, events.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "The thing to look up"}
},
"required": ["query"]
}
}
}
]
def run_agent(user_task: str, max_iterations: int = 10):
print(f"\nTask: {user_task}")
print("=" * 60)
messages = [
{
"role": "system",
"content": (
"You are a helpful assistant with access to tools. "
"Use tools to look up information and perform calculations. "
"Keep using tools until you have enough to give a complete answer. "
"When you have the final answer, state it clearly."
)
},
{"role": "user", "content": user_task}
]
for i in range(max_iterations):
print(f"\n[Iteration {i + 1}]")
response = ollama.chat(model="qwen2.5", messages=messages, tools=tools)
if response.message.tool_calls:
messages.append(response.message)
for tool_call in response.message.tool_calls:
name = tool_call.function.name
args = tool_call.function.arguments
print(f" Action: {name}({args})")
result = TOOLS[name](**args)
print(f" Observation: {result}")
messages.append({"role": "tool", "content": result})
else:
print(f"\nFinal Answer: {response.message.content}")
return response.message.content
print("\n[Max iterations reached]")
return None
run_agent("What is the population of the capital of France?")
run_agent("Who created Python, and what is 2 to the power of 10?")
run_agent("What country hosted the 2020 Olympics, and what is its capital?")
Watch the iteration log as it runs. You'll see the agent chain tool calls naturally — it searches, gets a result, decides what to search next, and keeps going until it has everything it needs.
Exercise 2 — Adding RAG as a tool
In Post #3 we built retrieval. Now let's give it to the agent as a tool — so the agent can search a knowledge base on its own:
import ollama
import chromadb
client = chromadb.Client()
collection = client.create_collection("agent_kb")
docs = [
"Python was created by Guido van Rossum and first released in 1991.",
"PostgreSQL is a powerful open-source relational database system.",
"Docker is a platform for containerising applications and their dependencies.",
"Kafka is a distributed event streaming platform for high-throughput messaging.",
"FastAPI is a modern Python web framework for building APIs quickly.",
"Redis is an in-memory data store commonly used for caching and queues.",
"Integration tests typically require a running database and external services.",
"Static analysis tools check code for style issues and potential bugs without running it.",
]
for i, doc in enumerate(docs):
emb = ollama.embeddings(model="nomic-embed-text", prompt=doc).embedding
collection.add(ids=[str(i)], embeddings=[emb], documents=[doc])
def calculate(expression: str) -> str:
try:
return str(eval(expression))
except Exception as e:
return f"Error: {e}"
def search_knowledge_base(query: str) -> str:
emb = ollama.embeddings(model="nomic-embed-text", prompt=query).embedding
results = collection.query(query_embeddings=[emb], n_results=3)
docs = results["documents"][0]
return "\n".join(f"- {d}" for d in docs) if docs else "No relevant information found."
TOOLS = {"calculate": calculate, "search_knowledge_base": search_knowledge_base}
tools = [
{
"type": "function",
"function": {
"name": "calculate",
"description": "Perform arithmetic or math calculations.",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "Python math expression"}
},
"required": ["expression"]
}
}
},
{
"type": "function",
"function": {
"name": "search_knowledge_base",
"description": "Search the internal knowledge base for information about "
"the knowledge base — tech topics, tools, and concepts.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "What to look up"}
},
"required": ["query"]
}
}
}
]
def run_agent(user_task: str, max_iterations: int = 8):
print(f"\nTask: {user_task}")
print("=" * 60)
messages = [
{
"role": "system",
"content": "You are a helpful assistant. Use the knowledge base to answer questions. "
"Use calculate for math. Answer clearly when you have enough information."
},
{"role": "user", "content": user_task}
]
for i in range(max_iterations):
response = ollama.chat(model="qwen2.5", messages=messages, tools=tools)
if response.message.tool_calls:
messages.append(response.message)
for tool_call in response.message.tool_calls:
name = tool_call.function.name
args = tool_call.function.arguments
print(f" → {name}({args})")
result = TOOLS[name](**args)
print(f" ← {result[:120]}")
messages.append({"role": "tool", "content": result})
else:
print(f"\nAnswer: {response.message.content}")
return
print("[Max iterations reached]")
run_agent("What database is commonly used in Python web services?")
run_agent("What do I need to run integration tests?")
run_agent("What is Redis used for, and what is 2024 - 2016?")
This is where Posts #2, #3, and #4 all come together — tool use, retrieval, and the agent loop working as one system.
Exercise 3 — Reading the reasoning trace
This exercise adds visibility into what the model is actually thinking between tool calls:
def run_agent_verbose(user_task: str, max_iterations: int = 8):
print(f"\nTask: {user_task}")
print("=" * 60)
messages = [
{
"role": "system",
"content": "You are a helpful assistant with tools. "
"Think step by step. Use tools when needed. "
"State your final answer clearly."
},
{"role": "user", "content": user_task}
]
for i in range(max_iterations):
print(f"\n--- Step {i + 1} | {len(messages)} messages in context ---")
response = ollama.chat(model="qwen2.5", messages=messages, tools=tools)
if response.message.content:
print(f"Thought: {response.message.content}")
if response.message.tool_calls:
messages.append(response.message)
for tool_call in response.message.tool_calls:
name = tool_call.function.name
args = tool_call.function.arguments
print(f"Action: {name}({args})")
result = TOOLS[name](**args)
print(f"Observation: {result}")
messages.append({"role": "tool", "content": result})
else:
print(f"\n✓ Final Answer: {response.message.content}")
return
print("\n[Stopped: max iterations reached]")
Notice two things as it runs. First, the context size grows with every step — every action and observation gets added to the message history. Second, try setting max_iterations=2 on a task that needs four steps and watch what happens when it gets cut off. That's why the cap matters, and why you'd want to handle that incomplete state gracefully in a real application.
Wrapping up
An agent is not magic. It's the same tool loop from Post #2, just running autonomously inside a loop with a stopping condition. The ReAct pattern gives it structure — reason, act, observe, repeat — and the system prompt tells it how to behave.
The two things that matter most: always have a max_iterations cap, and write good tool descriptions (as we covered in Post #2). Everything else follows from those.
In Post #5, we tackle the one thing this agent still can't do — remember anything between runs. See you there. 🚀
Top comments (0)