Build an AI Agent with Python and Ollama
Build a local AI agent from scratch that autonomously calls tools using Python.
Introduction
30 Second Summary
Every AI agent you interact with today follows the same core loop: call the model, check if it wants to use a tool, execute the tool, feed results back, and repeat. Whether it's a coding assistant, a customer service bot, or a research copilot, that pattern is identical.
In this project, you will build a fully functional AI agent from scratch using Python and a local LLM. No frameworks, no API keys, no cost.
What You'll Build
A command-line AI agent that autonomously decides which tools to call, chains multiple calls together, and produces a final answer using Ollama running locally on your machine.
By the end of this project, you'll have:
- A Python script implementing the complete agent loop pattern in roughly 80 lines of code.
- 4 custom tools the agent can invoke (time, math, file reading, system info).
- An interactive chat interface that maintains conversation history across multiple turns.
- Secret Mission: Add a web fetch tool that gives your agent access to live internet data.
Are there any prerequisites?
You should be comfortable with Python basics: functions, dictionaries, loops, and type hints. You'll also need Ollama already installed on your machine.
Before We Start
You're building an AI agent from scratch. No frameworks. No magic imports. Just Python, a local LLM, and the agent loop pattern that powers every AI agent in production today.
Before you write any agent code, you need three things in place: the qwen3 model downloaded locally, the ollama Python package installed, and a project folder ready to go.
In this step, get ready to:
- Pull the qwen3 model and verify Ollama is running.
- Install the ollama Python package.
- Create the project directory and verify the connection works.
Pull the qwen3 model
Ollama needs a model downloaded locally before it can respond to requests. qwen3 is Ollama's recommended model for tool calling and fits comfortably in 16GB RAM.
- Pull the qwen3 model by running this command:
ollama pull qwen3
What does this command do?
This downloads the qwen3 model files to your machine. It's a one-time download (around 5GB for the 8B parameter version). Ollama's server also starts automatically if it isn't already running.
- Wait for the download to finish. You'll see a progress bar for each model layer.
- Confirm the model is available by running:
ollama list
You should see qwen3 listed in the output table with its size and modification date.
Don't see qwen3 in the list?
Make sure Ollama is running. Look for the Ollama icon in your menu bar (macOS). If it's not there, open Ollama from your Applications folder.
If the pull command failed with a connection error, Ollama's server may not have started. Open a new terminal window and try the pull command again.
Help me troubleshoot why ollama pull qwen3 isn't working on my Mac
Install the ollama Python package
The ollama Python package gives you a clean API to communicate with your local Ollama server from Python code.
- Install the package by running:
pip install ollama
What does this install?
This installs the ollama Python package (v0.6.2 at time of writing). It provides the chat function and ChatResponse type that your agent will use to talk to the local model.
- Confirm the install succeeded. You should see output ending with Successfully installed ollama-... and a version number.
Seeing 'command not found' for pip?
On macOS, try pip3 install ollama instead. Some systems use pip3 for Python 3 packages.
Help me fix pip not being found on my Mac
Create the project directory and verify the connection
Your agent code will live in a single Python file. Let's create a dedicated folder for it and confirm everything is wired up.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir ai-agent
cd ai-agent
- Confirm the folder exists by running:
ls ~/Desktop
You should see ai-agent in the output.
- Verify that Python can connect to your local Ollama server by running this command:
python3 -c "from ollama import chat; r = chat(model='qwen3', messages=[{'role':'user','content':'Say hello in exactly 3 words'}], think=False); print(r.message.content)"
What does this test?
This one-liner imports the chat function, sends a short message to your local qwen3 model, and prints the response. If you see a short greeting back, everything is connected.
You should see a short response from the model (something like a three-word greeting). The exact words will vary each time.
Seeing a connection error?
Make sure Ollama is running. Check your menu bar for the Ollama icon. If it's not there, launch Ollama and try the command again.
If you see an import error for ollama, the package may have installed to a different Python version. Try pip3 install ollama and then run the test again.
Help me debug my Ollama Python connection
Your environment is ready. Next up, you'll define the tools that give your agent real capabilities.
Define Your Tools
Your development environment is connected and ready. Now it's time to give your agent its capabilities.
In AI agent terms, a "tool" is just a regular Python function that the model can decide to call on its own. You'll write four tools covering different kinds of tasks, then register them so the agent knows they exist.
In this step, get ready to:
- Write four Python tool functions that the agent can call.
- Create a tool registry that maps tool names to functions.
- Test each tool manually to confirm it works.
Write your tool functions
The Ollama Python SDK automatically converts Python functions with Google-style docstrings into JSON tool schemas. That means you don't need to write any schema definitions by hand.
Each function just needs type hints on its parameters and a docstring with Args and Returns sections.
- Create a new file called agent.py in your project directory.
- Add the imports and your first tool function by pasting this code:
from ollama import chat, ChatResponse
import datetime
import os
import platform
import json
# --- Tool Definitions ---
def get_current_time(timezone: str = "local") -> str:
"""Get the current date and time.
Args:
timezone: The timezone to get the time for. Use 'local' for system time.
Returns:
The current date and time as a string.
"""
now = datetime.datetime.now()
return now.strftime("%Y-%m-%d %H:%M:%S")
What does this code do?
The imports bring in everything your four tools will need. chat and ChatResponse come from the Ollama SDK and will power the agent loop in a later step.
The get_current_time function returns the current date and time as a formatted string. Its Google-style docstring tells the model what the function does, what arguments it accepts, and what it returns.
- Save agent.py.
- Confirm no syntax errors by running:
python3 -c "import agent"
If no output appears, the file is valid Python.
Seeing a ModuleNotFoundError?
Make sure your terminal is inside the project directory where agent.py lives. Run ls to confirm you can see the file listed.
help me debug a ModuleNotFoundError when importing agent
- Add the calculate function below get_current_time:
def calculate(expression: str) -> str:
"""Evaluate a mathematical expression safely.
Args:
expression: A mathematical expression to evaluate, e.g. '2 + 2' or '(10 * 5) / 2'.
Returns:
The result of the calculation as a string.
"""
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return "Error: Invalid characters in expression. Only numbers and +-*/.() are allowed."
try:
result = eval(expression)
return str(result)
except Exception as e:
return f"Error: {e}"
What does this code do?
This function evaluates math expressions like (10 * 5) / 2. It uses eval() but with a safety check first.
The allowed_chars set restricts input to digits, math operators, and parentheses. Any other character (like letters that could run arbitrary code) causes an error message instead of executing.
- Save agent.py. You'll test this function once all four tools are written.
- Add the read_file function below calculate:
def read_file(filepath: str) -> str:
"""Read the contents of a file.
Args:
filepath: The path to the file to read.
Returns:
The contents of the file as a string.
"""
try:
with open(filepath, "r") as f:
content = f.read()
if len(content) > 3000:
return content[:3000] + "\n... (truncated)"
return content
except FileNotFoundError:
return f"Error: File '{filepath}' not found."
except PermissionError:
return f"Error: Permission denied reading '{filepath}'."
except Exception as e:
return f"Error: {e}"
What does this code do?
This tool reads any file the agent asks for. It truncates large files to 3000 characters so the model's context window doesn't overflow.
The three except blocks handle common failures gracefully. Instead of crashing, they return descriptive error strings that the model can interpret and relay to the user.
- Save agent.py. One more tool to go.
- Add the get_system_info function below read_file:
def get_system_info() -> str:
"""Get information about the current system.
Returns:
A string with system information including OS, architecture, and Python version.
"""
info = {
"os": platform.system(),
"os_version": platform.version(),
"architecture": platform.machine(),
"python_version": platform.python_version(),
"hostname": platform.node(),
"working_directory": os.getcwd(),
}
return json.dumps(info, indent=2)
What does this code do?
This tool gathers system information and returns it as formatted JSON. It takes no arguments because it only reports facts about the current machine.
The model can use this tool when a user asks questions like "what OS am I running?" or "what Python version is installed?".
- Save agent.py and confirm no syntax errors by running:
python3 -c "import agent"
No output means all four functions are syntactically valid.
Seeing a SyntaxError?
Check that every docstring uses triple double-quotes (""") and that your try/except blocks are properly indented. A missing colon after except Exception as e is the most common cause.
help me fix a SyntaxError in my agent.py tool functions
Create the tool registry
The agent loop needs two things to work with your tools. It needs a list of function references (so Ollama can build the JSON schemas), and a dictionary that maps tool names to functions (so the loop can look up which function to call).
- Add the tool registry below get_system_info by pasting this code:
# --- Tool Registry ---
TOOLS = [get_current_time, calculate, read_file, get_system_info]
TOOL_REGISTRY = {
"get_current_time": get_current_time,
"calculate": calculate,
"read_file": read_file,
"get_system_info": get_system_info,
}
What does this code do?
TOOLS is a list of raw function references. When you pass this list to Ollama's chat() function, the SDK reads each function's docstring and type hints, then auto-generates the JSON schema the model needs.
TOOL_REGISTRY is a lookup dictionary. When the model responds with a tool call like "calculate", the agent loop uses this dictionary to find the actual calculate function and execute it.
- Save agent.py.
- Run the import check one more time to confirm everything is valid:
python3 -c "import agent; print(len(agent.TOOLS), 'tools registered')"
You should see 4 tools registered in the output.
Seeing a NameError?
A NameError means one of the function names in the TOOLS list or TOOL_REGISTRY dictionary doesn't match the function definition above. Check spelling carefully.
help me fix a NameError in my tool registry
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
from ollama import chat, ChatResponse
import datetime
import os
import platform
import json
# --- Tool Definitions ---
def get_current_time(timezone: str = "local") -> str:
"""Get the current date and time.
Args:
timezone: The timezone to get the time for. Use 'local' for system time.
Returns:
The current date and time as a string.
"""
now = datetime.datetime.now()
return now.strftime("%Y-%m-%d %H:%M:%S")
def calculate(expression: str) -> str:
"""Evaluate a mathematical expression safely.
Args:
expression: A mathematical expression to evaluate, e.g. '2 + 2' or '(10 * 5) / 2'.
Returns:
The result of the calculation as a string.
"""
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return "Error: Invalid characters in expression. Only numbers and +-*/.() are allowed."
try:
result = eval(expression)
return str(result)
except Exception as e:
return f"Error: {e}"
def read_file(filepath: str) -> str:
"""Read the contents of a file.
Args:
filepath: The path to the file to read.
Returns:
The contents of the file as a string.
"""
try:
with open(filepath, "r") as f:
content = f.read()
if len(content) > 3000:
return content[:3000] + "\n... (truncated)"
return content
except FileNotFoundError:
return f"Error: File '{filepath}' not found."
except PermissionError:
return f"Error: Permission denied reading '{filepath}'."
except Exception as e:
return f"Error: {e}"
def get_system_info() -> str:
"""Get information about the current system.
Returns:
A string with system information including OS, architecture, and Python version.
"""
info = {
"os": platform.system(),
"os_version": platform.version(),
"architecture": platform.machine(),
"python_version": platform.python_version(),
"hostname": platform.node(),
"working_directory": os.getcwd(),
}
return json.dumps(info, indent=2)
# --- Tool Registry ---
TOOLS = [get_current_time, calculate, read_file, get_system_info]
TOOL_REGISTRY = {
"get_current_time": get_current_time,
"calculate": calculate,
"read_file": read_file,
"get_system_info": get_system_info,
}
Test each tool manually
Before you build the agent loop, confirm each tool works independently. A broken tool would cause confusing failures later when the model tries to call it.
- Test get_current_time by running:
python3 -c "from agent import get_current_time; print(get_current_time())"
You should see today's date and time printed, like 2026-06-03 14:30:22.
- Test calculate by running:
python3 -c "from agent import calculate; print(calculate('(10 * 5) / 2'))"
You should see 25.0 in the output.
- Test read_file by running:
python3 -c "from agent import read_file; print(read_file('agent.py'))"
You should see the full contents of your agent.py file printed to the terminal.
- Test get_system_info by running:
python3 -c "from agent import get_system_info; print(get_system_info())"
You should see a JSON object with your OS name, architecture, Python version, and working directory.
One of the tools returned an error?
If get_current_time fails, check that import datetime is at the top of your file.
If read_file says "file not found", make sure you're running the command from the same directory where agent.py lives.
help me debug a failing tool function in my agent
All four tools are working independently. Next up, you'll build the agent loop that lets the model decide which tools to call on its own.
Build the Agent Loop
In the previous step, you gave your agent its capabilities by defining four tools and registering them. Now you need the brain that decides when to use those tools.
The agent loop is the core pattern behind every AI agent framework. It calls the model, checks if the model wants to use a tool, executes that tool, feeds the result back, and repeats until the model has enough information to answer.
In this step, get ready to:
- Add a system prompt that tells the model it has access to tools.
- Write the core agent loop that orchestrates tool calling.
- Test the agent with a single-tool query.
Add the system prompt
Every agent needs instructions that define its behaviour. A system prompt is a special message at the start of the conversation that the model reads before anything else.
- In agent.py, find the TOOL_REGISTRY dictionary near the bottom of the file.
- Add the following code below the TOOL_REGISTRY block:
# --- System Prompt ---
SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools.
When a user asks a question, decide whether you need to use a tool to answer it.
You can call multiple tools if needed, and you can call tools multiple times.
Always provide a clear, helpful final answer after using tools."""
What does this code do?
The SYSTEM_PROMPT constant defines the agent's personality and rules. It tells the model three things: it has tools available, it can call multiple tools per turn, and it must always give a clear final answer.
Without this prompt, the model might try to answer questions from its training data alone instead of reaching for the tools you gave it.
- Save agent.py.
Write the core agent loop
The agent loop is a while True loop that keeps calling the model until it stops requesting tools. Each iteration sends the full conversation history so the model sees both the user's question and any tool results so far.
- In agent.py, add the following code below SYSTEM_PROMPT:
# --- Agent Loop ---
def run_agent(user_input: str, messages: list) -> str:
"""Run the agent loop for a single user turn."""
messages.append({"role": "user", "content": user_input})
while True:
response: ChatResponse = chat(
model="qwen3",
messages=messages,
tools=TOOLS,
think=False,
)
messages.append(response.message)
if not response.message.tool_calls:
return response.message.content
for tool_call in response.message.tool_calls:
func_name = tool_call.function.name
func_args = tool_call.function.arguments
if func_name in TOOL_REGISTRY:
print(f" 🔧 Calling {func_name}({func_args})")
result = TOOL_REGISTRY[func_name](**func_args)
else:
result = f"Error: Unknown tool '{func_name}'"
messages.append({
"role": "tool",
"tool_name": func_name,
"content": str(result),
})
What does this code do?
This is the heart of your agent. Here is how each part works:
- messages.append({"role": "user", ...}) adds the user's question to the conversation history.
- chat(model="qwen3", messages=messages, tools=TOOLS, think=False) sends the full conversation to the model along with the available tool definitions.
- if not response.message.tool_calls checks whether the model decided to answer directly. If it did, the loop ends and the final answer is returned.
- The for loop executes each tool the model requested, then appends the result as a "role": "tool" message so the model sees the output on the next iteration.
The think=False parameter disables the model's internal reasoning tokens, keeping output clean and fast. The loop itself is the agent's reasoning mechanism.
Why a while loop instead of a single call?
A single LLM call can only request tools. It cannot execute them. Your code executes the tools and feeds results back. Sometimes the model needs multiple rounds of tool use before it has enough information to answer.
This is the exact same pattern that LangChain's AgentExecutor, CrewAI, and the OpenAI Agents SDK implement under the hood.
- Save agent.py.
Seeing a syntax error on save?
- Make sure the run_agent function is at the module level (not indented inside another function or class).
- Check that the triple-quoted docstring uses straight quotes, not curly/smart quotes.
- Verify the messages.append({...}) dictionary at the bottom has a trailing comma after str(result).
Ask for help: I'm getting a syntax error in my run_agent function in agent.py. Can you help me debug it?
Test with a single-tool query
Your agent loop is complete. Time to prove it works by asking a question that requires a tool call.
- Make sure your terminal is in the ai-agent project directory.
- Test the agent by running this command:
python3 -c "from agent import run_agent, SYSTEM_PROMPT; msgs = [{'role': 'system', 'content': SYSTEM_PROMPT}]; print(run_agent('What time is it?', msgs))"
You should see output like this:
🔧 Calling get_current_time({'timezone': 'local'})
The current date and time is 2026-06-03 14:23:45.
What should I see?
The first line shows your agent calling the get_current_time tool. This proves the model decided to use a tool instead of guessing the time.
The second line is the model's final answer, incorporating the tool's result. Your exact time and date will differ, but the format should match.
Not seeing any output?
- Make sure Ollama is still running. Open a separate terminal and run ollama list to confirm qwen3 appears.
- If you see a connection error, start Ollama with ollama serve in a background terminal.
- The first call may take 10-20 seconds as the model loads into memory. Wait for it to complete.
Ask for help: My agent test isn't producing any output. Ollama might not be running or the model might not be loaded.
✔️ Awesome, I've got everything!
Great. Double check you've saved agent.py before moving on.
ⓧ I'd like to double check the full code
Here is the full agent.py up to this point:
from ollama import chat, ChatResponse
import datetime
import os
import platform
import json
# --- Tool Definitions ---
def get_current_time(timezone: str = "local") -> str:
"""Get the current date and time.
Args:
timezone: The timezone to get the time for. Use 'local' for system time.
Returns:
The current date and time as a string.
"""
now = datetime.datetime.now()
return now.strftime("%Y-%m-%d %H:%M:%S")
def calculate(expression: str) -> str:
"""Evaluate a mathematical expression safely.
Args:
expression: A mathematical expression to evaluate, e.g. '2 + 2' or '(10 * 5) / 2'.
Returns:
The result of the calculation as a string.
"""
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return "Error: Invalid characters in expression. Only numbers and +-*/.() are allowed."
try:
result = eval(expression)
return str(result)
except Exception as e:
return f"Error: {e}"
def read_file(filepath: str) -> str:
"""Read the contents of a file.
Args:
filepath: The path to the file to read.
Returns:
The contents of the file as a string.
"""
try:
with open(filepath, "r") as f:
content = f.read()
if len(content) > 3000:
return content[:3000] + "\n... (truncated)"
return content
except FileNotFoundError:
return f"Error: File '{filepath}' not found."
except PermissionError:
return f"Error: Permission denied reading '{filepath}'."
except Exception as e:
return f"Error: {e}"
def get_system_info() -> str:
"""Get information about the current system.
Returns:
A string with system information including OS, architecture, and Python version.
"""
info = {
"os": platform.system(),
"os_version": platform.version(),
"architecture": platform.machine(),
"python_version": platform.python_version(),
"hostname": platform.node(),
"working_directory": os.getcwd(),
}
return json.dumps(info, indent=2)
# --- Tool Registry ---
TOOLS = [get_current_time, calculate, read_file, get_system_info]
TOOL_REGISTRY = {
"get_current_time": get_current_time,
"calculate": calculate,
"read_file": read_file,
"get_system_info": get_system_info,
}
# --- System Prompt ---
SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools.
When a user asks a question, decide whether you need to use a tool to answer it.
You can call multiple tools if needed, and you can call tools multiple times.
Always provide a clear, helpful final answer after using tools."""
# --- Agent Loop ---
def run_agent(user_input: str, messages: list) -> str:
"""Run the agent loop for a single user turn."""
messages.append({"role": "user", "content": user_input})
while True:
response: ChatResponse = chat(
model="qwen3",
messages=messages,
tools=TOOLS,
think=False,
)
messages.append(response.message)
if not response.message.tool_calls:
return response.message.content
for tool_call in response.message.tool_calls:
func_name = tool_call.function.name
func_args = tool_call.function.arguments
if func_name in TOOL_REGISTRY:
print(f" 🔧 Calling {func_name}({func_args})")
result = TOOL_REGISTRY[func_name](**func_args)
else:
result = f"Error: Unknown tool '{func_name}'"
messages.append({
"role": "tool",
"tool_name": func_name,
"content": str(result),
})
Your agent can now autonomously decide to call a tool and use the result to answer a question. Next up, you'll handle more complex scenarios where the model requests multiple tools in parallel.
Handle Multi-Tool and Parallel Calls
Your agent loop from Step 3 already handles single-tool queries. But real-world questions often require multiple tools working together.
In this step, you'll see how your existing for loop already handles parallel tool calls. You'll test it with a complex query and verify the error handling that catches hallucinated tool names.
In this step, get ready to:
- Understand how parallel tool calling works in your agent loop.
- Test with a complex query that requires multiple tools.
- Verify error handling for unknown tool names.
Understand how your loop handles parallel tool calls
When a model receives a complex question, it can request multiple tools in a single response. For example, asking "What time is it and what OS am I on?" might trigger both get_current_time and get_system_info at once.
- Open agent.py and find the for tool_call in response.message.tool_calls line inside run_agent.
for tool_call in response.message.tool_calls:
func_name = tool_call.function.name
func_args = tool_call.function.arguments
if func_name in TOOL_REGISTRY:
print(f" 🔧 Calling {func_name}({func_args})")
result = TOOL_REGISTRY[func_name](**func_args)
else:
result = f"Error: Unknown tool '{func_name}'"
messages.append({
"role": "tool",
"tool_name": func_name,
"content": str(result),
})
Why does this already handle parallel calls?
The for loop iterates over every item in response.message.tool_calls. Whether the model returns one tool call or five, the loop processes them all before the next iteration of the while True loop sends results back to the model.
This is the exact pattern behind LangChain's AgentExecutor and the OpenAI Agents SDK. They loop, check for tool calls, execute all of them, feed results back, and repeat until the model stops requesting tools.
The multi-turn aspect is equally important. After executing tools, the while True loop sends results back to the model. The model might request more tools based on those results, creating a chain of reasoning.
Test with a multi-tool query
A question like "Read the contents of agent.py and tell me how many lines it has" requires tool chaining. The model needs to call read_file first, then potentially use calculate to count lines.
- Add the following test code at the very bottom of agent.py, below the run_agent function:
# --- Temporary test (delete after testing) ---
if __name__ == "__main__":
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
result = run_agent("Read the contents of agent.py and tell me how many lines it has", messages)
print(f"\nAgent: {result}")
What does this test code do?
- It creates a fresh messages list with just the system prompt.
- It calls run_agent with a question that forces the model to use multiple tools across multiple turns.
- The if __name__ == "__main__": guard ensures this only runs when you execute the file directly.
- Save agent.py.
- Run the test by executing this command in your terminal:
python agent.py
- Watch the terminal output. You should see one or more 🔧 Calling lines as the agent reads the file and processes the content.
What should I see?
You'll see the agent call read_file with agent.py as the argument. It may then call calculate to count lines, or it might count them directly in its response. Either path is valid.
The key observation is the while True loop running multiple iterations. The model gets the file contents back, decides it needs to process them further, and the loop continues until it produces a final answer.
Agent taking a long time or not responding?
- The qwen3 model needs a few seconds per turn. A multi-tool query may take 10-20 seconds total. Wait for it to finish.
- If you see a connection error, make sure Ollama is still running. Open a separate terminal and run ollama list to confirm.
- If the model gives a wrong answer or skips the tool call entirely, that's normal for smaller models. Run the script again and it may choose tools correctly on the next attempt.
Why is my agent not calling tools or timing out?
Verify error handling for unknown tools
Sometimes a model "hallucinates" a tool name that doesn't exist in your registry. Your code already handles this gracefully.
- In agent.py, find the else branch inside the for tool_call loop:
if func_name in TOOL_REGISTRY:
print(f" 🔧 Calling {func_name}({func_args})")
result = TOOL_REGISTRY[func_name](**func_args)
else:
result = f"Error: Unknown tool '{func_name}'"
How does this protect your agent?
The if func_name in TOOL_REGISTRY check acts as a guard. If the model invents a tool name like search_web that doesn't exist, your code returns an error message instead of crashing with a KeyError.
That error string gets sent back to the model as the tool result. The model reads it, understands the tool doesn't exist, and adjusts its approach. This is the same self-correcting pattern production agents use.
- Now remove the temporary test code you added at the bottom of agent.py (everything below the # --- Temporary test comment).
- Save agent.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and removed the test code.
ⓧ I'd like to double check the full code
Here's what your agent.py should look like at this point (with no test code at the bottom):
from ollama import chat, ChatResponse
import datetime
import os
import platform
import json
# --- Tool Definitions ---
def get_current_time(timezone: str = "local") -> str:
"""Get the current date and time.
Args:
timezone: The timezone to get the time for. Use 'local' for system time.
Returns:
The current date and time as a string.
"""
now = datetime.datetime.now()
return now.strftime("%Y-%m-%d %H:%M:%S")
def calculate(expression: str) -> str:
"""Evaluate a mathematical expression safely.
Args:
expression: A mathematical expression to evaluate, e.g. '2 + 2' or '(10 * 5) / 2'.
Returns:
The result of the calculation as a string.
"""
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return "Error: Invalid characters in expression. Only numbers and +-*/.() are allowed."
try:
result = eval(expression)
return str(result)
except Exception as e:
return f"Error: {e}"
def read_file(filepath: str) -> str:
"""Read the contents of a file.
Args:
filepath: The path to the file to read.
Returns:
The contents of the file as a string.
"""
try:
with open(filepath, "r") as f:
content = f.read()
if len(content) > 3000:
return content[:3000] + "\n... (truncated)"
return content
except FileNotFoundError:
return f"Error: File '{filepath}' not found."
except PermissionError:
return f"Error: Permission denied reading '{filepath}'."
except Exception as e:
return f"Error: {e}"
def get_system_info() -> str:
"""Get information about the current system.
Returns:
A string with system information including OS, architecture, and Python version.
"""
info = {
"os": platform.system(),
"os_version": platform.version(),
"architecture": platform.machine(),
"python_version": platform.python_version(),
"hostname": platform.node(),
"working_directory": os.getcwd(),
}
return json.dumps(info, indent=2)
# --- Tool Registry ---
TOOLS = [get_current_time, calculate, read_file, get_system_info]
TOOL_REGISTRY = {
"get_current_time": get_current_time,
"calculate": calculate,
"read_file": read_file,
"get_system_info": get_system_info,
}
# --- System Prompt ---
SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools.
When a user asks a question, decide whether you need to use a tool to answer it.
You can call multiple tools if needed, and you can call tools multiple times.
Always provide a clear, helpful final answer after using tools."""
# --- Agent Loop ---
def run_agent(user_input: str, messages: list) -> str:
"""Run the agent loop for a single user turn."""
messages.append({"role": "user", "content": user_input})
while True:
response: ChatResponse = chat(
model="qwen3",
messages=messages,
tools=TOOLS,
think=False,
)
messages.append(response.message)
if not response.message.tool_calls:
return response.message.content
for tool_call in response.message.tool_calls:
func_name = tool_call.function.name
func_args = tool_call.function.arguments
if func_name in TOOL_REGISTRY:
print(f" 🔧 Calling {func_name}({func_args})")
result = TOOL_REGISTRY[func_name](**func_args)
else:
result = f"Error: Unknown tool '{func_name}'"
messages.append({
"role": "tool",
"tool_name": func_name,
"content": str(result),
})
Your agent now handles the full spectrum of tool calling patterns. It processes single tools, parallel tools, multi-turn chains, and gracefully recovers from hallucinated tool names. Next up, you'll wrap it all in an interactive chat interface.
Build the Interactive Chat
Your agent loop works. It can call tools, handle parallel requests, and recover from errors.
Right now, the only way to talk to your agent is by editing the script and re-running it. In this step, you'll wrap the agent in an interactive chat interface that keeps the conversation going across multiple turns.
In this step, get ready to:
- Add a REPL loop with conversation history and graceful exit handling.
- Test the full agent with multi-turn conversations.
Add a REPL loop with conversation history and exit handling
A REPL (Read-Eval-Print Loop) gives you a persistent session where the agent remembers everything from earlier in the conversation. Each user message and agent response stays in the messages list, so the model always has full context.
- In agent.py, scroll to the very bottom of the file (after the run_agent function).
- Add the interactive chat section by copying this code at the bottom:
# --- Interactive Chat ---
def main():
print("\ud83e\udd16 AI Agent (type 'quit' to exit)")
print(" Tools: get_current_time, calculate, read_file, get_system_info")
print("-" * 50)
# Initialise conversation with the system prompt
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
while True:
try:
user_input = input("\nYou: ").strip()
except (KeyboardInterrupt, EOFError):
print("\nGoodbye!")
break
# Skip empty input
if not user_input:
continue
# Allow the user to quit gracefully
if user_input.lower() in ("quit", "exit"):
print("Goodbye!")
break
# Run the agent and print the response
response = run_agent(user_input, messages)
print(f"\nAgent: {response}")
if __name__ == "__main__":
main()
What does this code do?
- messages = [{"role": "system", "content": SYSTEM_PROMPT}] starts the conversation with the system prompt so the model knows it has tools.
- The while True loop reads input, passes it to run_agent, and prints the response. Because messages is passed by reference, every turn accumulates in the same list.
- The try/except catches KeyboardInterrupt (Ctrl+C) and EOFError (Ctrl+D on macOS, Ctrl+Z on Windows) so the script exits cleanly instead of dumping a traceback.
- if __name__ == "__main__": means the chat only launches when you run the file directly, not when it's imported.
- Save agent.py.
Seeing a SyntaxError or IndentationError?
Make sure the def main(): line is at the top level (no extra indentation). Every line inside the function should be indented by 4 spaces.
Also confirm there are two blank lines between the end of run_agent and the # --- Interactive Chat --- comment.
Help me fix a SyntaxError in my agent.py main function.
Test the full agent with multi-turn conversations
Time to talk to your agent. You'll verify that it remembers context across turns and calls the right tools.
- Run the agent by executing this command in your terminal:
python3 agent.py
You should see the agent's welcome banner with a list of available tools and a You: prompt waiting for input.
- Type the following and press Enter:
What time is it?
You should see the agent call get_current_time and respond with the current date and time.
- Now test multi-turn memory. Without restarting the agent, type:
What is 123 * 456?
- After the agent responds, try a follow-up that references the previous answer:
Now divide that result by 7
The agent should remember the previous result and call calculate with the correct expression. This proves conversation history is working.
- Type quit and press Enter to exit the agent.
Why does conversation memory matter?
Every message (user, assistant, and tool results) accumulates in the messages list. The model sees the entire history on every turn, which is how it understands "that result" in a follow-up question.
This is the same pattern that ChatGPT, Claude, and every other multi-turn chat product uses under the hood.
Agent seems stuck or not responding?
The first response can take 10-30 seconds depending on your hardware. The qwen3 model needs time to generate, especially on the first call when loading into memory.
If it hangs for more than 60 seconds, open a new terminal and run ollama ps to check that the model is loaded.
Help me debug my agent that seems stuck.
✔️ Awesome, I've got everything!
Great. Double check you've saved agent.py and confirmed the interactive chat works.
ⓧ I'd like to double check the full code
Here is the complete agent.py file. Compare it with yours:
from ollama import chat, ChatResponse
import datetime
import os
import platform
import json
# --- Tool Definitions ---
def get_current_time(timezone: str = "local") -> str:
"""Get the current date and time.
Args:
timezone: The timezone to get the time for. Use 'local' for system time.
Returns:
The current date and time as a string.
"""
now = datetime.datetime.now()
return now.strftime("%Y-%m-%d %H:%M:%S")
def calculate(expression: str) -> str:
"""Evaluate a mathematical expression safely.
Args:
expression: A mathematical expression to evaluate, e.g. '2 + 2' or '(10 * 5) / 2'.
Returns:
The result of the calculation as a string.
"""
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return "Error: Invalid characters in expression. Only numbers and +-*/.() are allowed."
try:
result = eval(expression)
return str(result)
except Exception as e:
return f"Error: {e}"
def read_file(filepath: str) -> str:
"""Read the contents of a file.
Args:
filepath: The path to the file to read.
Returns:
The contents of the file as a string.
"""
try:
with open(filepath, "r") as f:
content = f.read()
if len(content) > 3000:
return content[:3000] + "\n... (truncated)"
return content
except FileNotFoundError:
return f"Error: File '{filepath}' not found."
except PermissionError:
return f"Error: Permission denied reading '{filepath}'."
except Exception as e:
return f"Error: {e}"
def get_system_info() -> str:
"""Get information about the current system.
Returns:
A string with system information including OS, architecture, and Python version.
"""
info = {
"os": platform.system(),
"os_version": platform.version(),
"architecture": platform.machine(),
"python_version": platform.python_version(),
"hostname": platform.node(),
"working_directory": os.getcwd(),
}
return json.dumps(info, indent=2)
# --- Tool Registry ---
TOOLS = [get_current_time, calculate, read_file, get_system_info]
TOOL_REGISTRY = {
"get_current_time": get_current_time,
"calculate": calculate,
"read_file": read_file,
"get_system_info": get_system_info,
}
# --- System Prompt ---
SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools.
When a user asks a question, decide whether you need to use a tool to answer it.
You can call multiple tools if needed, and you can call tools multiple times.
Always provide a clear, helpful final answer after using tools."""
# --- Agent Loop ---
def run_agent(user_input: str, messages: list) -> str:
"""Run the agent loop for a single user turn."""
messages.append({"role": "user", "content": user_input})
while True:
response: ChatResponse = chat(
model="qwen3",
messages=messages,
tools=TOOLS,
think=False,
)
messages.append(response.message)
if not response.message.tool_calls:
return response.message.content
for tool_call in response.message.tool_calls:
func_name = tool_call.function.name
func_args = tool_call.function.arguments
if func_name in TOOL_REGISTRY:
print(f" \ud83d\udd27 Calling {func_name}({func_args})")
result = TOOL_REGISTRY[func_name](**func_args)
else:
result = f"Error: Unknown tool '{func_name}'"
messages.append({
"role": "tool",
"tool_name": func_name,
"content": str(result),
})
# --- Interactive Chat ---
def main():
print("\ud83e\udd16 AI Agent (type 'quit' to exit)")
print(" Tools: get_current_time, calculate, read_file, get_system_info")
print("-" * 50)
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
while True:
try:
user_input = input("\nYou: ").strip()
except (KeyboardInterrupt, EOFError):
print("\nGoodbye!")
break
if not user_input:
continue
if user_input.lower() in ("quit", "exit"):
print("Goodbye!")
break
response = run_agent(user_input, messages)
print(f"\nAgent: {response}")
if __name__ == "__main__":
main()
Your AI agent is complete. You've built the exact same loop-and-memory pattern that powers LangChain's AgentExecutor, CrewAI, and the OpenAI Agents SDK. Next up, the Secret Mission will give your agent a new superpower.
Secret mission
Add a Web Fetch Tool
Your agent can read files, do math, and check the time. But it's completely cut off from the internet. What if it could fetch live data from any URL? In this secret mission, you'll add a web fetch tool that gives your agent access to real, live information from the web.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Project folder (ai-agent on your Desktop).
- The qwen3 model downloaded via Ollama (uses disk space).
- The ollama Python package.
Keep everything running
No action needed. Choose this if you're still actively building or want to experiment with adding more tools to your agent.
- Your project folder stays on your Desktop.
- The qwen3 model stays available for other Ollama projects.
- The ollama Python package stays installed in your environment.
Pause - I'll come back to this later
Stop the Ollama server to free up memory. Your files and model stay on disk so you can resume later.
- Quit the Ollama app from your menu bar (click the Ollama icon and select Quit Ollama).
- When you come back, reopen Ollama and run python agent.py from your project folder.
Delete - I don't want to use this again
Remove all project resources and free up disk space.
- Remove the qwen3 model by running:
ollama rm qwen3
- Uninstall the ollama Python package by running:
pip uninstall ollama
- Delete your project folder by running:
cd ~/Desktop
rm -rf ai-agent
On Windows, use this command instead:
cd ~\Desktop
rmdir /s /q ai-agent
Nice Work!
Nice Work!
You just built a fully functional AI agent from scratch. No frameworks, no magic imports. Just Python, a local LLM, and the agent loop pattern that powers every production AI agent today.
You've learned how to:
- Build a complete agent loop in ~80 lines of Python that calls a local LLM, checks for tool requests, executes tools, and feeds results back until the model produces a final answer.
- Implement tool calling with 4 custom tools, a tool registry, and automatic schema generation from Python docstrings.
- Handle parallel and multi-turn tool execution with error handling for unknown tools, plus an interactive chat interface that maintains conversation history.
- Added a web fetch tool that gives your agent access to live internet data using Python's built-in urllib module.
Ready to quiz yourself?