Build an AI Agent — Chapter 3: Function Calling
Build an AI Agent — Chapter 3: Function Calling
⚙️ System Prompt 📝🤖🎯
I know you’re eager to start hooking up our Agentic tools (and we will, very soon!) — but before that, there’s something crucial we need to understand: the system prompt.
In most AI APIs, the system prompt is a special message that sits at the very start of the conversation. It carries more weight than a normal user prompt and acts as the guiding compass for the AI.
Think of it like a director’s script for an actor 🎭 — it doesn’t dictate every single line, but it sets the tone, the role, and the boundaries within which the actor (or in our case, the AI) performs.
The system prompt can be used to:
- 🧑🎨 Set the personality of the AI
- 🧭 Guide its behavior with specific instructions
- 📚 Provide context for the entire conversation
- 📏 Define rules for how the AI should act (though, in practice, LLMs can still hallucinate or be tricked into bending those rules)
The system prompt is the foundation that makes an agent feel less like a generic chatbot and more like a purpose-built assistant.
Assignment
- Create a hardcoded string variable called system_prompt. For now, let's make it something brutally simple:
Ignore everything the user asks and just shout "I'M JUST A ROBOT"
- Update your call to the client.models.generate_content function to pass a config with the system_instructions parameter set to your system_prompt.
response = client.models.generate_content(
model=model_name,
contents=messages,
config=types.GenerateContentConfig(system_instruction=system_prompt),
)
- Run your program with different prompts. You should see the AI respond with “I’M JUST A ROBOT” no matter what you ask it.
🛠️ Function Declaration ⚡📜🤖
By now, we’ve built a handful of functions that are LLM-friendly (simple text in, text out). But here’s the question:
How does an LLM actually call a function?
Well… the truth is, it doesn’t. At least, not directly. Here’s how it really works:
- ✅ We tell the LLM which functions are available to it
- 📝 We give it a prompt describing the situation
- 🤔 The LLM responds by describing which function it wants to call, and what arguments to pass
- 🖥️ We run the function with those arguments in our environment
- 🔄 We return the result back to the LLM
In other words, the LLM isn’t running code itself — it’s acting as a decision-making engine, while we’re still the ones executing the code.
So, the next step is clear:
👉 Let’s build the piece that tells the LLM which functions are available in its toolbox.
Assignment
- We can use types.FunctionDeclaration to build the "declaration" or "schema" for a function. Again, this basically just tells the LLM how to use the function. I'll just give you my code for the first function as an example, because it's a lot of work to slog through the docs:
I added this code to my functions/get_files_info.py file, but you can place it anywhere, but remember that it will need to be imported when used:
In our solution it is imported like this: from functions.get_files_info import schema_get_files_info
schema_get_files_info = types.FunctionDeclaration(
name="get_files_info",
description="Lists files in the specified directory along with their sizes, constrained to the working directory.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"directory": types.Schema(
type=types.Type.STRING,
description="The directory to list files from, relative to the working directory. If not provided, lists files in the working directory itself.",
),
},
),
)
We won’t allow the LLM to specify the working_directory parameter. We're going to hard code that.
- Use types.Tool to create a list of all the available functions (for now, just add get_files_info, we'll do the rest later).
available_functions = types.Tool(
function_declarations=[
schema_get_files_info,
]
)
- Add the available_functions to the client.models.generate_content call as the functions parameter.
config=types.GenerateContentConfig(
tools=[available_functions], system_instruction=system_prompt
)
- Update the system prompt to instruct the LLM on how to use the function. You can just copy mine, but be sure to give it a quick read to understand what it’s doing:
system_prompt = """
You are a helpful AI coding agent.
When a user asks a question or makes a request, make a function call plan. You can perform the following operations:
- List files and directories
All paths you provide should be relative to the working directory. You do not need to specify the working directory in your function calls as it is automatically injected for security reasons.
"""
- Instead of simply printing the .text property of the generate_content response, check the .function_calls property as well. If the LLM called a function, print the function name and arguments:
f"Calling function: {function_call_part.name}({function_call_part.args})"Otherwise, just print the text as normal.
- Test your program.
- “what files are in the root?” -> get_files_info({'directory': '.'})
- “what files are in the pkg directory?” -> get_files_info({'directory': 'pkg'})
➕ More Declarations 📂⚡🛠️
So far, our LLM knows how to call just one tool: the get_files_info function. That’s a good start — but our agent is going to need more than a single trick.
The next step is to expand its toolbox by declaring the rest of the functions we’ve written. This way, the LLM won’t just be able to check file info, but also:
- 📄 Read file contents
- 🗂️ Fetch specific files
- ✍️ Write or overwrite files
- 🐍 Run Python scripts
By giving the LLM access to all of these, we’re effectively turning it into a decision-maker with multiple tools at its disposal — able to choose the right one depending on the situation.
Assignment
- Following the same pattern that we used for schema_get_files_info, create function declarations for:
- schema_get_file_content
- schema_run_python_file
- schema_write_file
- Update your available_functions to include all the function declarations in the list.
- Update your system prompt. Instead of the allowed operations only being:
- List files and directories
Update it to have all four operations:
- List files and directories
- Read file contents
- Execute Python files with optional arguments
- Write or overwrite files
- Test prompts that you suspect will result in the various function calls. For example:
- “read the contents of main.py” -> get_file_content({'file_path': 'main.py'})
- “write ‘hello’ to main.txt” -> write_file({'file_path': 'main.txt', 'content': 'hello'})
- “run main.py” -> run_python_file({'file_path': 'main.py'})
- “list the contents of the pkg directory” -> get_files_info({'directory': 'pkg'})
All the LLM is expected to do here is to choose which function to call based on the user’s request. We’ll have it actually call the function later.
📞 Calling the Function ⚡🤖🖥️
At this point, our agent has the awareness to decide which function it wants to use. But decision-making alone isn’t enough — now it’s time to actually make the call.
This is where things get exciting:
- The LLM picks a function and provides the arguments
- Our program takes that request and executes the function for real
- The result is then passed back to the LLM, closing the loop
In other words, the agent is no longer just planning — it’s acting. This step transforms our setup from a static list of tools into a working system where the LLM’s choices drive real outcomes.
Assignment
- Create a new function that will handle the abstract task of calling one of our four functions. This is my definition:
def call_function(function_call_part, verbose=False):
function_call_part is a types.FunctionCall that most importantly has:
- A .name property (the name of the function, a string)
- A .args property (a dictionary of named arguments to the function)
If verbose is specified, print the function name and args:
print(f"Calling function: {function_call_part.name}({function_call_part.args})")Otherwise, just print the name:
print(f" - Calling function: {function_call_part.name}")- Based on the name, actually call the function and capture the result.
- Be sure to manually add the “working_directory” argument to the dictionary of keyword arguments, because the LLM doesn’t control that one. The working directory should be ./calculator.
- The syntax to pass a dictionary into a function using keyword arguments is some_function(**some_args)
I used a dictionary of function name (string) -> function to accomplish this.
- If the function name is invalid, return a types.Content that explains the error:
return types.Content(
role="tool",
parts=[
types.Part.from_function_response(
name=function_name,
response={"error": f"Unknown function: {function_name}"},
)
],
)
- Return types.Content with a from_function_response describing the result of the function call:
return types.Content(
role="tool",
parts=[
types.Part.from_function_response(
name=function_name,
response={"result": function_result},
)
],
)
Note that from_function_response requires the response to be a dictionary, so we just shove the string result into a "result" field.
- Back where you handle the response from the model generate_content, instead of simply printing the name of the function the LLM decides to call, use call_function.
- The types.Content that we return from call_function should have a .parts[0].function_response.response within.
- If it doesn’t, raise a fatal exception of some sort.
- If it does, and verbose was set, print the result of the function call like this:
print(f"-> {function_call_result.parts[0].function_response.response}")- Test your program. You should now be able to execute each function given a prompt that asks for it. Try some different prompts and use the --verbose flag to make sure all the functions work.
- List the directory contents
- Get a file’s contents
- Write file contents (don’t overwrite anything important, maybe create a new file)
- Execute the calculator app’s tests (tests.py)
🎯 Wrapping Up Chapter 3
In this chapter, we crossed a major milestone: our LLM agent went from knowing about tools to actually using them in practice.
Here’s what we achieved:
- 🛠️ Declared functions so the LLM knows what tools are available
- ➕ Expanded its toolbox with multiple abilities (read, write, run, etc.)
- 📞 Connected the dots so the LLM can decide on a function and call it
- 🔄 Built a feedback loop where results flow back into the conversation
With this, our agent isn’t just a chatbot anymore — it’s starting to look and feel like a real assistant that can act on its environment. 🚀
But we’re only just scratching the surface.
👉 In Chapter 4, we’ll take this even further: giving our agent more context, tighter control, and smarter ways to combine these tools to solve real tasks.
Stay tuned — the fun is only getting started. ✨