Build a Self-Extending AI Agent in a VM
Build an AI agent inside a KVM VM that writes its own Python tools on demand.
Introduction
30 Second Summary
Most AI assistants can only do what their creators programmed them to do. What if your assistant could teach itself new skills the moment it needed them?
In this project, you will build a personal AI agent that runs inside an isolated virtual machine on your Fedora Linux host. When the agent encounters something it cannot do, it writes its own Python tool, saves it, and uses it immediately.
What You'll Build
A multi-model AI agent with a tool registry and sandboxed executor, running inside a KVM virtual machine provisioned with cloud-init.
By the end of this project, you'll have:
- A running Fedora Cloud VM accessible via SSH from your host.
- A conversational AI agent powered by Gemma 4 with a markdown-driven personality you control.
- A self-extending system where Gemini 2.5 Flash writes new Python tools on demand and the agent uses them immediately.
- Secret Mission: Persistent memory so the agent remembers things across restarts.
Are there any prerequisites?
You need a Fedora Linux host with hardware virtualization support (Intel VT-x) and a Google account for a free API key. No prior experience with virtual machines or AI APIs is required.
Set Up KVM and Launch a Fedora Cloud VM
Your AI agent will write and execute its own code. That means you need a safe place for it to run. A virtual machine gives you complete isolation from your host system.
In this step, you will install KVM virtualization on your Fedora host, download a lightweight cloud image, and boot a VM that you can SSH into.
In this step, get ready to:
- Install KVM/QEMU and virt-install on your Fedora host.
- Download the Fedora Cloud 44 image and prepare a cloud-init ISO.
- Launch the VM and verify SSH access.
Install KVM/QEMU and virt-install on your Fedora host
KVM is a virtualization technology built into the Linux kernel. On Fedora, you can install everything you need with a single package group.
- Install the virtualization group and the genisoimage tool by running this command:
sudo dnf install @virtualization genisoimage -y
What does this command do?
The @virtualization group installs QEMU, libvirt, virt-install, and other tools needed to create and manage VMs.
The genisoimage package creates ISO files, which you will use to pass configuration data into the VM on first boot.
- Verify KVM is working by running:
virsh list --all
You should see an empty table with column headers (Id, Name, State). This confirms libvirt is running and ready to manage VMs.
Seeing a connection error?
If you see a "failed to connect to the hypervisor" message, the libvirt socket may not be active yet. Run sudo systemctl start virtqemud.socket and try again.
Also make sure your user is in the libvirt group. Run sudo usermod -aG libvirt $USER then log out and back in.
Still stuck? Help me fix my virsh connection error on Fedora
Download the Fedora Cloud 44 image and prepare a cloud-init ISO
Cloud images are minimal OS images designed to be configured automatically on first boot using cloud-init. Instead of a 20-minute manual install, you download a pre-built image and tell it who you are via a small ISO file.
- Create a working directory and download the Fedora Cloud Base 44 qcow2 image by running these commands:
mkdir -p ~/vms && cd ~/vms
curl -LO https://dl.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2
Why a cloud image instead of a full install?
A cloud image is a pre-built disk that boots in seconds. It expects configuration (username, SSH key) from cloud-init on first boot. This avoids a 20+ minute interactive installer and keeps your VM lightweight.
- Confirm the download finished by running:
ls -lh Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2
You should see the file listed with a size of around 400-500 MB.
Now you need to create the cloud-init files that tell the image who you are and how to let you in.
- Generate an SSH key pair (if you do not already have one) by running:
ssh-keygen -t ed25519 -f ~/.ssh/id_agent_vm -N ""
What does this do?
This creates a new SSH key pair specifically for your VM. The -N "" flag sets an empty passphrase so you can SSH in without typing a password each time.
- Create the meta-data file by running:
cat > meta-data <<EOF
instance-id: agent-vm-01
local-hostname: agent-vm
EOF
- Create the user-data file by running:
cat > user-data <<EOF
#cloud-config
users:
- name: agent
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- $(cat ~/.ssh/id_agent_vm.pub)
EOF
What do these files do?
The meta-data file sets the VM's instance ID and hostname. Cloud-init uses the instance ID to know whether this is a fresh boot.
The user-data file creates a user called agent with sudo access and installs your SSH public key so you can log in without a password.
- Generate the cloud-init ISO from these two files by running:
genisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data
Why an ISO?
Cloud-init looks for a drive labelled cidata on first boot. By packaging your config into an ISO with that volume label, the VM automatically picks it up and configures itself.
- Confirm both the image and the ISO exist by running:
ls
You should see Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2, seed.iso, meta-data, and user-data in the output.
genisoimage command not found?
Make sure you included genisoimage in the install command from the first substep. Run sudo dnf install genisoimage -y to install it now.
Need more help? Help me troubleshoot my genisoimage setup
Launch the VM with virt-install and verify SSH access
You now have everything needed to create a VM: the cloud image (the OS disk) and the seed ISO (your credentials). Time to launch it.
- Create and start the VM by running this command:
sudo virt-install \
--name agent-vm \
--memory 2048 \
--vcpus 2 \
--disk path=Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2,format=qcow2 \
--disk path=seed.iso,device=cdrom \
--os-variant fedora40 \
--import \
--noautoconsole \
--network network=default
What does this command do?
The --import flag tells virt-install to skip the OS installer and boot directly from the qcow2 disk.
The --disk path=seed.iso,device=cdrom attaches your cloud-init ISO as a virtual CD drive. On first boot, cloud-init reads it and creates your user account.
The --noautoconsole flag prevents virt-install from attaching to the VM console, letting the command return immediately.
- Wait about 30 seconds for the VM to boot and cloud-init to finish. Then check the VM is running:
virsh list --all
You should see agent-vm listed with a State of running.
- Find the VM's IP address by running:
virsh domifaddr agent-vm
- SSH into the VM using the IP address shown in the output:
ssh -i ~/.ssh/id_agent_vm agent@[[VM_IP="the IP address from virsh domifaddr"]]
You should see a shell prompt inside the VM. You are now logged in as the agent user on your isolated Fedora Cloud VM.
SSH connection refused or timed out?
Cloud-init needs about 30-60 seconds to finish on first boot. Wait a minute and try again.
If virsh domifaddr agent-vm shows no IP, the default network may not be active. Run sudo virsh net-start default and restart the VM with virsh reboot agent-vm.
Still stuck? Help me SSH into my KVM virtual machine
Your VM is running and you have SSH access. Next up, you will configure the VM's Python environment and set up the project structure for your AI agent.
Configure the VM Environment
Your VM is running and you can reach it over SSH. Right now it is a blank Fedora Cloud instance with nothing installed beyond the base operating system.
In this step you will install the AI SDK, lay out the project files your agent needs, and connect the VM to the free Google AI API.
In this step, get ready to:
- Confirm Python and create a virtual environment with the google-genai SDK installed.
- Create the project directory with configuration, personality, and context files.
- Obtain a free Google AI Studio API key and store it inside the VM.
Install Python and create a virtual environment inside the VM
Fedora Cloud 44 ships with Python 3.14 pre-installed. You need a virtual environment so the SDK lives in an isolated space that is easy to copy later.
- SSH into your agent-vm from your Fedora host by running this command:
ssh [[VM_USER="your-vm-username"]]@[[VM_IP="your-vm-ip-address"]]
How do I find the VM IP again?
Run virsh domifaddr agent-vm on your Fedora host to see the IP address assigned to the VM. Use the same username you configured in cloud-init.
- Confirm Python is available inside the VM by running:
python3 --version
✔️ I see Python 3.14
Python is ready. Move on to the next instruction.
ⓧ Command not found
Fedora Cloud 44 should include Python by default. If the command is missing, install it by running:
sudo dnf install python3 -y
Run python3 --version again to confirm the install succeeded.
- Create a virtual environment and activate it by running these commands:
python3 -m venv ~/agent-env
source ~/agent-env/bin/activate
What does this do?
The first command creates an isolated Python environment at ~/agent-env. The second command activates it so any packages you install go into that environment instead of the system Python.
- Install the Google GenAI SDK by running:
pip install google-genai
- Verify the install succeeded by running:
pip show google-genai
You should see package metadata including the version number and location inside your agent-env directory.
Seeing a network error during pip install?
Make sure your VM has internet access. Run ping -c 2 google.com to check connectivity. If it fails, your VM's network may not be configured correctly.
Also confirm the virtual environment is active. Your terminal prompt should start with (agent-env). If not, run source ~/agent-env/bin/activate again.
help me debug a pip install failure inside my Fedora VM
Set up the project structure with config and markdown files
Your agent needs a home directory with a clear layout. Configuration lives in one file, personality and context live in markdown files you can edit freely, and tools get their own folder.
- Create the project directory and its subdirectory for tools by running:
mkdir -p ~/agent/tools
- Move into the project directory by running:
cd ~/agent
- Create config.py with the model configuration by running this command:
cat << 'EOF' > config.py
# Model configuration - change these to swap models
CHAT_MODEL = "gemma-4-31b-it"
BUILDER_MODEL = "gemini-2.5-flash"
# API configuration
import os
API_KEY = os.environ.get("GEMINI_API_KEY", "your-api-key-here")
EOF
What does this file do?
This file stores your model IDs and API key reference in one place. CHAT_MODEL is the conversational model (Gemma 4) and BUILDER_MODEL is the smarter model (Gemini 2.5 Flash) that writes new tools.
If you ever want to swap models, you only need to change these two strings. The rest of the codebase reads from config.py so nothing else needs editing.
- Create personality.md to define your agent's character by running:
cat << 'EOF' > personality.md
# Agent Personality
You are Nova, a helpful and slightly witty personal assistant.
## Tone
- Friendly but concise
- You use casual language but stay informative
- You occasionally make dry observations
## Rules
- Always be honest about what you can and cannot do
- If you need a tool you do not have, say so clearly
- Keep responses short unless asked for detail
EOF
What is personality.md for?
This markdown file becomes the system instruction for the conversational model. It tells the AI who it is, how it should talk, and what rules to follow. You can change the name, tone, and rules to create a completely different personality.
- Create context.md with facts the agent should know about you by running:
cat << 'EOF' > context.md
# Context
## About the User
- Name: (fill in your name)
- Preferences: (fill in your preferences)
## Important Facts
- This agent runs inside a VM on Fedora Linux
- The goal is to eventually migrate this to a Raspberry Pi
- Keep things lightweight and portable
EOF
Personalise your context file
Open context.md in nano or vi and replace the placeholder lines with real information about yourself. The more relevant context you provide, the more useful the agent becomes.
- Confirm all files were created by running:
ls ~/agent/
You should see config.py, context.md, personality.md, and a tools/ directory in the output.
Missing a file?
If any file is missing, the heredoc command may not have run completely. Copy the full command from the top cat line through the closing EOF and paste it again. Make sure EOF appears on its own line with no leading spaces.
help me troubleshoot missing files from heredoc commands
✔️ Awesome, I've got everything!
Great. Your project structure is ready.
ⓧ I'd like to double check the full code
Here is config.py in full:
# Model configuration - change these to swap models
CHAT_MODEL = "gemma-4-31b-it"
BUILDER_MODEL = "gemini-2.5-flash"
# API configuration
import os
API_KEY = os.environ.get("GEMINI_API_KEY", "your-api-key-here")
Here is personality.md in full:
# Agent Personality
You are Nova, a helpful and slightly witty personal assistant.
## Tone
- Friendly but concise
- You use casual language but stay informative
- You occasionally make dry observations
## Rules
- Always be honest about what you can and cannot do
- If you need a tool you do not have, say so clearly
- Keep responses short unless asked for detail
Here is context.md in full:
# Context
## About the User
- Name: (fill in your name)
- Preferences: (fill in your preferences)
## Important Facts
- This agent runs inside a VM on Fedora Linux
- The goal is to eventually migrate this to a Raspberry Pi
- Keep things lightweight and portable
Set up the Google AI API key
The agent uses the Google AI API to talk to both Gemma 4 and Gemini 2.5 Flash. Both models are free of charge on the free tier, so this costs nothing.
- Open a browser on your Fedora host and navigate to:
https://aistudio.google.com
- Sign in with your Google account.
- If you are a new user, Google AI Studio automatically creates a default project and API key after you accept the Terms of Service. Copy the key from the API keys page.
- If you need a new key, click Create API key and follow the dialog to add a new key.
- Copy the API key to your clipboard. You will paste it in the next command.
Why is this free?
Both Gemma 4 and Gemini 2.5 Flash are free of charge on the Google AI API free tier. There is no billing required and no credit card needed for this project.
- Switch back to your SSH session in the VM and set the API key as an environment variable by running:
export GEMINI_API_KEY=your-api-key-here
- Replace your-api-key-here with the actual key you copied from AI Studio.
- Make the variable persist across sessions by adding it to your shell profile:
echo 'export GEMINI_API_KEY=your-api-key-here' >> ~/.bashrc
- Replace your-api-key-here in the command above with your actual key before running it.
- Verify the variable is set by running:
echo $GEMINI_API_KEY
You should see your API key printed in the terminal output.
Seeing an empty output?
If the echo command prints a blank line, the export did not stick. Run the export GEMINI_API_KEY=... command again with your actual key pasted in place of the placeholder.
Make sure there are no spaces around the = sign. The correct format is export GEMINI_API_KEY=abc123 with no quotes needed unless the key contains special characters.
help me set the GEMINI_API_KEY environment variable in my VM
Your VM is now fully configured with Python, the AI SDK, your project files, and API access. Next up, you will use these pieces to build the conversational agent that loads personality and context from your markdown files.
Build the Conversational Agent
Your project structure, personality file, and context file are all in place. Now it is time to build the central piece: the agent itself.
The agent reads your markdown files at startup and uses them as system instructions for the Gemma 4 model. It then enters a conversation loop where it remembers everything you have said, giving the model full context on every turn.
In this step, get ready to:
- Create the base agent file with a markdown-loading helper function.
- Implement a conversation loop that maintains full history across turns.
- Test the agent's personality and context awareness.
Create the base agent that loads personality and context
The agent needs a way to read your markdown personality and context files and pass their contents to the AI model as instructions. You will create a helper function for this, along with the imports the agent needs.
- Create a new file called agent.py in your project directory by running this command:
touch agent.py
- Open agent.py in your editor and add the module docstring and imports:
"""Main agent loop with multi-model orchestration."""
import os
from google import genai
from google.genai import types
import config
import tool_registry
import executor
import builder
What do these imports do?
- genai and types come from the google-genai SDK you installed earlier. They handle all communication with the Google AI API.
- config is your config.py file with model IDs and API key.
- tool_registry, executor, and builder are modules you will create in the next two steps. The agent references them now so the full architecture is wired from the start.
- Below the imports, add the load_markdown helper function:
def load_markdown(filepath: str) -> str:
"""Load a markdown file and return its contents."""
if os.path.exists(filepath):
with open(filepath, "r") as f:
return f.read()
return ""
What does this function do?
load_markdown safely opens a file and returns its text. If the file does not exist, it returns an empty string instead of crashing. This makes the agent resilient to missing optional files like memory.md that you might add later.
- Save agent.py.
Implement the chat loop with conversation history
The google-genai SDK tracks conversation history as a list of types.Content objects, each with a role (user or model) and text parts. By appending each exchange to this list, the model sees the full conversation on every turn.
- In agent.py, below load_markdown, add the main() function and the entry point:
def main():
base_dir = os.path.dirname(__file__)
personality = load_markdown(os.path.join(base_dir, "personality.md"))
context = load_markdown(os.path.join(base_dir, "context.md"))
tools = tool_registry.load_tools()
tools_manifest = tool_registry.get_tools_manifest(tools)
system_instruction = f"""{personality}
{context}
{tools_manifest}
When the user asks you to do something you cannot do with conversation alone,
say exactly: "I need a new tool for this." followed by a description of what the tool should do.
"""
client = genai.Client(api_key=config.API_KEY)
history = []
print("Agent started. Type 'quit' to exit.\n")
while True:
user_input = input("You: ").strip()
if user_input.lower() in ("quit", "exit"):
print("Goodbye!")
break
if not user_input:
continue
matching_tool = tool_registry.find_matching_tool(user_input, tools)
if matching_tool:
tool_info = tools[matching_tool]
result = executor.execute_tool(tool_info["run"], user_input)
print(f"[Used tool: {matching_tool}]")
print(f"Nova: {result}\n")
continue
history.append(types.Content(role="user", parts=[types.Part.from_text(text=user_input)]))
response = client.models.generate_content(
model=config.CHAT_MODEL,
contents=history,
config=types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.7,
max_output_tokens=1024,
),
)
reply = response.text
history.append(types.Content(role="model", parts=[types.Part.from_text(text=reply)]))
if "I need a new tool for this." in reply:
print("Nova: I do not have a tool for that yet. Let me build one...")
tool_name = builder.build_tool(user_input)
if tool_name:
tools = tool_registry.load_tools()
tools_manifest = tool_registry.get_tools_manifest(tools)
system_instruction = f"""{personality}
{context}
{tools_manifest}
When the user asks you to do something you cannot do with conversation alone,
say exactly: "I need a new tool for this." followed by a description of what the tool should do.
"""
result = executor.execute_tool(tools[tool_name]["run"], user_input)
print(f"[Built new tool: {tool_name}]")
print(f"Nova: Done! I created a new tool called '{tool_name}'. Here is the result: {result}\n")
else:
print("Nova: I tried to build a tool but it did not work out. Can you rephrase what you need?\n")
else:
print(f"Nova: {reply}\n")
if __name__ == "__main__":
main()
What does this code do?
The main() function orchestrates the entire agent. Here is a breakdown of its key sections:
- Setup phase: loads personality.md and context.md, then builds a combined system_instruction string that tells the model who it is and what it knows.
- Tool check: before calling the AI, the loop checks if an existing tool matches the user's request. If it does, the tool runs directly without using an API call.
- Conversation history: each user message and model reply is appended to history as a types.Content object. The full history is sent on every turn so the model remembers context.
- Self-extension trigger: if the model says "I need a new tool for this.", the agent calls the builder model to generate a new tool, reloads the registry, and uses the new tool immediately.
- Save agent.py.
Seeing a syntax error when saving?
The triple-quoted f-string for system_instruction appears twice in the file (once in setup, once inside the builder block). Make sure each f""" has a matching closing """.
help me fix a triple-quoted f-string syntax error in my agent.py file
✔️ Awesome, I've got everything!
Great. Double check you have saved agent.py with both the load_markdown function and the full main() function.
ⓧ I'd like to double check the full code
Here is the complete agent.py file:
"""Main agent loop with multi-model orchestration."""
import os
from google import genai
from google.genai import types
import config
import tool_registry
import executor
import builder
def load_markdown(filepath: str) -> str:
"""Load a markdown file and return its contents."""
if os.path.exists(filepath):
with open(filepath, "r") as f:
return f.read()
return ""
def main():
base_dir = os.path.dirname(__file__)
personality = load_markdown(os.path.join(base_dir, "personality.md"))
context = load_markdown(os.path.join(base_dir, "context.md"))
tools = tool_registry.load_tools()
tools_manifest = tool_registry.get_tools_manifest(tools)
system_instruction = f"""{personality}
{context}
{tools_manifest}
When the user asks you to do something you cannot do with conversation alone,
say exactly: "I need a new tool for this." followed by a description of what the tool should do.
"""
client = genai.Client(api_key=config.API_KEY)
history = []
print("Agent started. Type 'quit' to exit.\n")
while True:
user_input = input("You: ").strip()
if user_input.lower() in ("quit", "exit"):
print("Goodbye!")
break
if not user_input:
continue
matching_tool = tool_registry.find_matching_tool(user_input, tools)
if matching_tool:
tool_info = tools[matching_tool]
result = executor.execute_tool(tool_info["run"], user_input)
print(f"[Used tool: {matching_tool}]")
print(f"Nova: {result}\n")
continue
history.append(types.Content(role="user", parts=[types.Part.from_text(text=user_input)]))
response = client.models.generate_content(
model=config.CHAT_MODEL,
contents=history,
config=types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.7,
max_output_tokens=1024,
),
)
reply = response.text
history.append(types.Content(role="model", parts=[types.Part.from_text(text=reply)]))
if "I need a new tool for this." in reply:
print("Nova: I do not have a tool for that yet. Let me build one...")
tool_name = builder.build_tool(user_input)
if tool_name:
tools = tool_registry.load_tools()
tools_manifest = tool_registry.get_tools_manifest(tools)
system_instruction = f"""{personality}
{context}
{tools_manifest}
When the user asks you to do something you cannot do with conversation alone,
say exactly: "I need a new tool for this." followed by a description of what the tool should do.
"""
result = executor.execute_tool(tools[tool_name]["run"], user_input)
print(f"[Built new tool: {tool_name}]")
print(f"Nova: Done! I created a new tool called '{tool_name}'. Here is the result: {result}\n")
else:
print("Nova: I tried to build a tool but it did not work out. Can you rephrase what you need?\n")
else:
print(f"Nova: {reply}\n")
if __name__ == "__main__":
main()
Test the agent's personality
The agent imports tool_registry, executor, and builder at the top. Those modules do not exist yet, so you need minimal placeholder files before Python can run agent.py. You will replace these with full implementations in the next two steps.
- Create placeholder modules so the imports resolve by running these commands:
cat > tool_registry.py << 'EOF'
def load_tools(): return {}
def get_tools_manifest(t): return "No tools available yet."
def find_matching_tool(i, t): return None
EOF
cat > executor.py << 'EOF'
def execute_tool(func, arg, timeout=10): return ""
EOF
cat > builder.py << 'EOF'
def build_tool(req): return None
EOF
Why create placeholders?
Python requires all imported modules to exist before it can run any code in the file. These stubs return empty or no-op values so the agent falls through to the conversational path. You will build the real implementations in Steps 4 and 5.
- Run the agent by executing:
python agent.py
- You should see Agent started. Type 'quit' to exit. printed in your terminal.
- Type a greeting like Hi, who are you? and press Enter.
- Verify the agent responds with Nova's personality: friendly, concise, and slightly witty.
- Ask something that uses your context file, like What system am I running you on? and confirm it mentions a VM on Fedora Linux.
- Type quit to exit when you are done testing.
Agent not responding or crashing?
- If you see ModuleNotFoundError, confirm you created all three placeholder files (tool_registry.py, executor.py, builder.py) in the same directory as agent.py.
- If you get an API error, check that your GEMINI_API_KEY environment variable is still set by running echo $GEMINI_API_KEY.
- If the response is generic and ignores personality, verify that personality.md is in the same directory and is not empty.
My agent is crashing or giving unexpected responses. Help me debug.
Your agent is alive and chatting with the personality you designed. Next up, you will build the tool registry and sandboxed executor so the agent can do more than just talk.
Build the Tool Registry and Executor
Your agent can hold a conversation, but it has no way to actually do things. If you ask it to calculate something or look up a fact, all it can do is generate text.
In this step, you will build two new modules that give the agent real capabilities. A tool registry discovers and loads Python tools from a folder. An executor runs those tools safely in isolated subprocesses so a misbehaving tool cannot crash your agent.
In this step, get ready to:
- Create a tool registry that discovers Python tools from a directory.
- Build a sandboxed executor that runs tools in isolated subprocesses.
- Create a calculator tool and test the full pipeline.
Create the tool registry that discovers and loads tools
The agent needs a way to know which tools are available and when to use them. The tool registry scans the tools/ directory at startup, loads every Python file it finds, and exposes each tool's description and trigger words.
- Create a new file called tool_registry.py in your project folder (the same directory as agent.py) by running:
touch tool_registry.py
- Open tool_registry.py in your editor and add the imports, directory constant, and the main discovery function:
"""Discovers and manages tools from the tools/ directory."""
import importlib.util
import os
TOOLS_DIR = os.path.join(os.path.dirname(__file__), "tools")
def load_tools() -> dict:
"""Scan tools/ directory and load all tool modules."""
tools = {}
if not os.path.exists(TOOLS_DIR):
os.makedirs(TOOLS_DIR)
return tools
for filename in os.listdir(TOOLS_DIR):
if filename.endswith(".py") and not filename.startswith("_"):
tool_name = filename[:-3]
filepath = os.path.join(TOOLS_DIR, filename)
spec = importlib.util.spec_from_file_location(tool_name, filepath)
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
tools[tool_name] = {
"module": module,
"description": getattr(module, "DESCRIPTION", "No description"),
"triggers": getattr(module, "TRIGGERS", []),
"run": module.run,
}
except Exception as e:
print(f"Warning: Failed to load tool '{tool_name}': {e}")
return tools
What does this code do?
TOOLS_DIR points to the tools/ folder sitting next to this file. The registry uses a relative path so the project stays portable across machines.
The load_tools() function uses Python's importlib to dynamically load each .py file from the tools folder. For each tool it stores the module's DESCRIPTION, TRIGGERS list, and run() function in a dictionary. If a tool fails to load, it prints a warning instead of crashing the agent.
- Below the load_tools() function, add the two helper functions that query the registry:
def find_matching_tool(user_input: str, tools: dict) -> str | None:
"""Find a tool whose triggers match the user input."""
user_lower = user_input.lower()
for tool_name, tool_info in tools.items():
for trigger in tool_info["triggers"]:
if trigger in user_lower:
return tool_name
return None
def get_tools_manifest(tools: dict) -> str:
"""Generate a text manifest of available tools for the AI."""
if not tools:
return "No tools available yet."
lines = ["Available tools:"]
for name, info in tools.items():
lines.append(f" - {name}: {info['description']}")
return "\n".join(lines)
What do these functions do?
find_matching_tool() checks the user's message against each tool's trigger words. If a trigger appears in the input, it returns that tool's name so the agent can execute it directly instead of sending the message to the AI model.
get_tools_manifest() builds a plain-text summary of all available tools. This gets injected into the AI's system instructions so the model knows what capabilities exist.
- Save tool_registry.py.
- Verify the module loads correctly by running:
python -c "import tool_registry; print(tool_registry.load_tools())"
You should see {} printed (an empty dictionary), because the tools/ folder has no tool files yet.
Seeing a ModuleNotFoundError?
Make sure you are running the command from the same directory that contains tool_registry.py. If you are in the wrong folder, Python cannot find the file.
Also confirm your virtual environment is active. You should see (venv) at the start of your terminal prompt.
Help me fix my tool_registry import error
✔️ Awesome, I've got everything!
Great. Double check you have saved tool_registry.py before moving on.
ⓧ I'd like to double check the full code
"""Discovers and manages tools from the tools/ directory."""
import importlib.util
import os
TOOLS_DIR = os.path.join(os.path.dirname(__file__), "tools")
def load_tools() -> dict:
"""Scan tools/ directory and load all tool modules."""
tools = {}
if not os.path.exists(TOOLS_DIR):
os.makedirs(TOOLS_DIR)
return tools
for filename in os.listdir(TOOLS_DIR):
if filename.endswith(".py") and not filename.startswith("_"):
tool_name = filename[:-3]
filepath = os.path.join(TOOLS_DIR, filename)
spec = importlib.util.spec_from_file_location(tool_name, filepath)
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
tools[tool_name] = {
"module": module,
"description": getattr(module, "DESCRIPTION", "No description"),
"triggers": getattr(module, "TRIGGERS", []),
"run": module.run,
}
except Exception as e:
print(f"Warning: Failed to load tool '{tool_name}': {e}")
return tools
def find_matching_tool(user_input: str, tools: dict) -> str | None:
"""Find a tool whose triggers match the user input."""
user_lower = user_input.lower()
for tool_name, tool_info in tools.items():
for trigger in tool_info["triggers"]:
if trigger in user_lower:
return tool_name
return None
def get_tools_manifest(tools: dict) -> str:
"""Generate a text manifest of available tools for the AI."""
if not tools:
return "No tools available yet."
lines = ["Available tools:"]
for name, info in tools.items():
lines.append(f" - {name}: {info['description']}")
return "\n".join(lines)
Build the sandboxed executor
Tools are just Python code, and some of that code will be written by an AI. Running untrusted code directly in your agent's process is risky. A subprocess with a timeout provides basic isolation: if a tool hangs or crashes, it cannot take down the agent.
Why subprocess instead of calling the function directly?
The agent will eventually write its own tools using AI-generated code. Running that code in the same process means a single infinite loop freezes everything. A subprocess with a timeout lets the agent kill a misbehaving tool after 10 seconds and continue operating.
This is not a production-grade sandbox, but it prevents the most common failure mode for self-generated code.
- Create executor.py in your project folder by running:
touch executor.py
- Open executor.py and add the full executor module:
"""Executes tool code in a sandboxed subprocess with timeout."""
import subprocess
import sys
import json
def execute_tool(tool_run_func, argument: str, timeout: int = 10) -> str:
"""Execute a tool's run function with a timeout using subprocess."""
tool_module_file = tool_run_func.__code__.co_filename
tool_module_name = tool_run_func.__module__
# Build a small script that re-imports the tool and runs it
script = f"""
import importlib.util
import sys
spec = importlib.util.spec_from_file_location("{tool_module_name}", "{tool_module_file}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
result = module.run({json.dumps(argument)})
print(result)
"""
try:
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=timeout,
)
if result.returncode == 0:
return result.stdout.strip()
else:
return f"Tool error: {result.stderr.strip()}"
except subprocess.TimeoutExpired:
return "Error: Tool execution timed out."
except Exception as e:
return f"Error executing tool: {e}"
What does this code do?
The executor looks up where the tool's source file lives on disk using __code__.co_filename. It then builds a small Python script that re-imports the tool module in a fresh subprocess and calls its run() function.
subprocess.run() launches that script with a 10 second timeout. If the tool finishes, stdout is returned. If it times out or errors, the executor returns a clean error message instead of crashing.
- Save executor.py.
- Verify the module loads without errors by running:
python -c "import executor; print('Executor loaded OK')"
You should see Executor loaded OK printed.
Seeing a SyntaxError?
Check that the f-string in the script variable uses triple quotes (f""") and that you have not accidentally added extra quotes inside the braces.
Help me debug my executor.py SyntaxError
✔️ Awesome, I've got everything!
Great. Double check you have saved executor.py before moving on.
ⓧ I'd like to double check the full code
"""Executes tool code in a sandboxed subprocess with timeout."""
import subprocess
import sys
import json
def execute_tool(tool_run_func, argument: str, timeout: int = 10) -> str:
"""Execute a tool's run function with a timeout using subprocess."""
tool_module_file = tool_run_func.__code__.co_filename
tool_module_name = tool_run_func.__module__
script = f"""
import importlib.util
import sys
spec = importlib.util.spec_from_file_location("{tool_module_name}", "{tool_module_file}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
result = module.run({json.dumps(argument)})
print(result)
"""
try:
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=timeout,
)
if result.returncode == 0:
return result.stdout.strip()
else:
return f"Tool error: {result.stderr.strip()}"
except subprocess.TimeoutExpired:
return "Error: Tool execution timed out."
except Exception as e:
return f"Error executing tool: {e}"
Wire the tools into the conversation loop
The agent code in agent.py already imports tool_registry and executor, and has the logic to check for matching tools before sending a message to the AI. What you need now is an actual tool to test the pipeline end-to-end.
You will create a calculator tool that the agent can invoke whenever someone asks a math question.
- Create the calculator tool file inside the tools/ directory by running:
touch tools/calculator.py
- Open tools/calculator.py and add the constants and the safe expression evaluator:
"""A simple calculator that evaluates mathematical expressions safely."""
import ast
import operator
DESCRIPTION = "Evaluates mathematical expressions. Use for any math calculations."
TRIGGERS = ["calculate", "math", "what is", "compute", "sum", "multiply", "divide"]
ALLOWED_OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
}
def safe_eval(node):
if isinstance(node, ast.Constant):
return node.value
elif isinstance(node, ast.BinOp):
left = safe_eval(node.left)
right = safe_eval(node.right)
op_type = type(node.op)
if op_type in ALLOWED_OPERATORS:
return ALLOWED_OPERATORS[op_type](left, right)
raise ValueError(f"Unsupported operator: {op_type}")
elif isinstance(node, ast.UnaryOp):
op_type = type(node.op)
if op_type in ALLOWED_OPERATORS:
return ALLOWED_OPERATORS[op_type](safe_eval(node.operand))
raise ValueError(f"Unsupported unary operator: {op_type}")
raise ValueError(f"Unsupported expression: {type(node)}")
What does this code do?
DESCRIPTION and TRIGGERS follow the standard tool interface. The registry uses DESCRIPTION to tell the AI what the tool does, and TRIGGERS to decide when to activate it.
safe_eval() walks a parsed AST tree and only allows basic math operations. This prevents code injection. Unlike Python's built-in eval(), it cannot run arbitrary code.
- Below safe_eval(), add the run() function that completes the tool interface:
def run(expression: str) -> str:
"""Evaluate a mathematical expression safely."""
try:
tree = ast.parse(expression, mode="eval")
result = safe_eval(tree.body)
return f"{expression} = {result}"
except Exception as e:
return f"Error evaluating '{expression}': {e}"
What does run() do?
Every tool must have a run() function that takes a string argument and returns a string result. The calculator parses the expression into an AST, evaluates it safely, and returns the result as a formatted string.
- Save tools/calculator.py.
- Test the calculator directly by running:
python -c "from tools import calculator; print(calculator.run('2 + 2'))"
You should see 2 + 2 = 4 printed.
Seeing an ImportError about tools?
Python needs the tools/ folder to be importable as a package. Create an empty __init__.py file inside it by running touch tools/__init__.py and try again. The tool registry uses importlib directly so it does not need this file, but testing with a direct import does.
Help me fix the calculator import error
- Now test the full pipeline by verifying the registry can discover and load the calculator tool:
python -c "import tool_registry; tools = tool_registry.load_tools(); print(tool_registry.get_tools_manifest(tools))"
You should see the tools manifest printed, listing calculator with its description.
- Finally, test the executor running the calculator in a subprocess:
python -c "import tool_registry, executor; tools = tool_registry.load_tools(); result = executor.execute_tool(tools['calculator']['run'], 'calculate 15 * 7'); print(result)"
You should see calculate 15 * 7 = 105 printed. The tool ran in a separate subprocess with a 10 second timeout and returned the result back to the agent.
Seeing a KeyError or empty output?
If you get a KeyError: 'calculator', the registry did not find the file. Check that tools/calculator.py exists and that you are running the command from the project directory (the folder containing tool_registry.py).
Help me debug my tool executor test
✔️ Awesome, I've got everything!
Great. Double check you have saved tools/calculator.py before moving on.
ⓧ I'd like to double check the full code
"""A simple calculator that evaluates mathematical expressions safely."""
import ast
import operator
DESCRIPTION = "Evaluates mathematical expressions. Use for any math calculations."
TRIGGERS = ["calculate", "math", "what is", "compute", "sum", "multiply", "divide"]
ALLOWED_OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
}
def safe_eval(node):
if isinstance(node, ast.Constant):
return node.value
elif isinstance(node, ast.BinOp):
left = safe_eval(node.left)
right = safe_eval(node.right)
op_type = type(node.op)
if op_type in ALLOWED_OPERATORS:
return ALLOWED_OPERATORS[op_type](left, right)
raise ValueError(f"Unsupported operator: {op_type}")
elif isinstance(node, ast.UnaryOp):
op_type = type(node.op)
if op_type in ALLOWED_OPERATORS:
return ALLOWED_OPERATORS[op_type](safe_eval(node.operand))
raise ValueError(f"Unsupported unary operator: {op_type}")
raise ValueError(f"Unsupported expression: {type(node)}")
def run(expression: str) -> str:
"""Evaluate a mathematical expression safely."""
try:
tree = ast.parse(expression, mode="eval")
result = safe_eval(tree.body)
return f"{expression} = {result}"
except Exception as e:
return f"Error evaluating '{expression}': {e}"
Your agent now has a working tool system. It can discover tools at startup, match user input to the right tool, and execute tools safely in isolated subprocesses. Next up, you will give the agent the ability to write brand new tools on its own using Gemini 2.5 Flash.
Implement Self-Extension with the Builder Model
Your agent can chat and use existing tools. But what happens when someone asks it to do something no tool covers?
In this step, you will create the builder module that connects your agent to a second, smarter AI model. When the agent detects it cannot handle a request, it calls Gemini 2.5 Flash to write a brand-new Python tool on the spot. The agent then hot-reloads the tool and uses it immediately.
In this step, get ready to:
- Understand how the agent detects a missing capability.
- Create the builder module that generates new tools with Gemini 2.5 Flash.
- Test self-extension end-to-end by triggering a new tool creation.
Detect when the agent cannot fulfill a request with existing tools
Your agent needs a way to signal that a request is beyond its current abilities. The mechanism is already wired into agent.py from the previous step.
- In agent.py, find the system_instruction string. Notice the final line:
When the user asks you to do something you cannot do with conversation alone,
say exactly: "I need a new tool for this." followed by a description of what the tool should do.
- Now scroll down to the while True loop and find this check:
if "I need a new tool for this." in reply:
print("Nova: I do not have a tool for that yet. Let me build one...")
tool_name = builder.build_tool(user_input)
How does detection work?
The system instruction tells the chat model (Gemma 4) to output a specific trigger phrase when it cannot handle a request on its own. After every model reply, agent.py checks for that exact string.
If the phrase is found, the agent calls builder.build_tool() to generate a new tool. This is the function you will create next.
Call Gemini 2.5 Flash to generate a new tool
The builder module sends the user's request to a more capable model along with a template that defines the expected tool structure. The model writes valid Python, and the builder saves it to the tools/ directory.
- Create a new file called builder.py in your project directory (the same folder that holds agent.py) by running:
touch builder.py
- Confirm the file exists by running:
ls
You should see builder.py listed alongside agent.py, config.py, and the other project files.
- Open builder.py in your editor and add the imports and tool template:
"""Uses the builder model to generate new tools on demand."""
from google import genai
from google.genai import types
import config
import os
TOOLS_DIR = os.path.join(os.path.dirname(__file__), "tools")
TOOL_TEMPLATE = '''"""TOOL_DESCRIPTION"""
DESCRIPTION = "TOOL_DESCRIPTION"
TRIGGERS = ["trigger1", "trigger2"]
def run(argument: str) -> str:
"""Execute the tool with the given argument and return a string result."""
# Tool implementation here
pass
'''
What does this code do?
The TOOL_TEMPLATE is a skeleton that shows the builder model the exact structure every tool must follow. It includes a DESCRIPTION string, a TRIGGERS list, and a run() function.
This template is embedded in the prompt sent to Gemini 2.5 Flash so the generated code always matches what the tool registry expects.
- Below the template, add the build_tool() function that calls the builder model:
def build_tool(user_request: str) -> str | None:
"""Ask the builder model to generate a new tool based on the user request."""
client = genai.Client(api_key=config.API_KEY)
prompt = f"""You are a Python tool builder. The user needs a tool that does not exist yet.
User request: {user_request}
Write a complete Python tool module following this exact template structure:
{TOOL_TEMPLATE}
Rules:
- The module must have a DESCRIPTION string, a TRIGGERS list, and a run(argument: str) -> str function.
- TRIGGERS should be lowercase words/phrases that indicate when this tool should be used.
- The run() function takes a single string argument and returns a string result.
- Keep it simple and self-contained. Only use standard library modules.
- Do NOT include any markdown formatting or code fences. Output only raw Python code.
- Make sure the tool actually works and handles errors gracefully.
"""
response = client.models.generate_content(
model=config.BUILDER_MODEL,
contents=prompt,
config=types.GenerateContentConfig(
temperature=0.2,
max_output_tokens=2048,
),
)
code = response.text.strip()
if code.startswith("```"):
code = code.split("\n", 1)[1]
if code.endswith("```"):
code = code[:-3]
tool_name = _extract_tool_name(user_request)
filepath = os.path.join(TOOLS_DIR, f"{tool_name}.py")
try:
compile(code, filepath, "exec")
except SyntaxError as e:
return None
with open(filepath, "w") as f:
f.write(code)
return tool_name
What does this code do?
The build_tool() function constructs a detailed prompt including the user's request and the tool template, then sends it to gemini-2.5-flash with low temperature for consistent code output.
After receiving the response, it strips any accidental markdown fences the model might add. It then uses Python's built-in compile() to verify the generated code is syntactically valid before saving it to disk.
If the code passes the syntax check, it gets written to the tools/ directory. If it fails, the function returns None and the agent tells the user it could not build the tool.
- Add the helper function that generates a file name from the user's request:
def _extract_tool_name(request: str) -> str:
"""Generate a simple tool name from the request."""
words = request.lower().split()
keywords = [w for w in words if len(w) > 3 and w.isalpha()]
name = "_".join(keywords[:3]) if keywords else "custom_tool"
return name
What does this code do?
This helper pulls up to three meaningful words from the user's request and joins them with underscores to create a descriptive file name. Short words (3 characters or fewer) and non-alphabetic tokens are filtered out.
For example, "convert temperature from celsius to fahrenheit" becomes convert_temperature_from.py.
- Save builder.py.
Seeing an import error when you run the agent?
Make sure builder.py is in the same directory as agent.py. The import builder line at the top of agent.py looks for the file relative to the script's location.
Also confirm you did not accidentally name it Builder.py with a capital B. Python imports are case-sensitive.
Help me fix an import error with builder.py
✔️ Awesome, I've got everything!
Great. Make sure you have saved builder.py before moving on.
ⓧ I'd like to double check the full code
"""Uses the builder model to generate new tools on demand."""
from google import genai
from google.genai import types
import config
import os
TOOLS_DIR = os.path.join(os.path.dirname(__file__), "tools")
TOOL_TEMPLATE = '''"""TOOL_DESCRIPTION"""
DESCRIPTION = "TOOL_DESCRIPTION"
TRIGGERS = ["trigger1", "trigger2"]
def run(argument: str) -> str:
"""Execute the tool with the given argument and return a string result."""
# Tool implementation here
pass
'''
def build_tool(user_request: str) -> str | None:
"""Ask the builder model to generate a new tool based on the user request."""
client = genai.Client(api_key=config.API_KEY)
prompt = f"""You are a Python tool builder. The user needs a tool that does not exist yet.
User request: {user_request}
Write a complete Python tool module following this exact template structure:
{TOOL_TEMPLATE}
Rules:
- The module must have a DESCRIPTION string, a TRIGGERS list, and a run(argument: str) -> str function.
- TRIGGERS should be lowercase words/phrases that indicate when this tool should be used.
- The run() function takes a single string argument and returns a string result.
- Keep it simple and self-contained. Only use standard library modules.
- Do NOT include any markdown formatting or code fences. Output only raw Python code.
- Make sure the tool actually works and handles errors gracefully.
"""
response = client.models.generate_content(
model=config.BUILDER_MODEL,
contents=prompt,
config=types.GenerateContentConfig(
temperature=0.2,
max_output_tokens=2048,
),
)
code = response.text.strip()
if code.startswith("```"):
code = code.split("\n", 1)[1]
if code.endswith("```"):
code = code[:-3]
tool_name = _extract_tool_name(user_request)
filepath = os.path.join(TOOLS_DIR, f"{tool_name}.py")
try:
compile(code, filepath, "exec")
except SyntaxError as e:
return None
with open(filepath, "w") as f:
f.write(code)
return tool_name
def _extract_tool_name(request: str) -> str:
"""Generate a simple tool name from the request."""
words = request.lower().split()
keywords = [w for w in words if len(w) > 3 and w.isalpha()]
name = "_".join(keywords[:3]) if keywords else "custom_tool"
return name
Hot-reload the new tool and use it immediately
The last piece of the puzzle is already in agent.py. After the builder saves a new tool file, the agent re-scans the tools/ directory, picks up the new module, and executes it right away.
- In agent.py, find the block that runs after builder.build_tool() returns successfully:
if tool_name:
tools = tool_registry.load_tools()
tools_manifest = tool_registry.get_tools_manifest(tools)
system_instruction = f"""{personality}
{context}
{tools_manifest}
When the user asks you to do something you cannot do with conversation alone,
say exactly: "I need a new tool for this." followed by a description of what the tool should do.
"""
result = executor.execute_tool(tools[tool_name]["run"], user_input)
print(f"[Built new tool: {tool_name}]")
print(f"Nova: Done! I created a new tool called '{tool_name}'. Here is the result: {result}\n")
What is happening here?
After build_tool() writes the new .py file, calling tool_registry.load_tools() again re-scans the tools/ directory and discovers the freshly created module. This is the hot-reload.
The system_instruction is also rebuilt so the chat model knows about the new tool on subsequent turns. Finally, the executor runs the new tool against the original user input and prints the result.
- Test self-extension end-to-end by running your agent:
python agent.py
- At the You: prompt, type something the agent has no tool for. For example:
You: reverse the string hello world
The agent should respond with a message that it is building a new tool, followed by the result. You will also see a new .py file appear in your tools/ directory.
- Type quit to exit the agent.
- Confirm the new tool was saved by running:
ls tools/
You should see calculator.py plus a new file whose name is derived from your request.
Agent says it tried but the tool did not work out?
This means the generated code failed the compile() syntax check. The model occasionally produces code with markdown fences or incomplete functions.
Try rephrasing your request to be more specific. For example, instead of "do something cool" try "reverse a string". More concrete requests produce better tool code.
Help me debug why my agent failed to build a tool
Why two models?
Gemma 4 is fast and free for everyday conversation. Gemini 2.5 Flash is better at writing structured code but has the same free-tier pricing. Splitting the workload means your agent stays responsive for chat while only calling the heavier model when it genuinely needs to create something new.
Both models are free of charge on the Google AI API, so this multi-model design costs nothing extra.
Secret mission
Persistent Memory Across Sessions
Your agent is smart, but it forgets everything the moment you restart it. In this secret mission, you'll give Nova persistent memory. It will save facts to a file when you ask it to remember something, and recall those facts even after a full restart.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local Fedora host inside a KVM virtual machine. Both AI models (Gemma 4 and Gemini 2.5 Flash) use the free tier of the Google AI API, 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:
- A KVM virtual machine (agent-vm) using disk space on your host.
- A Google AI Studio API key (free tier, no charges).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your agent. The VM stays available for use as your personal assistant whenever you want it.
- Your VM continues running in the background. It uses RAM and CPU only while active.
- Your Google AI Studio API key remains valid at no cost.
- The KVM/QEMU packages remain installed on your Fedora host.
Pause - I'll come back to this later
Shut down the VM to free up memory and CPU while keeping all your files intact. You can start it again later without losing anything.
- Shut down the VM by running:
- Start it again later by running:
virsh shutdown agent-vm
When you are ready to come back, start the VM with:
virsh start agent-vm
- Your project files, tools, and memory are all preserved on the VM's disk image.
- Your API key stays active and ready to use.
Delete - I don't want to use this again
Remove all project resources completely. This frees up disk space and revokes your API access.
- Destroy and remove the VM (this deletes all files inside it) by running:
virsh destroy agent-vm && virsh undefine agent-vm --remove-all-storage
What does this command do?
The virsh destroy command forces the VM to stop immediately. Then virsh undefine --remove-all-storage removes the VM's definition from libvirt and deletes its disk image file.
- Delete your Google AI Studio API key:
- Open the Google Cloud API Credentials page.
- Find your API key and click the Actions icon.
- Select Delete API key.
- Confirm deletion in the modal. The key stops working after a few minutes of propagation.
- Remove the Fedora Cloud qcow2 image you downloaded (if it still exists outside the VM storage) by running:
rm -f ~/Downloads/Fedora-Cloud-Base-*.qcow2
- Remove the cloud-init ISO you generated by running:
rm -f ~/cloud-init.iso
Nice Work!
Nice Work!
Nice work! You just built a self-extending AI assistant that runs inside an isolated virtual machine on your Fedora host.
You have learned how to:
- Set up a KVM virtual machine on Fedora using a pre-built Fedora Cloud image and cloud-init for automated provisioning.
- Built a multi-model AI agent powered by Gemma 4 for conversation and Gemini 2.5 Flash for generating new tools on demand.
- Created a tool registry with a sandboxed subprocess executor that discovers, loads, and safely runs Python tools with timeouts.
- Secret Mission: Added persistent memory so the agent remembers facts across restarts using a simple markdown file.
The entire project is a portable Python folder ready to copy to a Raspberry Pi whenever you are ready to take it off your desktop.
Ready to quiz yourself?