Build an AI Agent from Scratch
Build a Python AI agent that reasons, calls tools, and solves multi-step problems.
Introduction
30 Second Summary
When you ask an AI assistant to check the weather and then calculate something with the result, something interesting happens behind the scenes. The AI reasons about what tools it needs, calls them one by one, and keeps looping until it has a grounded answer.
In this project, you will build that exact system from scratch in Python using the free Gemini API. Your agent will take plain English questions, decide which tools to call, and chain results together to produce accurate answers.
What You'll Build
You'll type a question like "What's the weather in Tokyo and what's 72 minus that temperature?" into your terminal and watch your agent think through the problem step by step, calling tools and combining results into a final answer.
By the end of this project, you'll have:
- An AI agent you can ask complex, multi-step questions and watch it autonomously call tools like a weather lookup, calculator, and clock to deliver grounded answers.
- A step-by-step reasoning trace printed in your terminal showing exactly which tool the agent picks, what arguments it passes, and what result it gets back.
- Conversation memory so the agent remembers what you discussed earlier in the session, plus a stats command to review all tool calls made.
- Secret Mission: a custom note-taking tool that saves to a file, added without any changes to the core agent loop.
Are there any prerequisites?
This project assumes basic Python familiarity with variables, functions, loops, and dictionaries. No AI or machine learning experience is needed.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Environment
Your AI agent needs a brain. That brain is Google's Gemini API, a large language model that can understand questions and generate responses.
In this step, you'll grab an API key from Google AI Studio, set up a Python project with the official SDK, and confirm Gemini responds to your very first call.
In this step, get ready to:
- Get a Gemini API key from Google AI Studio.
- Create a project folder with a Python virtual environment and install the SDK.
- Test the connection by calling Gemini from a Python script.
Get a Gemini API key from Google AI Studio
The Gemini API is free to use and requires no credit card. You just need a Google account to get started.
- Open your browser and go to aistudio.google.com.
- Sign in with your Google account.
- Accept the Terms of Service if prompted.
Where is my API key?
Google AI Studio automatically creates a project and API key for new users. You can find it on the API keys page. If you don't see a key, click Create API key to generate one.
- Navigate to the API keys page at aistudio.google.com/app/apikey.
- Copy your API key and keep it somewhere safe (you'll paste it in a moment).
Treat your API key like a password
Never share your API key publicly or commit it to Git. Anyone with the key can make API calls on your account.
Create your project folder and install the SDK
You need a dedicated folder for your agent's code, a Python virtual environment to isolate dependencies, and the google-genai SDK to talk to the Gemini API.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- 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
You should see an empty listing since the folder is brand new.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the ai-agent folder, and click Open.
- Open the VS Code integrated terminal by pressing Ctrl+` (both macOS and Windows).
Now set your API key as an environment variable in this terminal. This is how the SDK will authenticate your requests.
macOS
- Set your API key by running this command (replace your-api-key-here with the key you copied):
export GEMINI_API_KEY="your-api-key-here"
Windows
- Set your API key by running this command (replace your-api-key-here with the key you copied):
$env:GEMINI_API_KEY="your-api-key-here"
Not sure if the key was set?
On macOS, run echo $GEMINI_API_KEY. On Windows PowerShell, run echo $env:GEMINI_API_KEY. You should see your API key printed back. If it's blank, re-run the export command above.
Help me set my GEMINI_API_KEY environment variable.
- Create a Python virtual environment by running:
python -m venv venv
What is a virtual environment?
A virtual environment is an isolated space for your Python project's packages. It keeps the packages you install here separate from your system Python, so different projects don't interfere with each other.
- Activate the virtual environment by running:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
Seeing a permissions error on Windows?
Windows may block scripts by default. Run this command first, then try activating again:
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
Help me activate my Python virtual environment.
- Install the Gemini SDK by running:
pip install google-genai
You should see output ending with a line like Successfully installed followed by package names.
Seeing 'pip not found'?
If pip is not found, try pip3 instead. Make sure your virtual environment is active (you should see (venv) in your prompt).
Help me install google-genai.
Test the connection
Everything is installed. Time to confirm that your API key works and Gemini can hear you.
- In VS Code's Explorer sidebar, click the New File icon next to AI-AGENT.
- Name the file agent.py and press Enter.
- Paste the following code into agent.py:
import os
from google import genai
# Create a Gemini API client
client = genai.Client()
# Send a simple prompt to the model and print the response
response = client.models.generate_content(
model='gemini-2.5-flash',
contents='Say hello'
)
print(response.text)
What does this code do?
genai.Client() creates a connection to the Gemini API. It automatically reads your GEMINI_API_KEY environment variable to authenticate.
client.models.generate_content() sends a prompt to the gemini-2.5-flash model and returns its response. response.text extracts the text from that response.
- Save agent.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Before you run this, what do you think will happen? Will the model return an exact phrase, or something different each time?
- Run your script by entering this command in the VS Code terminal:
python agent.py
You should see a friendly greeting from Gemini printed in your terminal. The exact wording varies each time since the model generates a fresh response.
Seeing an error instead of a greeting?
If you see an error mentioning the API key, make sure you set the GEMINI_API_KEY environment variable in the same terminal where you ran the script. Environment variables only last for the current terminal session.
If python is not found, try python3 instead. On some systems, Python 3 uses the python3 command.
If you see an error about the free tier not being available in your country, you may need to enable billing on your Google Cloud project.
Help me fix my agent.py script.
✔️ Awesome, I've got everything!
Great. Double check that you've saved agent.py.
ⓧ I'd like to double check the full code
Here is what agent.py should look like at this point:
import os
from google import genai
client = genai.Client()
response = client.models.generate_content(
model='gemini-2.5-flash',
contents='Say hello'
)
print(response.text)
Your environment is wired up and Gemini is talking back. Next up, you'll turn this one-shot call into a real conversation loop and discover exactly where a plain LLM falls short.
Make Your First LLM Call (and See It Fail)
In the last step, you proved that your connection to Gemini works. Right now, though, your script sends one hardcoded message and exits.
An LLM like Gemini generates text based on patterns in its training data. It has no access to live information and no built-in calculator. In this step, you'll turn your script into a real conversation and find out what that means in practice.
In this step, get ready to:
- Build a chat loop that sends your input to Gemini and prints the response.
- Test the model with questions it can't answer from memory alone.
Build a simple chat loop
Your current agent.py sends a single hardcoded message and prints the reply. To have a real conversation, you need a loop that reads your input, sends it to Gemini, and keeps going until you say stop.
- Replace everything in agent.py with the following code:
from google import genai
# Set up the client and model
client = genai.Client()
model_id = 'gemini-2.5-flash'
# Start the chat loop
print('Simple Chat (type quit to exit)')
while True:
user_input = input('You: ').strip()
# Skip empty input
if not user_input:
continue
# Exit on 'quit'
if user_input.lower() == 'quit':
print('Goodbye!')
break
# Send the message to Gemini and print the response
response = client.models.generate_content(
model=model_id,
contents=user_input
)
print(f'Agent: {response.text}')
What does this code do?
- model_id stores the model name in one place so you can swap it out later without hunting through your code.
- while True creates an infinite loop that keeps asking for input until you type quit.
- input('You: ').strip() reads whatever you type and removes any extra whitespace from the edges.
- Each message is sent to Gemini using generate_content, and the response is printed with an Agent: prefix so you can tell who said what.
- Save agent.py.
- Run the chat loop by running:
python agent.py
- You should see Simple Chat (type quit to exit) printed in your terminal.
- Type What is Python? and press Enter.
Gemini responds with an explanation of Python. Your chat loop is working.
- Type quit to exit the loop.
Not seeing any output?
- Make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.
- Check that your GEMINI_API_KEY environment variable is still set in this terminal session. Closing and reopening the terminal clears it.
- Confirm you replaced the entire file. If any code from the old one-shot script remains, the loop won't run correctly.
Still stuck? Help me debug why my chat loop isn't printing a response from Gemini.
✔️ Awesome, I've got everything!
Great. Make sure you've saved agent.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete agent.py at this point. Compare it line by line with yours.
from google import genai
client = genai.Client()
model_id = 'gemini-2.5-flash'
print('Simple Chat (type quit to exit)')
while True:
user_input = input('You: ').strip()
if not user_input:
continue
if user_input.lower() == 'quit':
print('Goodbye!')
break
response = client.models.generate_content(
model=model_id,
contents=user_input
)
print(f'Agent: {response.text}')
Test the limits
Your chat loop works for general knowledge questions. Now let's push it with questions that need something more than training data.
- Start the agent again by running:
python agent.py
Before you ask your next question, think about this: does this model have access to a live weather feed? What do you think it will say if you ask about today's weather?
- Type What's the weather in Tokyo right now? and press Enter.
The model gives you a confident, detailed answer with specific temperatures and conditions. Look closely. It has no internet connection and no weather API. Every detail it just gave you is fabricated.
Now try a precise math question. Think about whether the model is calculating or guessing.
- Type What is 847 * 293? and press Enter.
The correct answer is 248,171. Compare it with what the model returned. It may be close, or it may be completely off. The model is predicting the shape of a correct answer, not actually doing arithmetic.
- Type quit to exit the chat.
Why does the model sound so confident?
LLMs generate text by predicting the most likely next word. When asked about weather, the model has seen thousands of weather reports in its training data, so it produces text that looks like a weather report. It has no way to know whether the details are accurate.
This is the core problem your agent will solve. An LLM without tools is just guessing. In the next step, you'll give it real tools so it can look up actual data and compute real answers instead of making them up.
You've seen the gap firsthand. The model sounds convincing but has no way to verify its own answers. Next up, you'll teach your agent to use real tools so it can look up weather, crunch numbers, and tell the time.
Teach Your Agent to Use Tools
You just watched your agent confidently fabricate weather data and fumble basic math. It sounded convincing, but it had zero way to check real information or compute precise answers.
Function calling fixes this. You'll define Python functions that the model can request by name. Instead of guessing, the LLM asks you to run a function, reads the result, and answers based on real data.
In this step, get ready to:
- Define three tool functions: weather lookup, calculator, and clock.
- Register the tools with the model using JSON schema declarations.
- Wire up function calling so the agent executes tools and feeds results back to the model.
Define three tool functions
Your agent needs three capabilities: looking up weather, computing math, and telling the time. Each tool is a plain Python function that takes arguments and returns a dictionary of results.
- Select all the code in agent.py and delete it. Replace it with these imports and the first tool function by pasting the following code:
import json
from datetime import datetime
from google import genai
from google.genai import types
# Simulate weather data for a handful of cities
def get_weather(city):
weather_data = {
'Tokyo': {'temperature': '22°C', 'condition': 'Partly Cloudy', 'humidity': '65%'},
'New York': {'temperature': '18°C', 'condition': 'Sunny', 'humidity': '45%'},
'London': {'temperature': '14°C', 'condition': 'Rainy', 'humidity': '80%'},
'Paris': {'temperature': '20°C', 'condition': 'Clear', 'humidity': '55%'},
'Sydney': {'temperature': '26°C', 'condition': 'Sunny', 'humidity': '50%'},
}
# Normalize the city name so 'tokyo' matches 'Tokyo'
city_key = city.strip().title()
if city_key in weather_data:
return weather_data[city_key]
return {'temperature': 'Unknown', 'condition': 'No data for this city', 'humidity': 'Unknown'}
What does this code do?
The new imports bring in json for formatting tool results, datetime for the clock tool, and types from the Gemini SDK for declaring tools.
get_weather() looks up a city in a hardcoded dictionary and returns its temperature, condition, and humidity. In a real agent, this would call a weather API. The dictionary keeps things simple for learning.
- Add the calculator and clock tools below get_weather by pasting this code at the bottom of agent.py:
# Safely evaluate a math expression
def calculate(expression):
try:
# Only allow safe math functions, no dangerous builtins
allowed = {'abs': abs, 'round': round, 'min': min, 'max': max, 'pow': pow}
result = eval(expression, {'__builtins__': {}}, allowed)
return {'expression': expression, 'result': str(result)}
except Exception as e:
return {'expression': expression, 'error': str(e)}
# Return the current date and time
def get_current_time():
now = datetime.now()
return {
'date': now.strftime('%Y-%m-%d'),
'time': now.strftime('%H:%M:%S'),
'day': now.strftime('%A'),
}
What does this code do?
calculate() uses Python's eval() to compute a math expression. The {'__builtins__': {}} blocks access to dangerous Python functions, and allowed whitelists only safe math helpers like abs and pow.
get_current_time() grabs the system clock and formats it into a date, time, and day of the week. No arguments needed.
- Save agent.py.
- Test that your functions work by running this command in your terminal:
python -c "from agent import get_weather, calculate; print(get_weather('Tokyo')); print(calculate('2 + 2'))"
You should see the Tokyo weather dictionary followed by a calculation result showing 4.
Seeing a SyntaxError or ImportError?
Make sure you deleted all the old chat loop code before pasting. Having leftover lines from the previous version causes conflicts.
Check that your virtual environment is still active. You should see (venv) at the start of your terminal prompt.
Why can't Python import my tool functions?
The functions work, but the model has no idea they exist. You need to tell the model what tools are available, what each one does, and what arguments they accept.
- Add the tool registry below get_current_time by pasting this code at the bottom of agent.py:
# Map tool names to their Python functions
TOOL_FUNCTIONS = {
'get_weather': get_weather,
'calculate': calculate,
'get_current_time': get_current_time,
}
# Describe each tool so the model knows when and how to use it
TOOL_DECLARATIONS = [
{
'name': 'get_weather',
'description': 'Gets the current weather for a city. Returns temperature, condition, and humidity.',
'parameters': {
'type': 'object',
'properties': {
'city': {
'type': 'string',
'description': 'The city name, e.g. Tokyo, London, New York, Paris, Sydney',
},
},
'required': ['city'],
},
},
]
What are tool declarations?
TOOL_FUNCTIONS maps each tool's name string to the actual Python function. When the model says "call get_weather", your code looks up the function here.
TOOL_DECLARATIONS describes each tool using a JSON schema. The model reads the name, description, and parameters to decide which tool fits the user's question and what arguments to pass.
- The TOOL_DECLARATIONS list currently only describes get_weather. Add the declarations for calculate and get_current_time by pasting these entries inside the list, after the get_weather entry and before the closing ]:
{
'name': 'calculate',
'description': 'Evaluates a math expression. Supports +, -, *, /, ** and functions like abs, round, min, max, pow.',
'parameters': {
'type': 'object',
'properties': {
'expression': {
'type': 'string',
'description': "The math expression to evaluate, e.g. '(10 * 5) / 3'",
},
},
'required': ['expression'],
},
},
{
'name': 'get_current_time',
'description': 'Returns the current date, time, and day of the week.',
'parameters': {
'type': 'object',
'properties': {},
},
},
Why does get_current_time have empty properties?
get_current_time() takes no arguments. You still need the parameters key with an empty properties object so the model knows the tool exists but doesn't need to supply any input.
- Save agent.py.
- Verify your registry is complete by running this command:
python -c "from agent import TOOL_DECLARATIONS; print(len(TOOL_DECLARATIONS), 'tools registered')"
You should see 3 tools registered printed in your terminal.
Seeing fewer than 3 tools?
Make sure you pasted the calculate and get_current_time entries inside the TOOL_DECLARATIONS list brackets, not below the closing ]. Each entry needs a comma after its closing }.
Why isn't my TOOL_DECLARATIONS list showing all 3 tools?
Add function calling to the LLM request
Your tools are defined and registered. Now you need a new main loop that passes the declarations to the model so it can request tool calls instead of guessing.
How does function calling work?
You send your question plus the tool declarations to the model. The model does NOT run the function itself. Instead, it returns a function_call object saying "I want to call this tool with these arguments."
Your code then executes the function, sends the result back to the model, and the model uses that real data to write its final answer.
- Add the new main loop to agent.py by pasting this code at the very bottom of the file, below TOOL_DECLARATIONS:
# Set up the Gemini client with tool config
client = genai.Client()
model_id = 'gemini-2.5-flash'
# Pass tool declarations so the model knows what's available
tools = types.Tool(function_declarations=TOOL_DECLARATIONS)
config = types.GenerateContentConfig(tools=[tools])
print('AI Agent with Tools (type quit to exit)')
while True:
user_input = input('You: ').strip()
if not user_input:
continue
if user_input.lower() == 'quit':
print('Goodbye!')
break
# Send the user's message with tool declarations attached
contents = [user_input]
response = client.models.generate_content(model=model_id, contents=contents, config=config)
part = response.candidates[0].content.parts[0]
# Check if the model wants to use a tool
if part.function_call:
print('(Tool call detected!)')
else:
print(f'Agent: {response.text}')
What does this code do?
types.Tool(function_declarations=TOOL_DECLARATIONS) wraps your declarations so the SDK can send them with every request. types.GenerateContentConfig(tools=[tools]) attaches the tool config to the API call.
After the model responds, part.function_call is truthy when the model wants to use a tool instead of answering directly. Right now the code just prints a placeholder message. You'll replace that with actual tool execution next.
- Save agent.py and run it:
python agent.py
- Type What's the weather in London? and press Enter.
You should see (Tool call detected!) printed. The model recognized that your question needs a tool, but the code doesn't handle the call yet.
- Type quit to exit the agent.
Now replace the placeholder with code that actually executes the tool and sends the result back to the model.
- In agent.py, find this line inside the if part.function_call: block:
print('(Tool call detected!)')
- Replace that entire line with this code, keeping the same indentation level:
fc = part.function_call
tool_name = fc.name
tool_args = dict(fc.args) if fc.args else {}
print(f' [Tool Call] {tool_name}({json.dumps(tool_args)})')
# Run the matching Python function
if tool_name in TOOL_FUNCTIONS:
result = TOOL_FUNCTIONS[tool_name](**tool_args)
else:
result = {'error': f'Unknown tool: {tool_name}'}
print(f' [Tool Result] {json.dumps(result)}')
# Send the tool result back so the model can write its final answer
contents.append(response.candidates[0].content)
fn_response = types.Part.from_function_response(name=fc.name, response=result, id=fc.id)
contents.append(types.Content(role='user', parts=[fn_response]))
response = client.models.generate_content(model=model_id, contents=contents, config=config)
print(f'Agent: {response.text}')
What does this code do?
fc.name and fc.args extract the tool the model wants and the arguments it chose. The code looks up the matching Python function in TOOL_FUNCTIONS and runs it with those arguments.
types.Part.from_function_response() packages the tool's output as a message the model can read. You append it to contents and call generate_content a second time. The model now has real data and can write a grounded answer.
- Save agent.py and run it:
python agent.py
- Type What's the weather in London? and press Enter.
You should see three lines: a [Tool Call] line showing which function the model chose, a [Tool Result] line with the raw data, and a final Agent: answer that weaves the real data into a natural sentence.
Seeing an error when the tool call runs?
If you see an indentation error, make sure the replacement code inside if part.function_call: is indented 8 spaces (two levels). It must be deeper than the if line itself.
If the model answers without calling any tool, double check that you're passing config=config to generate_content. Without it, the model has no idea the tools exist.
Why isn't my agent calling the tool function?
✔️ 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
Your agent.py should contain these sections in this exact order. Scroll up through your file and confirm each one is present:
- Four import lines at the top: json, datetime, genai, and types.
- Three tool functions: get_weather, calculate, get_current_time.
- TOOL_FUNCTIONS dictionary mapping 3 names to functions.
- TOOL_DECLARATIONS list with 3 JSON schema entries (get_weather, calculate, get_current_time).
- Client setup, tool config, and the while True loop with the full if part.function_call: handling block (not the placeholder).
Test your tools
Your agent can now use real tools instead of guessing. Before you move on, test each tool and see how the model handles a question that needs more than one.
- Run the agent if it's not already running:
python agent.py
Before you test each question below, predict what you expect to see. Which tool should the model pick? What result should it return?
- Type What's the weather in Tokyo? and press Enter.
You should see [Tool Call] get_weather({"city": "Tokyo"}) followed by [Tool Result] with 22°C and Partly Cloudy, then a natural-language answer from the agent.
- Type What is 847 * 293? and press Enter.
The calculator tool returns 248171. Compare that to the vague guess your agent gave back in the previous step.
- Type What time is it? and press Enter.
You should see your actual system time in the tool result, not a fabricated guess.
- Now try a harder question. Type What's the weather in Tokyo and London? and press Enter.
The model calls one tool and answers, but it can only handle a single tool call before responding. The second city's data either gets guessed or skipped. That one-call ceiling is the limitation you'll fix in the next step.
- Type quit to exit the agent.
Your agent now uses real tools for weather, math, and time. The gap is that it can only handle one tool call per question. Next up, you'll build an agentic loop that lets the model call as many tools as it needs, chaining results together to solve multi-step problems.
Build the Agentic Loop
In the last step, your agent learned to call a tool, read the result, and give a grounded answer. Try asking it a question that needs two tools, though, and the single-call approach falls apart.
The fix is a while loop that wraps the entire call-and-respond cycle. The model calls a tool, gets the result, decides whether it needs more information, and keeps looping until it has everything it needs to answer.
In this step, get ready to:
- Wrap the function-calling logic in a while loop that chains multiple tool calls.
- Test multi-step reasoning with questions that require more than one tool.
Replace the old loop with the agent setup
The current code handles tool calls inline, with no way to loop back for a second call. You will replace the entire bottom section of agent.py with a dedicated run_agent function that can call tools as many times as needed.
- In agent.py, find the line that says client = genai.Client() (just below the TOOL_DECLARATIONS list).
- Select everything from that line to the end of the file and delete it.
- Add the following code at the bottom of the file, right after the closing ] of TOOL_DECLARATIONS:
class AgentState:
"""Tracks tool calls and conversation history."""
def __init__(self):
self.tool_calls = []
self.conversation_history = []
def run_agent(user_message, state, max_iterations=10):
"""Run the agent loop: reason, call tools, observe, repeat."""
client = genai.Client()
model_id = 'gemini-2.5-flash'
# Set up tool declarations and a system instruction
tools = types.Tool(function_declarations=TOOL_DECLARATIONS)
config = types.GenerateContentConfig(
tools=[tools],
system_instruction=(
'You are a helpful assistant with access to tools. '
'Use the available tools to answer questions accurately. '
'Always prefer using a tool over guessing.'
),
)
# Start from conversation history and add the new message
contents = list(state.conversation_history)
contents.append(user_message)
print(f'\nYou: {user_message}')
What does this code do?
AgentState is a simple class that holds two lists: one for tracking tool calls and one for conversation history. It is mostly scaffolding for now. You will wire it up fully in the next step.
run_agent is the function that will contain the agentic loop. It creates a fresh Client, configures the tools and a system_instruction that tells the model to prefer tools over guessing, then builds the contents list the model will see.
Add the agentic while loop
This is the core of the agent. Each iteration calls the model, checks whether it wants to use a tool, executes the tool if so, feeds the result back, and loops again. The loop only breaks when the model returns a plain text response.
- Still inside run_agent, add the while loop directly below the print(f'\nYou: {user_message}') line:
# Loop: call the model, check for tool calls, repeat
iteration = 0
while iteration < max_iterations:
iteration += 1
print(f' [Iteration {iteration}]')
response = client.models.generate_content(model=model_id, contents=contents, config=config)
part = response.candidates[0].content.parts[0]
if part.function_call:
fc = part.function_call
tool_name = fc.name
tool_args = dict(fc.args) if fc.args else {}
print(f' [Tool Call] {tool_name}({json.dumps(tool_args)})')
if tool_name in TOOL_FUNCTIONS:
result = TOOL_FUNCTIONS[tool_name](**tool_args)
else:
result = {'error': f'Unknown tool: {tool_name}'}
print(f' [Tool Result] {json.dumps(result)}')
# Feed the tool result back so the model can continue
contents.append(response.candidates[0].content)
fn_response = types.Part.from_function_response(name=fc.name, response=result, id=fc.id)
contents.append(types.Content(role='user', parts=[fn_response]))
else:
# Model returned text, so we have our final answer
answer = response.text
print(f'\nAgent: {answer}')
return answer
return 'I reached the maximum number of steps without a final answer.'
What does this loop do?
- Each iteration calls generate_content and checks whether the model's response contains a function_call.
- If it does, the loop executes the matching Python function, appends the result back to contents using types.Part.from_function_response, and loops again so the model can decide what to do next.
- If the response is plain text instead of a tool call, the loop breaks and the answer is returned.
- max_iterations is a safety limit. If the model keeps calling tools without producing a final answer, the loop stops after 10 iterations.
Why is this the core agentic pattern?
This loop is the reason-act-observe cycle that defines an AI agent. The model reasons about what it needs, acts by calling a tool, observes the result, and then reasons again. Production agentic systems like Claude Code use the same pattern under the hood.
Add the main function
- Add the following code at the very end of agent.py, below the run_agent function:
def main():
"""Chat loop that sends user input to the agent."""
state = AgentState()
print('=== AI Agent ===')
print("Ask me anything! I can check weather, do math, and tell the time.")
print("Commands: 'quit' = exit\n")
while True:
user_input = input('You: ').strip()
if not user_input:
continue
if user_input.lower() == 'quit':
print('Goodbye!')
break
# Send user input to the agent
run_agent(user_input, state)
if __name__ == '__main__':
main()
What does this code do?
- main() creates a single AgentState instance and passes it to run_agent on every question. This keeps the state alive across the entire session.
- if __name__ == '__main__': makes sure main() only runs when you execute the file directly, not when another file imports it.
- Save agent.py.
- Run the agent to confirm the refactor works by running this command:
python agent.py
- Type What's the weather in London? and press Enter.
You should see an iteration number, a [Tool Call] for get_weather, a [Tool Result] with the weather data, and then the agent's final answer grounded in that data.
Seeing a NameError or import error?
- Make sure you deleted the old loop code completely. If any lines from the old while True: block remain, they will conflict with the new functions.
- Check that class AgentState: sits directly below the closing ] of TOOL_DECLARATIONS, with a blank line between them.
- Confirm the while iteration < max_iterations: block is indented inside run_agent, not at the top level.
Help me debug my agent.py refactor
✔️ Awesome, I've got everything!
Great. Double check you have saved agent.py.
ⓧ I'd like to double check the full code
Your agent.py is now too long to show in a single block. Scroll up and confirm these sections are present in order:
- Imports (json, datetime, genai, types).
- Three tool functions: get_weather, calculate, get_current_time.
- TOOL_FUNCTIONS dictionary and TOOL_DECLARATIONS list.
- AgentState class with __init__.
- run_agent function with the while iteration < max_iterations: loop inside it.
- main() function and if __name__ == '__main__': at the bottom.
Test multi-step reasoning
A single tool call already worked in the last step. The real payoff of the while loop is chaining multiple tools together in one question.
- If you quit the agent, start it again by running:
python agent.py
Before you type the next question, think about what you expect to happen. How many tool calls will the agent need to answer a question that combines weather data with a math calculation?
- Type this question and press Enter:
What's the weather in Tokyo and what's 72 minus the temperature?
Watch the output closely. You should see at least two iterations. In the first, the agent calls get_weather to get Tokyo's temperature. In the second, it calls calculate to subtract that value from 72. Then it combines both results into a final answer.
- Try another chained question:
What day is it today and what's 2 raised to the power of the day number?
The agent calls get_current_time to find today's date, then calculate to compute the exponent. This is the core agentic pattern in action: reason, act, observe, repeat.
- Type quit to exit the agent.
Agent only calls one tool and stops?
- Make sure the if part.function_call: branch does NOT contain a return or break statement. Only the else branch (text response) should return.
- Verify that after appending the fn_response, the loop goes back to the top and calls generate_content again. The tool call branch should just append to contents and let the loop continue.
Help me fix my agentic loop so it chains multiple tool calls
Your agent can now chain as many tool calls as it needs to answer complex questions. There is one thing missing, though. Try asking a follow-up question that references your previous conversation. The agent has no memory of what you just discussed. In the next step, you will add state tracking and conversation memory to fix that.
Add State and Conversation Memory
In the last step you built the agentic loop that chains multiple tool calls to solve complex questions. But try asking a follow-up question and the agent has no idea what you just discussed.
Right now your agent treats every question as a fresh conversation. Adding state tracking and conversation memory turns it from a one-shot tool-caller into a persistent assistant that remembers context and can report on its own behavior.
In this step, get ready to:
- Add record_tool_call and print_stats methods to the AgentState class.
- Wire conversation history into the agentic loop so the agent remembers prior turns.
- Add a stats command that shows tool usage data.
Flesh out the AgentState class
Your AgentState class already exists in agent.py, but it is just an empty shell. It needs two methods: one to log every tool call, and one to print a summary of what the agent has done.
- In agent.py, find the existing AgentState class:
class AgentState:
def __init__(self):
self.tool_calls = []
self.conversation_history = []
- Replace the entire AgentState class with this expanded version:
# ---- Agent State ----
class AgentState:
"""Tracks the agent's progress and conversation history."""
def __init__(self):
self.tool_calls = []
self.conversation_history = []
def record_tool_call(self, tool_name, args, result, iteration):
self.tool_calls.append({
"tool": tool_name,
"args": args,
"result": result,
"iteration": iteration,
})
def print_stats(self):
print("\n--- Agent Stats ---")
print(f"Total tool calls: {len(self.tool_calls)}")
if self.tool_calls:
tools_used = [tc["tool"] for tc in self.tool_calls]
print(f"Tools used: {', '.join(tools_used)}")
print(f"Conversation turns: {len(self.conversation_history)}")
print()
What does this code do?
- record_tool_call appends a dictionary to self.tool_calls each time the agent uses a tool. It captures which tool was called, what arguments were passed, what result came back, and which loop iteration it happened on.
- print_stats reads the logged data and prints a summary. It shows the total number of tool calls, lists the names of every tool used, and reports how many conversation turns have happened.
The class has methods now, but nothing in the agentic loop calls them yet. Let's wire it in.
Wire tool call tracking into the agentic loop
Every time the agent executes a tool, the result gets printed but never recorded. One line connects the loop to the state tracker.
- In the run_agent function, scroll down to the if part.function_call: block.
- Find this line:
print(f" [Tool Result] {json.dumps(result)}")
- Add this line directly below it:
state.record_tool_call(tool_name, tool_args, result, iteration)
What does this line do?
This captures the tool name, arguments, result, and current iteration number every time the agent executes a tool. Without it, the agent does its work but leaves no record behind.
There is also a leftover debug line from the previous step that we can clean up now.
- Still in run_agent, find this line inside the while loop (right after iteration += 1):
print(f' [Iteration {iteration}]')
- Delete this line. The tool call and result prints already show each iteration clearly.
- Save agent.py.
- Test that the agent still works by running:
python agent.py
- Ask: What's the weather in London?
You should see the [Tool Call] and [Tool Result] output as before, followed by a natural-language answer. The tool call is now being silently recorded in state behind the scenes.
- Type quit to exit.
Seeing an error when you ask a question?
- Check that the state.record_tool_call line is indented at the same level as the print lines above it (inside the if part.function_call: block).
- Make sure the record_tool_call method in AgentState has self as its first parameter.
- Confirm you fully deleted the print(f' [Iteration {iteration}]') line and didn't accidentally remove iteration += 1 above it.
Help me debug my AgentState or run_agent wiring.
Add conversation memory and the stats command
Tool calls are being tracked, but the agent still forgets everything between questions. To fix that, you need to save the full conversation after each answer and load it back when the next question comes in.
- In the run_agent function, find the else block at the bottom of the while loop:
else:
answer = response.text
print(f'\nAgent: {answer}')
return answer
- Replace it with this version that saves conversation history before returning:
else:
answer = response.text
print(f"\nAgent: {answer}")
# Save the full conversation so the next question has context
state.conversation_history = contents
state.conversation_history.append(response.candidates[0].content)
return answer
What does this code do?
- state.conversation_history = contents saves everything that happened during this question: the user message, any tool calls, tool results, and the model's reasoning.
- The .append(response.candidates[0].content) line adds the model's final answer to the history too, so the agent remembers what it said.
- On the next question, contents = list(state.conversation_history) (already in your code from the previous step) loads this history back, giving the model full context from prior turns.
Now the agent remembers conversations, but the learner has no way to inspect its tool usage. The stats command fixes that.
- Scroll down to the main function and find this line:
print("Commands: 'quit' = exit\n")
- Replace it with this line to advertise the new command:
print("Commands: 'stats' = see tool usage, 'quit' = exit\n")
- Now find the quit check inside the while True: loop:
if user_input.lower() == 'quit':
print('Goodbye!')
break
run_agent(user_input, state)
- Add the stats handler between the quit check and the run_agent call, so the block looks like this:
if user_input.lower() == "quit":
print("Goodbye!")
break
if user_input.lower() == "stats":
state.print_stats()
continue
run_agent(user_input, state)
Why use continue here?
The continue keyword skips the rest of the loop body and jumps straight back to the next input() prompt. Without it, typing stats would print the summary and then also send "stats" to the agent as a question.
- Save agent.py.
Stats printing but showing 0 tool calls?
- Make sure you added the state.record_tool_call(...) line from the previous substep. The stats command reads data that record_tool_call writes.
- Check that the state.record_tool_call line sits inside the if part.function_call: block, not in the else block.
Help me debug my stats command or tool call tracking.
Test memory and stats
Everything is wired together. Time to prove the agent actually remembers what you discussed and tracks its own behavior.
- Run the agent by running:
python agent.py
- Ask: What's the weather in Tokyo?
You should see the agent call get_weather and give you a grounded answer about Tokyo's weather.
Before you ask your next question, think about this: you never told the agent to remember anything. Do you think it will know what city you just asked about?
- Now ask a follow-up: Was it hotter or colder than 20 degrees?
The agent should answer correctly by referencing Tokyo's temperature from your first question. It knows "it" means Tokyo because the full conversation history was loaded into the model's context.
- Type stats to see your tool usage summary.
You should see output showing at least 1 tool call to get_weather and at least 1 conversation turn.
- Type quit to exit.
What are the three layers of an AI agent?
You now have a complete agent with all three layers of agentic AI architecture.
- Reasoning loop: the while loop that plans, acts, observes, and repeats until it has a final answer.
- Tool use: function calling that gives the model access to live data and precise computation.
- State management: the AgentState class that tracks tool calls, records conversation history, and lets the agent report on its own behavior.
This is the same architecture that powers production agentic systems like Claude Code. The loop is bigger, the tools are more powerful, and the state is more complex, but the pattern is identical.
✔️ Awesome, I've got everything!
Great. Double check you've saved agent.py and that your agent responds to multi-turn questions and the stats command.
ⓧ I'd like to double check the full code
Your agent.py is now too long to show in one block. Scroll up and confirm each of these pieces is present in order:
- Imports at the top: json, datetime, genai, types.
- Three tool functions: get_weather, calculate, get_current_time.
- TOOL_FUNCTIONS dictionary and TOOL_DECLARATIONS list.
- AgentState class with __init__, record_tool_call, and print_stats.
- run_agent function with state.record_tool_call(...) in the tool call block, and conversation history saving in the else block.
- main function with stats command handling before run_agent.
Your agent can now reason, act, remember, and report. That is the full architecture of an AI agent. Next up, you'll prove this architecture is extensible by adding a brand new tool without touching the loop.
Secret mission
Add a Custom Tool
Your agent can reason, call tools, and remember conversations. But can you teach it a brand new trick without touching the agentic loop? Add a note-taking tool and watch the agent save files to disk, all on its own.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine and uses the Gemini API free tier, 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:
- Your project folder on the Desktop (contains agent.py, the venv virtual environment, and any note files created by the agent).
- Your Gemini API key in Google AI Studio.
- The GEMINI_API_KEY environment variable in your terminal session (disappears automatically when you close the terminal).
Keep everything running
No action needed. Choose this if you are still actively building on your agent or plan to add more tools.
- Your project folder stays on the Desktop, ready to pick up where you left off.
- Your Gemini API key stays active in Google AI Studio. It uses the free tier, so there is no charge.
- Next time you open a new terminal, you will need to reactivate your virtual environment and re-set the GEMINI_API_KEY environment variable. It only lasts for the current terminal session.
Pause - I'll come back to this later
Stop any running processes to free up your terminal. Your files and API key stay intact so you can return later.
- If your agent is still running in the terminal, type quit to exit, or press Ctrl+C to stop it.
- Deactivate your virtual environment by running:
deactivate
- When you come back, reactivate the virtual environment and re-set GEMINI_API_KEY before running python agent.py again.
Delete - I don't want to use this again
Remove all project files and revoke your API key so nothing lingers on your machine or Google account.
- If your agent is still running, type quit or press Ctrl+C to stop it.
- Deactivate your virtual environment by running:
deactivate
- Delete your project folder from the Desktop. On macOS or Linux, run:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
- On Windows (PowerShell), run:
cd ~/Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your project folder name"]]
- Delete your Gemini API key. Go to https://aistudio.google.com and open the API keys page.
- Find the API key you created for this project and delete it.
Nice Work!
Nice Work!
You just built a working AI agent from scratch in Python that autonomously reasons, calls tools, and solves multi-step problems.
You've learned how to:
- Build an agentic reasoning loop that plans, acts, observes, and repeats until the agent has everything it needs to answer a question.
- Register tools using LLM function calling so the model can request real actions (weather lookups, math, time checks) instead of guessing.
- Add state management and conversation memory so the agent tracks its own tool usage and remembers context across turns.
- Secret Mission: Add a custom save_note tool and watch the agent use it without any changes to the core loop, proving the architecture is extensible.
Ready to quiz yourself?