Build a Budget Detective Agent
Build a Python agent that uses Gemini tools to analyze a budget CSV.
Introduction
30 Second Summary
Budget advice can sound convincing even when nobody has checked the transactions behind it. A recommendation becomes useful when every number can be traced back to its source.
In this project, you will build a command-line Budget Detective powered by an AI agent. The terminal exposes its model-tool-observation loop before the agent saves a grounded Markdown report from synthetic expenses.
What You'll Build
You ask the terminal to investigate one month of spending while your agent shows every tool decision on its way to a CSV-backed recommendation.
By the end of this project, you'll have:
- A chat-only baseline that tells you it cannot verify the values in transactions.csv without file access or tools.
- A ground-truth finance check that shows the Dining category at 320.0 plus monthly savings of 80.0 from a 25% cut.
- A bounded agent run that shows numbered turns with tool observations. Its grounded recommendation is saved in budget_report.md.
- Secret Mission: A human approval checkpoint that writes budget_report.md only after you enter y.
Are there any prerequisites?
This project assumes basic Python experience on Windows using Python 3.10 or newer.
You'll need Visual Studio Code plus access to Google AI Studio.
Before We Start
Before any setup, lock in the goal for your Windows PowerShell command-line Budget Detective. Its six-turn model-tool-observation loop lets you trace each Gemini decision from deterministic local finance evidence to a grounded recommendation before you approve the Markdown file write.
Set Up the Gemini Workspace
Your Budget Detective eventually asks the Gemini API to make decisions. This step creates the authenticated connection it needs before any agent logic can run.
Python will live in an isolated virtual environment inside one Visual Studio Code workspace. Native Windows PowerShell keeps your commands in the same operating system as your environment variable.
In this step, get ready to:
- Store a Gemini API auth key in the Windows user environment variable GEMINI_API_KEY.
- Create an isolated Python environment containing google-genai==2.25.0.
- Send a minimal request to Gemini from check_setup.py.
Create and store your Gemini API key
An API key authorizes your local program to use Gemini. A Windows user environment variable gives the Google GenAI SDK access to the key without placing it in your source code.
Your API key works like a password. Keep it out of screenshots during the setup process.
- Open your web browser.
- Go to Google AI Studio.
- Sign in with your Google account.
- Accept the terms if Google AI Studio prompts you.
- Open the API Keys page.
Google AI Studio may have created a key automatically when you accepted the terms. Choose the path that matches the page you see.
✔️ A key is already listed
- Copy the automatically created API key.
- Keep the API Keys page open until you store the key in Windows.
ⓧ No key is listed
- Select Create API key.
- Copy the generated API key.
- Keep the API Keys page open until you store the key in Windows.
Windows user variables persist across terminal sessions. A newly opened terminal receives the saved value when it starts.
- Press the Windows key to open Windows Search.
- Type Environment Variables.
- Select Environment Variables from the search results.
- Choose New under your user variables.
- Enter GEMINI_API_KEY in the Variable name field.
The new user variable now has the exact name the Google GenAI SDK detects automatically.
- Paste your copied API key into the Variable value field.
- Select OK to save the user variable.
- Close the browser page that displays the key.
You should see GEMINI_API_KEY listed under your user variables. Its saved value becomes available to new processes.
Why keep the key out of code?
Source files are easy to copy or commit to version control. An environment variable keeps the credential separate from every Python file you create in this project.
Having trouble saving the key?
- Confirm that you created the variable under your user variables.
- Check that the name is exactly GEMINI_API_KEY.
- Create a fresh terminal after saving the variable.
Help me check why my Gemini API key is unavailable to a new Windows terminal.
Create the isolated Python workspace
A virtual environment gives this project its own packages. Pinning Google GenAI SDK version 2.25.0 keeps the project dependency consistent.
- Press the Windows key to open Windows Search.
- Type Visual Studio Code.
- Press Enter to open Visual Studio Code.
- Select File from the top menu.
- Select Open Folder....
- Select New Folder in the folder picker.
- Enter budget-detective as the folder name.
- Press Enter to create the folder.
The folder picker now shows your new budget-detective folder on the Desktop.
- Select the budget-detective folder.
- Select Select Folder to open it as your workspace.
- Confirm that you trust the folder if the Workspace Trust dialog appears.
You should see budget-detective at the top of the Explorer sidebar. This confirms that every file created next lands in the same workspace.
- Select View from the top menu.
- Select Terminal.
- Open the terminal profile dropdown if the shell is not PowerShell.
- Select PowerShell from the available profiles.
- Check the installed Python version by running this command:
python --version
What does this check prove?
The command asks the Python interpreter on your Windows path to report its version. Google GenAI SDK 2.25.0 requires Python 3.10 or newer.
You should see Python 3.10 or a newer version. That confirms your interpreter can run the pinned SDK.
Seeing an older Python version?
- Install Python 3.10 or newer from the official Python website if the reported version is too old.
- Open a fresh VS Code PowerShell terminal after the installation.
- Run the version check again.
Help me get Python 3.10 or newer working in my VS Code PowerShell terminal.
- Create the isolated .venv environment by running this command:
python -m venv .venv
What does this command do?
Python creates an isolated environment named .venv inside budget-detective. Windows places that environment's Python interpreter inside its Scripts directory.
Activation is optional because every later command calls the environment's interpreter directly.
You should see a .venv folder appear in the Explorer sidebar. That folder now isolates this project's dependencies.
Don't see the virtual environment?
- Confirm that the terminal path ends with your budget-detective folder.
- Expand the workspace in the Explorer sidebar to look for .venv.
- Rerun the command after resolving any Python installation problem.
Help me diagnose why python -m venv .venv did not create the virtual environment in my project folder.
- Create requirements.txt in the Explorer sidebar by selecting New File.
- Add the pinned dependency by pasting this line:
google-genai==2.25.0
What does this dependency pin do?
The package name tells Python which SDK to install. The exact version pin makes every project run against Google GenAI SDK 2.25.0.
- Save requirements.txt.
- Install the pinned dependency into .venv by running this command:
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
What does this install command do?
The command uses the Python interpreter inside .venv. It reads the dependency pin from requirements.txt.
The installed SDK stays inside this project environment.
The terminal should finish the installation without an unresolved package error. Your isolated environment now contains Google GenAI SDK 2.25.0.
Dependency installation failed?
- Confirm that requirements.txt contains exactly the dependency line shown above.
- Confirm that the .venv folder exists inside budget-detective.
- Check that your internet connection allows Python to download packages.
Help me troubleshoot the Google GenAI SDK installation in my Windows virtual environment.
Verify the Gemini connection
A minimal interaction tests the full setup in one pass. Success proves that Python can import the SDK before the client authenticates with your environment variable.
- Create check_setup.py in the Explorer sidebar by selecting New File.
You should now see check_setup.py beside requirements.txt in the Explorer sidebar.
- Add the connection check by pasting this code into check_setup.py:
from google import genai
MODEL = "gemini-3.8-flash"
def main() -> None:
with genai.Client() as client:
interaction = client.interactions.create(
model=MODEL,
input="Reply with READY and nothing else.",
)
print("Gemini replied:", interaction.output_text)
if __name__ == "__main__":
main()
What does this code do?
- The genai import exposes the installed SDK.
- MODEL identifies gemini-3.8-flash for the request.
- genai.Client() detects GEMINI_API_KEY from the new process environment.
- client.interactions.create() sends the prompt to Gemini.
- interaction.output_text provides the model's text response.
- Save check_setup.py.
Before you run the check, what do you expect the authenticated model to return?
- Verify the Gemini connection by running this command:
.\.venv\Scripts\python.exe check_setup.py
What does this command verify?
The command runs check_setup.py with the interpreter inside .venv. The script then completes one authenticated Gemini interaction.
You should see Gemini replied: followed by a short response. That's the connection in place: your project can now reach Gemini through the isolated SDK.
Didn't receive a Gemini reply?
- Confirm that this terminal was created after you saved GEMINI_API_KEY.
- Check that the environment variable name contains no spaces.
- Rerun the dependency installation command if Python reports a missing import.
Help me debug why check_setup.py cannot receive a Gemini response without exposing my API key.
Use the second tab if you want to compare both project files with the completed versions.
✔️ Awesome, I've got everything!
Great. Your dependency file and connection check are saved in the open workspace.
ⓧ I'd like to double check the full code
- Compare your complete requirements.txt with this reference:
google-genai==2.25.0
What should match?
Your dependency file contains one exact package pin. This keeps the project on Google GenAI SDK 2.25.0.
- Compare your complete check_setup.py with this reference:
from google import genai
MODEL = "gemini-3.8-flash"
def main() -> None:
with genai.Client() as client:
interaction = client.interactions.create(
model=MODEL,
input="Reply with READY and nothing else.",
)
print("Gemini replied:", interaction.output_text)
if __name__ == "__main__":
main()
What should match?
Your script imports the SDK before defining MODEL and main(). The final guard calls main() when you run the file directly.
Your authenticated Gemini workspace is ready. Next, you will give the model a budgeting question that exposes the limits of text-only chat.
Expose the Chatbot Data Wall
You confirmed that the Gemini API can answer through your isolated Python environment. That gives the Budget Detective a working model connection.
A reliable recommendation needs evidence from the exact expense records. This step tests whether a text-only chatbot can verify a local CSV file that the model has never received.
In this step, get ready to:
- Add synthetic August 2026 expenses to transactions.csv.
- Create a text-only request that prohibits invented values.
- Run the baseline to expose its missing access to local evidence.
Create the synthetic expense file
Gemini API Free Tier submissions may be used to improve Google products. Synthetic records keep personal finances outside this experiment.
- Create transactions.csv inside the open project folder by pasting this dataset into a new file:
date,description,category,amount
2026-08-01,Apartment rent,Rent,1200.00
2026-08-03,Supermarket,Groceries,220.00
2026-08-06,Train pass,Transport,90.00
2026-08-08,Restaurant,Dining,180.00
2026-08-10,Concert,Entertainment,120.00
2026-08-12,Streaming bundle,Subscriptions,75.00
2026-08-16,Supermarket,Groceries,180.00
2026-08-18,Rideshare,Transport,60.00
2026-08-20,Takeout,Dining,140.00
2026-08-24,Games,Entertainment,80.00
2026-08-27,Cloud storage,Subscriptions,25.00
What does this data contain?
- Each row represents one synthetic purchase from August 2026.
- The category field groups purchases for later analysis.
- The amount field provides fixed values that you can verify independently.
- Save transactions.csv.
- Count the data rows beneath the header to confirm that the file contains eleven expenses.
You should see dates from 2026-08-01 through 2026-08-27. The last row records a cloud storage subscription.
Does the dataset look incomplete?
- Check that every transaction occupies one line if your row count differs.
- Remove any blank rows between the transactions.
- Confirm the filename is exactly transactions.csv.
Ask for help checking the file if the rows still look wrong: Help me compare my transactions.csv file with the expected synthetic dataset.
You have the evidence ready: eleven synthetic expenses now give later results a fixed source of truth.
✔️ Awesome, I've got everything!
The saved transactions.csv file matches the synthetic dataset.
ⓧ I'd like to double check the full code
date,description,category,amount
2026-08-01,Apartment rent,Rent,1200.00
2026-08-03,Supermarket,Groceries,220.00
2026-08-06,Train pass,Transport,90.00
2026-08-08,Restaurant,Dining,180.00
2026-08-10,Concert,Entertainment,120.00
2026-08-12,Streaming bundle,Subscriptions,75.00
2026-08-16,Supermarket,Groceries,180.00
2026-08-18,Rideshare,Transport,60.00
2026-08-20,Takeout,Dining,140.00
2026-08-24,Games,Entertainment,80.00
2026-08-27,Cloud storage,Subscriptions,25.00
Build the text-only baseline
A text-only baseline sends a prompt without supplying the expense records. Its wording forbids invented values so the response has to expose what the model can substantiate.
- Create chat_only.py inside the open project folder by pasting this code into a new file:
from google import genai
MODEL = "gemini-3.8-flash"
PROMPT = """
I have a local file named transactions.csv. Tell me which category is my
largest flexible expense and whether cutting it by 25% would save at least
$150 per month. You have no tools or file contents. Do not invent values.
Explain whether you can complete this request accurately.
"""
def main() -> None:
with genai.Client() as client:
interaction = client.interactions.create(
model=MODEL,
input=PROMPT,
)
print(interaction.output_text)
if __name__ == "__main__":
main()
What does this code do?
- The MODEL constant selects gemini-3.8-flash for the request.
- The PROMPT constant asks for an evidence-based budget conclusion.
- The main() function sends only PROMPT to the model.
- The interaction.output_text value exposes the model's response in the terminal.
- Save chat_only.py.
- Confirm the request passes PROMPT as its input.
The script contains no code that opens transactions.csv. It declares no tools for Gemini.
Seeing a problem in the script?
- Compare the opening and closing triple quotes around PROMPT if the editor highlights the string.
- Check that main() starts at the left edge of the final call.
- Confirm the filename uses an underscore in chat_only.py.
Ask for a focused comparison if the code still looks different: Help me compare my chat_only.py file with the expected text-only Gemini request.
✔️ Awesome, I've got everything!
Your chat_only.py baseline is saved and ready to test.
ⓧ I'd like to double check the full code
from google import genai
MODEL = "gemini-3.8-flash"
PROMPT = """
I have a local file named transactions.csv. Tell me which category is my
largest flexible expense and whether cutting it by 25% would save at least
$150 per month. You have no tools or file contents. Do not invent values.
Explain whether you can complete this request accurately.
"""
def main() -> None:
with genai.Client() as client:
interaction = client.interactions.create(
model=MODEL,
input=PROMPT,
)
print(interaction.output_text)
if __name__ == "__main__":
main()
Run the text-only baseline
The baseline is ready to face the same question your finished agent must answer. This run reveals whether a model connection alone provides access to local evidence.
Before you run it, consider whether the model can verify a category total from the local file.
- Test the baseline from PowerShell by running this command:
.\.venv\Scripts\python.exe chat_only.py
What should I see?
Gemini responds that it cannot verify the values in transactions.csv without file access or tools.
It may discuss budgeting in general. It should avoid presenting an exact category or savings amount as a verified result.
You have now caught the chatbot at its data boundary. A grounded answer needs an observation from transactions.csv.
Did the baseline fail differently?
- Confirm the terminal is still inside the open project folder if Python cannot locate the script.
- Start a fresh Visual Studio Code terminal if the current session predates the GEMINI_API_KEY user variable.
- Confirm input=PROMPT remains in the request if the response claims exact transaction values.
Get help diagnosing the specific result: Help me troubleshoot why my chat-only Gemini baseline did not show the expected data-access limitation.
Your baseline has exposed the missing link between a model and local evidence. Next, you will build deterministic finance functions that turn the synthetic CSV into verifiable observations.
Build Ground-Truth Finance Tools
Your chat-only baseline exposed the data wall. Gemini could discuss budgeting without verifying a single value in transactions.csv.
Deterministic Python tools turn each CSV row into a verifiable observation. You can test those numbers before Gemini ever requests them.
In this step, get ready to:
- Build a deterministic category summary from transactions.csv.
- Calculate percentage-based savings with input validation.
- Verify the known Dining values from PowerShell.
Build the expense summary tool
A reliable summary starts with one consistent path to the synthetic expense file. A small loader keeps file-reading logic out of the finance calculations.
- In the Explorer sidebar in Visual Studio Code create tools.py beside transactions.csv.
- Add the data-loading foundation to tools.py by pasting this code:
import csv
from collections import defaultdict
from pathlib import Path
DATA_FILE = Path(__file__).with_name("transactions.csv")
def _load_transactions() -> list[dict[str, str]]:
with DATA_FILE.open(newline="", encoding="utf-8") as file:
return list(csv.DictReader(file))
How does the loader work?
- Path builds a file location beside tools.py.
- DATA_FILE points to the existing transactions.csv file.
- _load_transactions() reads the file with UTF-8 encoding.
- csv.DictReader turns every transaction into a dictionary keyed by the CSV headers.
- Save tools.py.
- Confirm tools.py appears beside transactions.csv in the Explorer sidebar.
Can't find the new file?
Check that you created tools.py inside the same folder that contains transactions.csv.
If the name looks different, remove any extra file extension added after tools.py.
Help me check why my Python file cannot locate transactions.csv.
The summary function groups rows by category. It sorts the totals from highest to lowest so later decisions start with the largest expense.
- Place summarize_expenses() below _load_transactions() in tools.py by pasting this code:
def summarize_expenses() -> dict:
"""Return category totals and the overall total from transactions.csv."""
totals: defaultdict[str, float] = defaultdict(float)
for row in _load_transactions():
totals[row["category"]] += float(row["amount"])
ordered_totals = dict(
sorted(
((category, round(amount, 2)) for category, amount in totals.items()),
key=lambda item: item[1],
reverse=True,
)
)
return {
"category_totals": ordered_totals,
"overall_total": round(sum(totals.values()), 2),
}
How are the totals built?
- defaultdict(float) starts each new category at zero.
- The loop converts every amount value to a number before adding it to the matching category.
- sorted() orders the category totals from largest to smallest.
- The returned dictionary includes category_totals plus overall_total.
- Save tools.py.
- Confirm summarize_expenses() appears below _load_transactions() in the file.
Is the summary code flagged?
Check that the for loop sits inside summarize_expenses() with consistent indentation.
Confirm the return dictionary closes after the ordered_totals calculation.
Help me compare my summarize_expenses function with the expected structure.
Add guarded savings calculations
A savings estimate needs a valid category plus a percentage from zero through one hundred. Returning error observations gives the future agent useful evidence when an input falls outside those boundaries.
- Place calculate_savings() below summarize_expenses() in tools.py by pasting this code:
def calculate_savings(category: str, reduction_percent: float) -> dict:
"""Calculate monthly savings from reducing one category by a percentage."""
if not 0 <= reduction_percent <= 100:
return {"error": "reduction_percent must be between 0 and 100"}
rows = _load_transactions()
matching_total = sum(
float(row["amount"])
for row in rows
if row["category"].casefold() == category.casefold()
)
if matching_total == 0:
return {"error": f"Unknown category: {category}"}
monthly_savings = matching_total * reduction_percent / 100
return {
"category": category,
"current_total": round(matching_total, 2),
"reduction_percent": reduction_percent,
"monthly_savings": round(monthly_savings, 2),
}
How is the estimate protected?
- The first condition rejects a reduction_percent outside zero through one hundred.
- casefold() lets category matching ignore capitalization differences.
- An unmatched category returns an error dictionary instead of a misleading zero saving.
- monthly_savings applies the requested percentage to the matched category total.
- Save tools.py.
- Confirm calculate_savings() appears below summarize_expenses() in the file.
Is the savings function flagged?
Check that matching_total closes after the filtered generator expression.
Confirm the final return dictionary remains inside calculate_savings().
Help me find the structural problem in my calculate_savings function.
✔️ Awesome, I've got everything!
Your CSV loader plus both finance tools are now in place.
ⓧ I'd like to double check the full code
import csv
from collections import defaultdict
from pathlib import Path
DATA_FILE = Path(__file__).with_name("transactions.csv")
def _load_transactions() -> list[dict[str, str]]:
with DATA_FILE.open(newline="", encoding="utf-8") as file:
return list(csv.DictReader(file))
def summarize_expenses() -> dict:
"""Return category totals and the overall total from transactions.csv."""
totals: defaultdict[str, float] = defaultdict(float)
for row in _load_transactions():
totals[row["category"]] += float(row["amount"])
ordered_totals = dict(
sorted(
((category, round(amount, 2)) for category, amount in totals.items()),
key=lambda item: item[1],
reverse=True,
)
)
return {
"category_totals": ordered_totals,
"overall_total": round(sum(totals.values()), 2),
}
def calculate_savings(category: str, reduction_percent: float) -> dict:
"""Calculate monthly savings from reducing one category by a percentage."""
if not 0 <= reduction_percent <= 100:
return {"error": "reduction_percent must be between 0 and 100"}
rows = _load_transactions()
matching_total = sum(
float(row["amount"])
for row in rows
if row["category"].casefold() == category.casefold()
)
if matching_total == 0:
return {"error": f"Unknown category: {category}"}
monthly_savings = matching_total * reduction_percent / 100
return {
"category": category,
"current_total": round(matching_total, 2),
"reduction_percent": reduction_percent,
"monthly_savings": round(monthly_savings, 2),
}
Test both tools from PowerShell
Direct testing separates calculation problems from future model behavior. The known synthetic totals provide a fixed checkpoint for both functions.
Before you run the check, which two values should prove that the Dining calculation matches the CSV?
- Call both finance functions from the VS Code PowerShell terminal by running:
.\.venv\Scripts\python.exe -c "from tools import summarize_expenses, calculate_savings; print(summarize_expenses()); print(calculate_savings('Dining', 25))"
What should you see?
The first dictionary includes a Dining total of 320.0.
The second dictionary includes monthly_savings of 80.0 for a 25 percent reduction.
That is the data wall solved. Your project can now produce exact finance observations from the synthetic transactions.
Don't see the expected values?
Confirm the terminal is using the folder that contains tools.py plus transactions.csv.
Check the category spelling in transactions.csv if the result reports an unknown category.
Help me debug why my finance tools do not return the expected Dining values.
Your finance logic now produces trusted observations on demand. Next up, you will let Gemini select these tools inside an explicit agent loop.
Wire the Manual Agent Loop
Your finance functions now produce trusted totals from the synthetic CSV. They remain ordinary helpers until something can choose when to call them.
This step connects those functions to Gemini through function calling. Python stays responsible for every local execution.
The loop returns each observation to the same interaction. It stops when Gemini produces a grounded recommendation.
In this step, get ready to:
- Declare the two finance functions as tools Gemini can request.
- Execute each requested function through an allowlisted dispatcher.
- Continue the interaction until Gemini returns a grounded answer.
Declare the model's finance tools
A tool declaration tells Gemini which function exists. Its schema describes the arguments Gemini may supply.
- Create agent.py beside tools.py using the file controls in VS Code.
- Build the file foundation by pasting this code:
import json
from pathlib import Path
from typing import Callable
from google import genai
from tools import calculate_savings, summarize_expenses
MODEL = "gemini-3.8-flash"
MAX_TURNS = 6
REPORT_FILE = Path(__file__).with_name("budget_report.md")
TASK = """
Analyze the synthetic transactions in the available finance tools. Identify the
largest flexible spending category. Calculate the savings from reducing that
category by 25%, determine whether that reaches a $150 monthly target, and state
any shortfall. Use tool observations for every numeric claim. Do not answer until
you have called both available tools.
"""
What does this foundation do?
- The imports provide JSON serialization plus access to Gemini and your tested finance functions.
- MODEL selects the same Gemini model that your earlier scripts verified.
- MAX_TURNS gives the loop a firm stopping point.
- TASK requires evidence from both tools before the model answers.
- Save agent.py.
- Confirm Python can parse the foundation by running this command:
.\.venv\Scripts\python.exe agent.py
What should you see?
PowerShell returns to its prompt without a traceback. That confirms the imports and configuration are valid Python.
Seeing an import or syntax problem?
- Confirm agent.py sits beside tools.py.
- Check that the opening and closing triple quotes around TASK are present.
- Use the environment interpreter shown in the command so Python can load the installed SDK.
Ask for help with the exact traceback: Help me diagnose an import or syntax error in the foundation of agent.py.
The first declaration exposes the summary function without giving it any arguments. Gemini can request it whenever category totals are needed.
- Add the first tool declaration directly below TASK by pasting this code:
TOOLS = [
{
"type": "function",
"name": "summarize_expenses",
"description": "Read transactions.csv and return category totals and the overall total.",
"parameters": {
"type": "object",
"properties": {},
},
},
]
How does the declaration work?
- The function type tells Gemini that this tool represents callable application code.
- The name matches summarize_expenses() in tools.py.
- The empty properties object shows that the function needs no arguments.
The savings function needs two arguments. Its schema gives Gemini the allowed value types and the required field names.
- Find the final closing bracket of TOOLS.
- Insert this second declaration directly above that bracket:
{
"type": "function",
"name": "calculate_savings",
"description": "Calculate savings from reducing one expense category by a percentage.",
"parameters": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "An exact category name returned by summarize_expenses.",
},
"reduction_percent": {
"type": "number",
"description": "The percentage reduction, from 0 through 100.",
},
},
"required": ["category", "reduction_percent"],
},
},
Why does this schema matter?
- category must contain a category name from the summary observation.
- reduction_percent must contain a number.
- The required list tells Gemini that neither argument may be omitted.
- Save agent.py.
- Confirm both declarations form valid Python by running this command:
.\.venv\Scripts\python.exe agent.py
What should you see?
PowerShell returns to its prompt without a traceback. This proves both nested tool schemas are structurally valid Python.
Seeing a bracket error?
- Confirm the second declaration sits inside the square brackets assigned to TOOLS.
- Check that each nested dictionary closes with a matching brace.
- Compare the commas after each tool declaration with the reference later in this step.
Help me repair the tool schema nesting in agent.py.
A declaration gives Gemini a menu. The dispatcher connects each approved menu item to the local Python function that performs the work.
- Add the allowlisted function map and dispatcher below TOOLS by pasting this code:
TOOL_FUNCTIONS: dict[str, Callable[..., dict]] = {
"summarize_expenses": summarize_expenses,
"calculate_savings": calculate_savings,
}
def dispatch_tool(name: str, arguments: dict) -> dict:
"""Run only an allowlisted tool and turn failures into observations."""
function = TOOL_FUNCTIONS.get(name)
if function is None:
return {"error": f"Tool is not allowed: {name}"}
try:
return function(**arguments)
except Exception as error:
return {"error": f"{type(error).__name__}: {error}"}
What does the dispatcher control?
- TOOL_FUNCTIONS maps only the two declared names to executable local functions.
- dispatch_tool() rejects any function name outside that map.
- The exception handler converts a Python failure into an observation that Gemini can interpret.
Build the model-tool-observation loop
The loop starts with the investigation task and the tool declarations. Gemini responds with function-call steps when it needs evidence from the CSV.
- Add the first part of run_agent() below dispatch_tool() by pasting this code:
def run_agent(client: genai.Client) -> str:
interaction = client.interactions.create(
model=MODEL,
input=TASK,
tools=TOOLS,
)
for turn in range(1, MAX_TURNS + 1):
print(f"\nTurn {turn}")
tool_calls = [
step for step in interaction.steps if step.type == "function_call"
]
if not tool_calls:
final_answer = interaction.output_text
if not final_answer:
raise RuntimeError("The model returned neither tool calls nor text.")
return final_answer
How does the loop read Gemini's decision?
- The first interaction sends the task with both tool declarations.
- interaction.steps contains the structured steps returned by Gemini.
- The list comprehension keeps entries whose type is function_call.
- An interaction without tool calls supplies the final text that ends the loop.
- Save agent.py.
- Confirm the dispatcher and loop definition parse by running this command:
.\.venv\Scripts\python.exe agent.py
What should you see?
PowerShell returns to its prompt without a traceback. Python has successfully loaded the dispatcher and the first part of the loop.
Seeing an indentation problem?
- Align interaction and the for statement one indentation level inside run_agent().
- Align the if not tool_calls block one level inside the loop.
- Keep return final_answer inside the no-tool-calls branch.
Help me fix the indentation in my run_agent function.
A requested tool call contains a name, arguments, and a unique call ID. Python executes the name through the dispatcher and packages the observation with that same ID.
- Add the tool execution block after the final-answer branch inside run_agent() by pasting this code:
function_results = []
for call in tool_calls:
arguments = dict(call.arguments or {})
print(f"Tool requested: {call.name}({arguments})")
result = dispatch_tool(call.name, arguments)
print(f"Observation: {result}")
function_results.append(
{
"type": "function_result",
"name": call.name,
"call_id": call.id,
"result": [
{"type": "text", "text": json.dumps(result)}
],
}
)
How does an observation get packaged?
- call.arguments becomes the dictionary passed to the local function.
- dispatch_tool() returns either trusted data or a structured error observation.
- call.id links the observation to the request that produced it.
- json.dumps() converts the Python dictionary into text for the function result.
The continuation sends those results back into the existing interaction. Gemini can then request another tool or finish with text.
- Complete run_agent() by adding this continuation below the function-result block:
interaction = client.interactions.create(
model=MODEL,
previous_interaction_id=interaction.id,
input=function_results,
tools=TOOLS,
)
raise RuntimeError(f"Agent exceeded the {MAX_TURNS}-turn limit.")
Why continue the same interaction?
- previous_interaction_id preserves the model's earlier task and tool requests.
- input=function_results supplies the observations that answer those requests.
- The final runtime error stops an interaction that reaches the configured turn limit.
- Save agent.py.
- Confirm the completed loop parses by running this command:
.\.venv\Scripts\python.exe agent.py
What should you see?
PowerShell returns to its prompt without a traceback. The complete loop is now valid Python and ready to be called.
Seeing a continuation error?
- Keep the continuation request inside the turn loop.
- Keep the turn-limit error outside the loop.
- Check that function_results is spelled the same in the list declaration and continuation request.
Help me check the nesting of my interaction continuation.
Run the grounded agent
The final entry point creates the authenticated client and starts the loop. Once Gemini stops requesting tools, the same grounded recommendation is printed and saved.
- Add the program entry point below run_agent() by pasting this code:
def main() -> None:
with genai.Client() as client:
answer = run_agent(client)
print("\nFinal recommendation:\n")
print(answer)
REPORT_FILE.write_text(answer, encoding="utf-8")
print(f"\nSaved report to {REPORT_FILE.name}")
if __name__ == "__main__":
main()
What does the entry point do?
- The client context provides the authenticated Gemini connection.
- run_agent() returns only after Gemini produces final text.
- REPORT_FILE.write_text() saves that same answer to budget_report.md with UTF-8 encoding.
✔️ Awesome, I've got everything!
Your agent.py now contains the declarations, dispatcher, observation loop, and program entry point.
ⓧ I'd like to double check the full code
import json
from pathlib import Path
from typing import Callable
from google import genai
from tools import calculate_savings, summarize_expenses
MODEL = "gemini-3.8-flash"
MAX_TURNS = 6
REPORT_FILE = Path(__file__).with_name("budget_report.md")
TASK = """
Analyze the synthetic transactions in the available finance tools. Identify the
largest flexible spending category. Calculate the savings from reducing that
category by 25%, determine whether that reaches a $150 monthly target, and state
any shortfall. Use tool observations for every numeric claim. Do not answer until
you have called both available tools.
"""
TOOLS = [
{
"type": "function",
"name": "summarize_expenses",
"description": "Read transactions.csv and return category totals and the overall total.",
"parameters": {
"type": "object",
"properties": {},
},
},
{
"type": "function",
"name": "calculate_savings",
"description": "Calculate savings from reducing one expense category by a percentage.",
"parameters": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "An exact category name returned by summarize_expenses.",
},
"reduction_percent": {
"type": "number",
"description": "The percentage reduction, from 0 through 100.",
},
},
"required": ["category", "reduction_percent"],
},
},
]
TOOL_FUNCTIONS: dict[str, Callable[..., dict]] = {
"summarize_expenses": summarize_expenses,
"calculate_savings": calculate_savings,
}
def dispatch_tool(name: str, arguments: dict) -> dict:
"""Run only an allowlisted tool and turn failures into observations."""
function = TOOL_FUNCTIONS.get(name)
if function is None:
return {"error": f"Tool is not allowed: {name}"}
try:
return function(**arguments)
except Exception as error:
return {"error": f"{type(error).__name__}: {error}"}
def run_agent(client: genai.Client) -> str:
interaction = client.interactions.create(
model=MODEL,
input=TASK,
tools=TOOLS,
)
for turn in range(1, MAX_TURNS + 1):
print(f"\nTurn {turn}")
tool_calls = [
step for step in interaction.steps if step.type == "function_call"
]
if not tool_calls:
final_answer = interaction.output_text
if not final_answer:
raise RuntimeError("The model returned neither tool calls nor text.")
return final_answer
function_results = []
for call in tool_calls:
arguments = dict(call.arguments or {})
print(f"Tool requested: {call.name}({arguments})")
result = dispatch_tool(call.name, arguments)
print(f"Observation: {result}")
function_results.append(
{
"type": "function_result",
"name": call.name,
"call_id": call.id,
"result": [
{"type": "text", "text": json.dumps(result)}
],
}
)
interaction = client.interactions.create(
model=MODEL,
previous_interaction_id=interaction.id,
input=function_results,
tools=TOOLS,
)
raise RuntimeError(f"Agent exceeded the {MAX_TURNS}-turn limit.")
def main() -> None:
with genai.Client() as client:
answer = run_agent(client)
print("\nFinal recommendation:\n")
print(answer)
REPORT_FILE.write_text(answer, encoding="utf-8")
print(f"\nSaved report to {REPORT_FILE.name}")
if __name__ == "__main__":
main()
How to use this reference
Compare each section with your saved agent.py. Pay close attention to indentation inside run_agent().
Before you run the complete agent, which finance tool do you expect Gemini to request first?
- Save agent.py.
- Start the model-tool-observation loop by running this command:
.\.venv\Scripts\python.exe agent.py
What should you see?
You will see numbered turns followed by requests for summarize_expenses and calculate_savings. Each request is followed by the observation returned from your CSV-backed function.
The final recommendation uses the observed Dining total of 320.0 and monthly savings of 80.0. You will also see confirmation that the answer was saved to budget_report.md.
That is the agent loop working. Gemini chooses the tools while your Python application controls every execution and observation.
Agent not reaching a recommendation?
- Confirm the current PowerShell terminal has access to GEMINI_API_KEY.
- Check that both interaction requests include tools=TOOLS.
- Confirm tools.py and transactions.csv remain beside agent.py.
Help me debug why my manual agent loop is not producing a grounded recommendation.
Your model can now request evidence and reason over the returned observations. Next, you will test the guardrails and inspect the saved report.
Bound, Trace, and Save the Result
Your Budget Detective now completes a model-tool-observation loop. It returns a recommendation grounded in CSV evidence.
An open-ended loop can run too long. Unrestricted function dispatch can reach code that the model should never execute.
Visible traces make each decision reviewable. A saved Markdown report preserves the final result.
In this step, get ready to:
- Audit which local functions the model can execute.
- Run the six-turn loop to inspect its numbered traces.
- Review the grounded recommendation saved in budget_report.md.
Audit the safety boundaries
An allowlist controls which local functions an AI agent can execute. Error observations keep failures visible inside the loop.
- In agent.py from the previous step, review the two entries in TOOL_FUNCTIONS.
- Follow dispatch_tool() from its lookup to its unknown-tool response.
- Inspect the exception branch that converts a Python failure into an error dictionary.
- Locate the loop controlled by MAX_TURNS.
- Locate the three print statements for turn numbers, requested tools, and observations.
- Confirm the final line raises RuntimeError after the loop.
Why these boundaries matter
The dictionary limits execution to the two finance functions you tested earlier. The dispatcher converts failures into data that the loop can return to the model.
The turn limit guarantees a stopping point. The trace shows exactly what happened before that point.
- Choose the tab that matches how confident you feel about agent.py.
✔️ Awesome, I've got everything!
- Keep your existing agent.py unchanged.
- Save agent.py.
ⓧ I'd like to double check the full code
- Compare your existing agent.py with this exact finished file:
import json
from pathlib import Path
from typing import Callable
from google import genai
from tools import calculate_savings, summarize_expenses
MODEL = "gemini-3.8-flash"
MAX_TURNS = 6
REPORT_FILE = Path(__file__).with_name("budget_report.md")
TASK = """
Analyze the synthetic transactions in the available finance tools. Identify the
largest flexible spending category. Calculate the savings from reducing that
category by 25%, determine whether that reaches a $150 monthly target, and state
any shortfall. Use tool observations for every numeric claim. Do not answer until
you have called both available tools.
"""
TOOLS = [
{
"type": "function",
"name": "summarize_expenses",
"description": "Read transactions.csv and return category totals and the overall total.",
"parameters": {
"type": "object",
"properties": {},
},
},
{
"type": "function",
"name": "calculate_savings",
"description": "Calculate savings from reducing one expense category by a percentage.",
"parameters": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "An exact category name returned by summarize_expenses.",
},
"reduction_percent": {
"type": "number",
"description": "The percentage reduction, from 0 through 100.",
},
},
"required": ["category", "reduction_percent"],
},
},
]
TOOL_FUNCTIONS: dict[str, Callable[..., dict]] = {
"summarize_expenses": summarize_expenses,
"calculate_savings": calculate_savings,
}
def dispatch_tool(name: str, arguments: dict) -> dict:
"""Run only an allowlisted tool and turn failures into observations."""
function = TOOL_FUNCTIONS.get(name)
if function is None:
return {"error": f"Tool is not allowed: {name}"}
try:
return function(**arguments)
except Exception as error:
return {"error": f"{type(error).__name__}: {error}"}
def run_agent(client: genai.Client) -> str:
interaction = client.interactions.create(
model=MODEL,
input=TASK,
tools=TOOLS,
)
for turn in range(1, MAX_TURNS + 1):
print(f"\nTurn {turn}")
tool_calls = [
step for step in interaction.steps if step.type == "function_call"
]
if not tool_calls:
final_answer = interaction.output_text
if not final_answer:
raise RuntimeError("The model returned neither tool calls nor text.")
return final_answer
function_results = []
for call in tool_calls:
arguments = dict(call.arguments or {})
print(f"Tool requested: {call.name}({arguments})")
result = dispatch_tool(call.name, arguments)
print(f"Observation: {result}")
function_results.append(
{
"type": "function_result",
"name": call.name,
"call_id": call.id,
"result": [
{"type": "text", "text": json.dumps(result)}
],
}
)
interaction = client.interactions.create(
model=MODEL,
previous_interaction_id=interaction.id,
input=function_results,
tools=TOOLS,
)
raise RuntimeError(f"Agent exceeded the {MAX_TURNS}-turn limit.")
def main() -> None:
with genai.Client() as client:
answer = run_agent(client)
print("\nFinal recommendation:\n")
print(answer)
REPORT_FILE.write_text(answer, encoding="utf-8")
print(f"\nSaved report to {REPORT_FILE.name}")
if __name__ == "__main__":
main()
How the finished file fits together
The TOOL_FUNCTIONS dictionary defines the execution boundary. The dispatch_tool() function turns rejected calls or exceptions into observations.
The run_agent() loop records each turn before continuing the interaction. The main() function saves the returned answer in budget_report.md.
Run the bounded agent
A complete trace exposes the model's requests before Python executes them. It also records the JSON observations that support the final recommendation.
Before you run the finished agent, do you expect one tool request or a sequence of requests?
- In the PowerShell terminal from earlier, run the finished agent with this command:
.\.venv\Scripts\python.exe agent.py
What does this run prove?
- The numbered Turn lines show each pass through the bounded loop.
- The Tool requested: lines expose each selected function and its arguments.
- The Observation: lines show the ground-truth values returned to the model.
- The Final recommendation: line separates the model's completed answer from its tool work.
You'll see calls to summarize_expenses and calculate_savings across numbered turns. Each call is followed by an observation.
The run finishes with a grounded recommendation. You'll also see confirmation that budget_report.md was saved.
Run stopped before the report?
- Return to the PowerShell terminal from earlier where GEMINI_API_KEY is available.
- Use the last numbered turn to identify which tool request prevented the model from finishing.
- Compare the final observation with the arguments printed immediately above it.
Ask for help with the trace: Help me diagnose why my bounded Python agent stopped before saving budget_report.md.
Review the saved report
The REPORT_FILE.write_text() call saves the same answer with UTF-8 encoding. The report gives you a durable artifact from the traced run.
Before you open the report, do you expect its recommendation to differ from the terminal output?
- In the VS Code Explorer sidebar, select budget_report.md.
- Compare the report with the final recommendation in the terminal.
- Match each numeric claim with a value printed after Observation:.
You'll see the same recommendation in the terminal and budget_report.md. Its numeric claims are backed by the displayed tool observations.
You now have a Budget Detective whose saved recommendation can be traced back to each controlled tool call.
Secret mission
Add Human Approval Before Saving
Your agent currently saves every recommendation automatically. In this mission, you will add a human approval checkpoint so the report is written only after you explicitly approve it.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep the project ready, pause your work, or delete its files and credentials. Keeping these resources has no ongoing cost within the Gemini API Free Tier.
Resources you used:
- The local project files: requirements.txt, check_setup.py, transactions.csv, chat_only.py, tools.py, and agent.py.
- The .venv virtual environment containing the project dependency.
- Any budget_report.md generated through the approval path.
- The GEMINI_API_KEY Windows user variable.
- The Gemini API auth key in Google AI Studio.
Keep everything running
No action is needed. Choose this option if you plan to keep testing or extending the agent.
- Keep the project folder available in Visual Studio Code for more experiments.
- Keep GEMINI_API_KEY stored as a Windows user variable.
- Keep the Google AI Studio auth key active for future Gemini API requests.
- Continue using synthetic data because Gemini API Free Tier content may be used to improve Google products.
Keep Your Auth Key Private
The auth key remains active until you revoke it.
- Keep the auth key out of code and source control.
Pause - I'll come back to this later
Pause the project when you want to preserve every file without keeping your workspace open. No API requests occur while the scripts are not running.
- Press Ctrl+C in the Visual Studio Code PowerShell terminal if the agent is still running.
- Close Visual Studio Code.
- Leave the project folder in its current location.
- Leave the Windows user variable and Google AI Studio auth key in place.
Delete - I don't want to use this again
Complete removal clears the local project and its stored access configuration. The agent cannot run again without recreating those resources.
Deletion Is Permanent
Deleting the project folder removes your scripts and generated report. Revoking the auth key prevents it from authorizing future requests.
- Copy each file you want to keep to a location outside the project folder.
- Open Environment Variables from Windows Search.
- Remove the user variable named GEMINI_API_KEY.
- Open the API Keys page in Google AI Studio to locate the auth key used by this project.
- Revoke the auth key used by this project.
- Use the context menu for the top-level folder in the Visual Studio Code Explorer sidebar to reveal that folder in File Explorer.
- Close Visual Studio Code.
- Delete the highlighted project folder in File Explorer.
Nice Work!
Nice Work!
You cracked it! You now have a from-scratch AI agent that turns a natural-language budgeting goal into a grounded report.
You've learned how to:
- Run a chat-only baseline that exposes why a model cannot verify local expense data without tools.
- Build deterministic finance tools that turn synthetic CSV records into exact category totals plus savings estimates.
- Implement a bounded agent loop that returns tool observations to Gemini across multiple turns. Its allowlisted dispatcher converts failures into observations. Its six-turn limit keeps execution bounded. Each requested tool plus observation appears in the terminal. The final recommendation is saved as a Markdown report in budget_report.md.
- Secret Mission: Add a human approval gate that displays the final recommendation before any file write. Only y creates budget_report.md.
Ready to quiz yourself?