Build a Self-Extending AI Agent in a VM
Build a multi-agent system in a Fedora VM that writes and loads its own tools.
Introduction
30 Second Summary
Think about how you get better at your job over time. You learn new skills, pick up new tools, and adapt your approach based on experience. What if an AI agent could do the same thing?
In this project, you will build a self-extending AI agent that can write its own Python tools, load them at runtime, and grow its capabilities through conversation. The entire system runs inside a sandboxed virtual machine for safety.
What You'll Build
A terminal-based multi-agent system running inside a Fedora VM, where an orchestrator agent dispatches tasks to a tool-coder sub-agent that generates new Python tools on demand.
By the end of this project, you'll have:
- A running Fedora Workstation 43 VM with a Python development environment and Google AI Studio API access.
- A multi-agent orchestrator powered by Gemma 4 that manages conversation and dispatches tasks to specialized sub-agents.
- A self-extending tool system that writes Python tools on demand, persists them across sessions, and dynamically loads them at runtime.
- Secret Mission: Give the agent the power to rewrite its own system prompt and orchestration logic.
Are there any prerequisites?
This project requires a Fedora Linux host machine with virtualization enabled (VT-x/AMD-V in BIOS). You also need a Google account to access Google AI Studio for your API key.
Before We Start
Before We Start...
Set Up the Fedora VM
A self-extending AI agent that writes and executes its own code needs a safety boundary. If the agent makes a mistake, you want the damage contained to a disposable environment, not your main system.
In this step, you will create that boundary by setting up a QEMU/KVM virtual machine running Fedora Workstation 43 with a GNOME desktop. Everything the agent does later will happen inside this VM.
In this step, get ready to:
- Install QEMU/KVM and virt-manager on your Fedora host.
- Download the Fedora Workstation 43 ISO and create the VM.
- Boot into the VM, open a terminal, and verify network connectivity.
Install QEMU/KVM and virt-manager on your Fedora host
Your Fedora host already has hardware virtualization enabled in the BIOS. Now you need the software layer that manages virtual machines.
- Open a terminal on your Fedora host by pressing the Super key, typing Terminal, and pressing Enter.
- Install the full virtualization group by running this command:
sudo dnf install @virtualization
- When prompted to confirm the installation, enter y and press Enter.
- Install virt-manager (the graphical VM management tool) by running:
sudo dnf install virt-manager
- Enter y again when prompted to confirm.
- Enable and start the libvirtd service by running:
sudo systemctl enable --now libvirtd
- Add your user to the libvirt group so you can manage VMs without sudo every time:
sudo usermod -aG libvirt $(whoami)
What do these commands do?
The @virtualization group installs QEMU, KVM kernel modules, libvirt, and supporting tools in one bundle.
The libvirtd service is the daemon that manages your VMs behind the scenes. Enabling it means the service starts automatically on every boot.
Adding yourself to the libvirt group grants your user account permission to create and manage VMs through libvirt without root privileges.
- Log out and log back in (or reboot) for the group change to take effect.
- After logging back in, open a new terminal and verify the service is running:
systemctl status libvirtd
You should see active (running) in green text near the top of the output.
Service not showing as active?
If libvirtd shows as inactive or failed, try restarting it manually with sudo systemctl restart libvirtd. Check the logs with journalctl -xeu libvirtd for specific error messages.
Help me troubleshoot why libvirtd won't start on my Fedora host.
Download the Fedora Workstation 43 ISO and create the VM
You need an installation image to install Fedora inside your VM. The ISO file is approximately 2.6 GB, so the download may take a few minutes depending on your connection.
- Download the Fedora Workstation 43 ISO by running:
wget -P ~/Downloads https://download.fedoraproject.org/pub/fedora/linux/releases/43/Workstation/x86_64/iso/Fedora-Workstation-Live-43-1.6.x86_64.iso
What if the download is slow?
You can also download the ISO through your browser from https://fedoraproject.org/workstation/download. Click the download button for the x86_64 Live ISO and save it to your ~/Downloads folder.
- Verify the download completed by running:
ls -lh ~/Downloads/Fedora-Workstation-Live-43-1.6.x86_64.iso
You should see a file around 2.6 GB in size.
- Create the VM using virt-install with 8GB RAM, 4 vCPUs, and a 40GB disk by running:
virt-install \
--name self-extending-agent \
--memory 8192 \
--vcpus 4 \
--disk size=40 \
--os-variant fedora43 \
--cdrom ~/Downloads/Fedora-Workstation-Live-43-1.6.x86_64.iso \
--network network=default \
--graphics spice
What do these flags mean?
- --name gives your VM a unique identifier for management commands.
- --memory 8192 allocates 8 GB of RAM (in MiB) from your host's 16 GB.
- --vcpus 4 assigns 4 virtual CPUs to the guest.
- --disk size=40 creates a 40 GB virtual disk (sparse by default, so it only uses actual space as needed).
- --os-variant fedora43 tells libvirt to apply Fedora 43 optimizations like virtio drivers.
- --cdrom mounts the ISO as a virtual DVD drive for installation.
- --graphics spice opens a graphical window so you can interact with the GNOME installer.
If fedora43 is not recognized
If you get an error about an unknown OS variant, your osinfo database may not include Fedora 43 yet. Run osinfo-query os | grep fedora to see available variants. Use the highest numbered Fedora variant listed, or use fedora-rawhide as a fallback.
A graphical window (virt-viewer) opens showing the Fedora installer.
- Select Install Fedora (not the live session option).
- The Anaconda WebUI installer loads inside the VM window.
- Choose your language, then click Next.
- Select the 40 GB virtual disk as the installation destination.
- Create a user account with a password you will remember.
- Click Begin Installation and wait for the process to complete (this takes several minutes).
- When installation finishes, click Reboot.
Why does the installer look like a web page?
Fedora 43 uses the Anaconda WebUI as its default installer interface. It runs in a browser-style window inside the VM. The workflow is straightforward: choose language, select disk, create user, and install.
VM window shows a black screen after reboot?
The ISO may still be mounted as the boot device. Close the virt-viewer window, then start the VM again with virsh start self-extending-agent and open the viewer with virt-viewer self-extending-agent. If it boots back into the installer, shut down the VM with virsh destroy self-extending-agent, then eject the ISO and start again.
Help me fix my VM that keeps booting to the installer instead of the installed OS.
Verify network connectivity and system readiness
Your VM should now boot into the Fedora GNOME desktop. You need to confirm that the VM can reach the internet, since later steps will download Python packages from PyPI.
- After the GNOME desktop loads, press the Super key, type Terminal, and press Enter to open a terminal inside the VM.
- Verify you are running Fedora 43 by running:
cat /etc/fedora-release
You should see: Fedora release 43 (Forty Three).
- Verify network connectivity by running:
ping -c 3 google.com
You should see three successful replies with response times. This confirms your VM can reach the internet through the default NAT network.
- Update the system packages to make sure everything is current:
sudo dnf upgrade --refresh -y
Why update now?
Running a system upgrade ensures you have the latest security patches and that Python and its development headers are up to date. This prevents version mismatch issues when you install the Google GenAI SDK in the next step.
The upgrade may take a few minutes. Once it completes, your VM is ready for development.
Ping shows network is unreachable?
The libvirt default network may not have started automatically. On your host (not inside the VM), run sudo virsh net-start default to bring up the NAT network. Then restart the VM with virsh reboot self-extending-agent.
Help me fix networking in my Fedora VM.
Your sandboxed environment is ready. Next up, you will configure Python and connect the VM to Google AI Studio so your agent has a brain to think with.
Configure the Python Development Environment
Your Fedora VM is up and running with a GNOME desktop. Now you need a Python development environment inside it to build your AI agent system.
This step gets your project directory, virtual environment, and API access ready. By the end, you will have confirmed that your VM can talk to Google's AI models.
In this step, get ready to:
- Install Python development tools, create a project directory, and set up a virtual environment with the google-genai SDK.
- Obtain a Google AI Studio API key, store it securely in a .env file, and verify connectivity by calling the Gemma 4 model.
Install Python tools and create the project environment
Fedora 43 ships with Python 3.14 pre-installed as the system interpreter. You still need the development headers and pip to manage dependencies inside a virtual environment.
- In your VM's terminal, install the Python development tools by running this command:
sudo dnf install python3-pip python3-devel
What does this command do?
- python3-pip installs the pip package manager so you can install Python libraries.
- python3-devel installs development headers needed to compile packages that have C extensions.
- When prompted to confirm the installation, type y and press Enter.
- Verify Python is available by running:
python3 --version
You should see a version number starting with 3.14 in the output.
- Create your project directory and move into it by running these commands:
mkdir ~/self-extending-agent
cd ~/self-extending-agent
- Create a Python virtual environment by running:
python3 -m venv venv
- Activate the virtual environment by running:
source venv/bin/activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms you are working inside the isolated environment.
Why use a virtual environment?
Fedora marks its system Python as externally managed. Installing packages globally with pip will fail or cause conflicts with system tools.
A virtual environment gives you an isolated space to install project-specific packages without touching the system Python.
- Create the requirements.txt file that lists your project dependencies by running:
cat > requirements.txt << 'EOF'
google-genai==2.10.0
python-dotenv==1.1.0
EOF
What are these packages?
- google-genai is the official Google GenAI SDK for calling Gemma and Gemini models through the API.
- python-dotenv loads environment variables from a .env file so your API key stays out of your code.
- Install the dependencies by running:
pip install -r requirements.txt
You should see pip downloading and installing both packages along with their dependencies.
Seeing an error during installation?
- Make sure your virtual environment is active. You should see (venv) in your prompt. If not, run source venv/bin/activate again.
- If you see network errors, check that your VM has internet connectivity with ping google.com.
help me troubleshoot pip install errors in my Fedora VM
Get your API key and verify connectivity
The agent system uses two Google AI models: Gemma 4 for orchestration and Gemini 2.5 Flash for code generation. Both are accessed through the same Google AI Studio API key.
- Open Firefox inside your VM by pressing the Super key, typing Firefox, and pressing Enter.
- Navigate to aistudio.google.com.
- Sign in with your Google account.
- For new users, Google AI Studio automatically creates a default project and API key after you accept the Terms of Service.
- Click Get API Key in the left navigation panel.
- Click Create API key if you need a new key.
- Copy the generated API key. You will only see it once, so keep this tab open until you have saved it.
Is this free?
Both gemma-4-26b-a4b-it and gemini-2.5-flash are listed as free of charge on the Google AI Studio free tier. On the free tier, your content may be used to improve Google products.
- Switch back to your terminal. Make sure you are in the ~/self-extending-agent directory with your virtual environment active.
- Create the .env file to store your API key by running:
cat > .env << 'EOF'
GEMINI_API_KEY=your-api-key-here
EOF
- Open the .env file in a text editor:
nano .env
- Replace your-api-key-here with the actual API key you copied from Google AI Studio.
- Save the file by pressing Ctrl+O, then Enter, then exit with Ctrl+X.
Keep your key out of code
The .env file keeps your API key separate from your Python code. Never paste an API key directly into a script file.
- Write a quick test script to verify API connectivity by running:
cat > test_api.py << 'EOF'
import os
from dotenv import load_dotenv
from google import genai
# Load the API key from the .env file
load_dotenv()
# Initialize the GenAI client
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
# Send a test request to the Gemma 4 model
response = client.models.generate_content(
model="gemma-4-26b-a4b-it",
contents="Say hello and confirm you are working."
)
# Print the response
print(response.text)
EOF
What does this script do?
- load_dotenv() reads the GEMINI_API_KEY from your .env file into the environment.
- genai.Client(api_key=...) creates a client that authenticates with Google's API.
- generate_content() sends a prompt to the gemma-4-26b-a4b-it model and returns its response.
- Run the test script:
python test_api.py
You should see a friendly text response from the Gemma 4 model printed in your terminal. This confirms your API key and SDK are working correctly.
Getting an authentication or connection error?
- Double-check that your .env file contains the correct key with no extra spaces or quotes around the value.
- Make sure the line reads GEMINI_API_KEY= followed immediately by your key (no spaces around the =).
- Confirm your VM has internet access by running ping google.com.
help me debug my Google AI Studio API connection error
✔️ Awesome, I've got everything!
Great. Double check you have saved both requirements.txt and .env in your ~/self-extending-agent directory.
ⓧ I'd like to double check the full code
Here are the two project files you should have at this point.
requirements.txt:
google-genai==2.10.0
python-dotenv==1.1.0
.env:
GEMINI_API_KEY=your-api-key-here
Your development environment is ready and confirmed working. Next up, you will build the orchestrator agent that manages conversations and dispatches tasks to tools.
Build the Orchestrator Agent
Your VM has a working Python environment and confirmed API access to Gemma 4. Now it is time to build the brain of the system.
An orchestrator agent manages conversation, interprets user intent, and dispatches tasks to the right tools. In this step you will build a tool registry that holds reusable Python functions, and a main conversation loop that decides when to call those tools based on the model's responses.
In this step, get ready to:
- Build a tool registry that registers, lists, and executes Python functions.
- Create the orchestrator's conversation loop with response parsing.
- Test the agent by asking it to use the built-in tools.
Build the tool registry
The tool registry is the central catalog that holds every tool the agent can use. Each tool is a Python function with a name, description, and callable. The registry also handles persistence so tools survive between sessions.
- Create a new file called tool_registry.py in your ~/self-extending-agent/ directory by running:
touch tool_registry.py
- Open tool_registry.py in your editor and add the imports, class skeleton, and core methods by pasting this code:
import os
import importlib.util
import json
from datetime import datetime
TOOLS_DIR = "tools"
MANIFEST_PATH = os.path.join(TOOLS_DIR, "manifest.json")
class ToolRegistry:
def __init__(self):
self.tools = {}
os.makedirs(TOOLS_DIR, exist_ok=True)
def register(self, name, description, func):
self.tools[name] = {"description": description, "func": func}
def list_tools_description(self):
lines = []
for name, info in self.tools.items():
lines.append(f"- {name}: {info['description']}")
return "\n".join(lines) if lines else "No tools available."
def execute(self, name, args_str):
if name not in self.tools:
return f"Error: Tool '{name}' not found."
try:
func = self.tools[name]["func"]
if args_str:
result = eval(f"func({args_str})")
else:
result = func()
return str(result)
except Exception as e:
return f"Error executing tool '{name}': {e}"
What does this code do?
TOOLS_DIR defines where generated tools will be saved as .py files. The __init__ method creates that directory if it does not already exist.
register() stores a tool by name with its description and callable function. list_tools_description() formats all registered tools into a string that gets injected into the system prompt. execute() looks up a tool by name and calls it with optional arguments.
- Below the execute method, add the load_builtin_tools method that registers two starter tools:
def load_builtin_tools(self):
def datetime_now():
"""Returns the current date and time."""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def list_files(directory="."):
"""Lists files in a directory."""
return os.listdir(directory)
self.register("datetime_now", "Returns the current date and time", datetime_now)
self.register("list_files", "Lists files in a given directory", list_files)
What does this code do?
load_builtin_tools() defines two simple Python functions and registers them immediately. datetime_now returns the current timestamp. list_files returns the contents of a given directory.
These are the agent's first capabilities. You will test them at the end of this step.
- Below load_builtin_tools, add the persistence methods that load saved tools and track them in a manifest:
def load_saved_tools(self):
if not os.path.exists(TOOLS_DIR):
return
for filename in os.listdir(TOOLS_DIR):
if filename.endswith(".py"):
filepath = os.path.join(TOOLS_DIR, filename)
self._load_tool_file(filepath)
def _load_tool_file(self, filepath):
module_name = os.path.basename(filepath)[:-3]
try:
spec = importlib.util.spec_from_file_location(module_name, filepath)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if hasattr(module, "tool_name") and hasattr(module, "tool_description") and hasattr(module, "tool_func"):
self.register(module.tool_name, module.tool_description, module.tool_func)
except Exception as e:
print(f"Warning: Failed to load tool from {filepath}: {e}")
def save_manifest_entry(self, name, description, filename):
manifest = {}
if os.path.exists(MANIFEST_PATH):
with open(MANIFEST_PATH, "r") as f:
manifest = json.load(f)
manifest[name] = {
"description": description,
"filename": filename,
"created": datetime.now().isoformat()
}
with open(MANIFEST_PATH, "w") as f:
json.dump(manifest, f, indent=2)
What does this code do?
load_saved_tools() scans the tools/ directory on startup and dynamically imports every .py file it finds. This is how tools survive between sessions.
_load_tool_file() uses Python's importlib to import a file as a module and checks for the required exports: tool_name, tool_description, and tool_func. save_manifest_entry() records each generated tool in a JSON manifest for auditability.
- Save tool_registry.py.
- Confirm the file exists by running:
ls tool_registry.py
You should see tool_registry.py listed in the output.
File not showing up?
Make sure you are in the ~/self-extending-agent/ directory. Run pwd to confirm your location.
help me find my tool_registry.py file
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
import os
import importlib.util
import json
from datetime import datetime
TOOLS_DIR = "tools"
MANIFEST_PATH = os.path.join(TOOLS_DIR, "manifest.json")
class ToolRegistry:
def __init__(self):
self.tools = {}
os.makedirs(TOOLS_DIR, exist_ok=True)
def register(self, name, description, func):
self.tools[name] = {"description": description, "func": func}
def list_tools_description(self):
lines = []
for name, info in self.tools.items():
lines.append(f"- {name}: {info['description']}")
return "\n".join(lines) if lines else "No tools available."
def execute(self, name, args_str):
if name not in self.tools:
return f"Error: Tool '{name}' not found."
try:
func = self.tools[name]["func"]
if args_str:
result = eval(f"func({args_str})")
else:
result = func()
return str(result)
except Exception as e:
return f"Error executing tool '{name}': {e}"
def load_builtin_tools(self):
def datetime_now():
"""Returns the current date and time."""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def list_files(directory="."):
"""Lists files in a directory."""
return os.listdir(directory)
self.register("datetime_now", "Returns the current date and time", datetime_now)
self.register("list_files", "Lists files in a given directory", list_files)
def load_saved_tools(self):
if not os.path.exists(TOOLS_DIR):
return
for filename in os.listdir(TOOLS_DIR):
if filename.endswith(".py"):
filepath = os.path.join(TOOLS_DIR, filename)
self._load_tool_file(filepath)
def _load_tool_file(self, filepath):
module_name = os.path.basename(filepath)[:-3]
try:
spec = importlib.util.spec_from_file_location(module_name, filepath)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if hasattr(module, "tool_name") and hasattr(module, "tool_description") and hasattr(module, "tool_func"):
self.register(module.tool_name, module.tool_description, module.tool_func)
except Exception as e:
print(f"Warning: Failed to load tool from {filepath}: {e}")
def save_manifest_entry(self, name, description, filename):
manifest = {}
if os.path.exists(MANIFEST_PATH):
with open(MANIFEST_PATH, "r") as f:
manifest = json.load(f)
manifest[name] = {
"description": description,
"filename": filename,
"created": datetime.now().isoformat()
}
with open(MANIFEST_PATH, "w") as f:
json.dump(manifest, f, indent=2)
Create the orchestrator module
The orchestrator is the main entry point. It initializes the GenAI client, manages a conversation loop, and parses the model's responses to detect tool calls.
- Create main.py by running:
touch main.py
- Open main.py and add the imports, client initialization, and the load_config() function:
import os
import json
from dotenv import load_dotenv
from google import genai
from tool_registry import ToolRegistry
from tool_coder import ToolCoder
load_dotenv()
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
CONFIG_PATH = "config.json"
def load_config():
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r") as f:
return json.load(f)
default_config = {
"system_prompt": (
"You are a self-extending AI agent. You have access to tools and can create new ones. "
"When a user asks for something you cannot do, suggest creating a new tool. "
"Available tools: {tool_list}\n"
"To use a tool, respond with: [TOOL: tool_name(arguments)]\n"
"To create a new tool, respond with: [CREATE_TOOL: description of the tool needed]"
),
"verbosity": "normal",
"auto_extend": True,
"interaction_count": 0
}
with open(CONFIG_PATH, "w") as f:
json.dump(default_config, f, indent=2)
return default_config
What does this code do?
The imports bring in the google-genai SDK, our ToolRegistry, and a ToolCoder class (which you will build in the next step). load_dotenv() reads your API key from .env.
load_config() loads the agent's configuration from config.json if it exists, or creates one with sensible defaults. The system prompt contains a {tool_list} placeholder that gets filled at runtime with the registered tools.
- Below load_config(), add the two helper functions that build the system prompt and parse the agent's responses:
def build_system_prompt(config, registry):
tool_list = registry.list_tools_description()
return config["system_prompt"].replace("{tool_list}", tool_list)
def parse_agent_response(response_text):
if "[TOOL:" in response_text:
start = response_text.index("[TOOL:") + 6
end = response_text.index("]", start)
tool_call = response_text[start:end].strip()
paren_idx = tool_call.index("(")
tool_name = tool_call[:paren_idx].strip()
args_str = tool_call[paren_idx + 1:-1].strip()
return "tool_call", tool_name, args_str
elif "[CREATE_TOOL:" in response_text:
start = response_text.index("[CREATE_TOOL:") + 13
end = response_text.index("]", start)
description = response_text[start:end].strip()
return "create_tool", description, None
return "message", response_text, None
What does this code do?
build_system_prompt() injects the current tool descriptions into the system prompt so the model knows what tools are available.
parse_agent_response() scans the model's text output for the special patterns [TOOL: ...] and [CREATE_TOOL: ...]. If it finds one, it extracts the tool name and arguments. Otherwise it returns the raw message text.
- Below parse_agent_response, add the main() function that runs the conversation loop:
def main():
config = load_config()
registry = ToolRegistry()
registry.load_builtin_tools()
registry.load_saved_tools()
coder = ToolCoder(client)
conversation_history = []
print("Self-Extending Agent ready. Type 'quit' to exit.")
print(f"Loaded {len(registry.tools)} tools: {', '.join(registry.tools.keys())}")
print()
while True:
user_input = input("You: ").strip()
if user_input.lower() == "quit":
break
config["interaction_count"] = config.get("interaction_count", 0) + 1
system_prompt = build_system_prompt(config, registry)
conversation_history.append({"role": "user", "parts": [{"text": user_input}]})
response = client.models.generate_content(
model="gemma-4-26b-a4b-it",
contents=conversation_history,
config={"system_instruction": system_prompt}
)
agent_text = response.text
action, value, extra = parse_agent_response(agent_text)
if action == "tool_call":
result = registry.execute(value, extra)
print(f"Agent: [Used tool '{value}'] {result}")
conversation_history.append(
{"role": "model", "parts": [{"text": f"Tool {value} returned: {result}"}]}
)
elif action == "create_tool":
print(f"Agent: Creating new tool: {value}")
success, tool_name = coder.create_tool(value, registry)
if success:
print(f"Agent: Successfully created and registered tool '{tool_name}'.")
conversation_history.append(
{"role": "model", "parts": [{"text": f"Created new tool: {tool_name}"}]}
)
else:
print(f"Agent: Failed to create tool. Error: {tool_name}")
conversation_history.append(
{"role": "model", "parts": [{"text": f"Failed to create tool: {tool_name}"}]}
)
else:
print(f"Agent: {agent_text}")
conversation_history.append(
{"role": "model", "parts": [{"text": agent_text}]}
)
if config.get("auto_extend") and config["interaction_count"] % 5 == 0:
self_reflect(client, config, conversation_history)
with open(CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2)
What does this code do?
main() loads the config, initializes the registry with built-in and saved tools, then enters a loop. Each iteration sends the conversation history to gemma-4-26b-a4b-it with the system prompt injected via the config parameter.
The response is parsed. If the model outputs [TOOL: name(args)], the registry executes that tool. If it outputs [CREATE_TOOL: description], the tool-coder agent creates a new tool. Otherwise, the response is printed directly.
Every 5 interactions, the agent triggers a self-reflection cycle to potentially improve its own system prompt. You will explore this in depth in a later step.
- Below main(), add the self_reflect() function and the entry point:
def self_reflect(client, config, history):
reflection_prompt = (
"Review the recent conversation history. Based on patterns you observe, "
"suggest a brief improvement to your system prompt to be more helpful. "
"Respond with ONLY the new system prompt text, nothing else. "
"Keep the {tool_list} placeholder and the [TOOL:] and [CREATE_TOOL:] format instructions."
)
recent_history = history[-10:] if len(history) > 10 else history
recent_history_copy = recent_history + [{"role": "user", "parts": [{"text": reflection_prompt}]}]
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=recent_history_copy
)
new_prompt = response.text.strip()
if "{tool_list}" in new_prompt and "[TOOL:" in new_prompt:
config["system_prompt"] = new_prompt
with open(CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2)
print("\n[System: Agent has updated its own system prompt via self-reflection]\n")
if __name__ == "__main__":
main()
What does this code do?
self_reflect() uses gemini-2.5-flash (a reasoning-capable model) to analyze the last 10 messages and suggest an improved system prompt. If the new prompt still contains the required placeholders, it overwrites the current config. This is the self-modification hook.
The if __name__ == "__main__" block ensures main() runs when you execute the script directly.
- Save main.py.
Seeing a syntax error when you save?
Check that all the methods inside main() are indented consistently with 4 spaces. Mixing tabs and spaces is a common source of Python errors.
help me fix indentation errors in my main.py
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
import os
import json
from dotenv import load_dotenv
from google import genai
from tool_registry import ToolRegistry
from tool_coder import ToolCoder
load_dotenv()
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
CONFIG_PATH = "config.json"
def load_config():
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r") as f:
return json.load(f)
default_config = {
"system_prompt": (
"You are a self-extending AI agent. You have access to tools and can create new ones. "
"When a user asks for something you cannot do, suggest creating a new tool. "
"Available tools: {tool_list}\n"
"To use a tool, respond with: [TOOL: tool_name(arguments)]\n"
"To create a new tool, respond with: [CREATE_TOOL: description of the tool needed]"
),
"verbosity": "normal",
"auto_extend": True,
"interaction_count": 0
}
with open(CONFIG_PATH, "w") as f:
json.dump(default_config, f, indent=2)
return default_config
def build_system_prompt(config, registry):
tool_list = registry.list_tools_description()
return config["system_prompt"].replace("{tool_list}", tool_list)
def parse_agent_response(response_text):
if "[TOOL:" in response_text:
start = response_text.index("[TOOL:") + 6
end = response_text.index("]", start)
tool_call = response_text[start:end].strip()
paren_idx = tool_call.index("(")
tool_name = tool_call[:paren_idx].strip()
args_str = tool_call[paren_idx + 1:-1].strip()
return "tool_call", tool_name, args_str
elif "[CREATE_TOOL:" in response_text:
start = response_text.index("[CREATE_TOOL:") + 13
end = response_text.index("]", start)
description = response_text[start:end].strip()
return "create_tool", description, None
return "message", response_text, None
def main():
config = load_config()
registry = ToolRegistry()
registry.load_builtin_tools()
registry.load_saved_tools()
coder = ToolCoder(client)
conversation_history = []
print("Self-Extending Agent ready. Type 'quit' to exit.")
print(f"Loaded {len(registry.tools)} tools: {', '.join(registry.tools.keys())}")
print()
while True:
user_input = input("You: ").strip()
if user_input.lower() == "quit":
break
config["interaction_count"] = config.get("interaction_count", 0) + 1
system_prompt = build_system_prompt(config, registry)
conversation_history.append({"role": "user", "parts": [{"text": user_input}]})
response = client.models.generate_content(
model="gemma-4-26b-a4b-it",
contents=conversation_history,
config={"system_instruction": system_prompt}
)
agent_text = response.text
action, value, extra = parse_agent_response(agent_text)
if action == "tool_call":
result = registry.execute(value, extra)
print(f"Agent: [Used tool '{value}'] {result}")
conversation_history.append(
{"role": "model", "parts": [{"text": f"Tool {value} returned: {result}"}]}
)
elif action == "create_tool":
print(f"Agent: Creating new tool: {value}")
success, tool_name = coder.create_tool(value, registry)
if success:
print(f"Agent: Successfully created and registered tool '{tool_name}'.")
conversation_history.append(
{"role": "model", "parts": [{"text": f"Created new tool: {tool_name}"}]}
)
else:
print(f"Agent: Failed to create tool. Error: {tool_name}")
conversation_history.append(
{"role": "model", "parts": [{"text": f"Failed to create tool: {tool_name}"}]}
)
else:
print(f"Agent: {agent_text}")
conversation_history.append(
{"role": "model", "parts": [{"text": agent_text}]}
)
if config.get("auto_extend") and config["interaction_count"] % 5 == 0:
self_reflect(client, config, conversation_history)
with open(CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2)
def self_reflect(client, config, history):
reflection_prompt = (
"Review the recent conversation history. Based on patterns you observe, "
"suggest a brief improvement to your system prompt to be more helpful. "
"Respond with ONLY the new system prompt text, nothing else. "
"Keep the {tool_list} placeholder and the [TOOL:] and [CREATE_TOOL:] format instructions."
)
recent_history = history[-10:] if len(history) > 10 else history
recent_history_copy = recent_history + [{"role": "user", "parts": [{"text": reflection_prompt}]}]
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=recent_history_copy
)
new_prompt = response.text.strip()
if "{tool_list}" in new_prompt and "[TOOL:" in new_prompt:
config["system_prompt"] = new_prompt
with open(CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2)
print("\n[System: Agent has updated its own system prompt via self-reflection]\n")
if __name__ == "__main__":
main()
Test the agent with tool dispatch
main.py imports ToolCoder from a module you have not created yet. You need a placeholder so the agent can run and respond to tool calls right now.
- Create a minimal placeholder tool_coder.py so the import succeeds by running:
cat > tool_coder.py << 'EOF'
class ToolCoder:
def __init__(self, client):
self.client = client
def create_tool(self, description, registry):
return False, "Tool coder not yet implemented"
EOF
Why a placeholder?
Python needs every imported module to exist before the script runs. This stub satisfies the import while you focus on testing tool dispatch. You will replace it with the full implementation in the next step.
- Start the agent by running:
python main.py
You should see output similar to:
Self-Extending Agent ready. Type 'quit' to exit.
Loaded 2 tools: datetime_now, list_files
- Type what time is it? and press Enter.
The agent should respond with the current timestamp from the datetime_now tool.
- Type list files in the current directory and press Enter.
The agent should respond with the directory contents from the list_files tool.
- Type quit to exit the agent.
The model is non-deterministic
The agent may not always format its response with the exact [TOOL: ...] pattern on the first try. If it responds with plain text instead of calling the tool, try rephrasing your request more directly (e.g., "use the datetime_now tool").
Seeing an import error or API error?
If you see ModuleNotFoundError: No module named 'tool_coder', make sure you ran the cat > tool_coder.py command above in the same directory as main.py.
If you see a 400 or 403 error from the API, double-check that your .env file contains a valid GEMINI_API_KEY and that your virtual environment is activated.
help me debug my self-extending agent startup errors
- Confirm that config.json was created by running:
cat config.json
You should see the default configuration including the system prompt, verbosity set to normal, and interaction count incremented from your test messages.
Your orchestrator is working. It can hold a conversation, detect when a tool is needed, and dispatch to the right function from its registry. Next up, you will build the tool-coder sub-agent so the system can create brand new tools on demand.
Build the Tool-Coder Sub-Agent
Your orchestrator can use tools, but it only knows the two you hardcoded. Every time the user needs something new, you would have to stop the agent, write a tool by hand, register it, and restart.
In this step, you will build a tool-coder sub-agent powered by Gemini 2.5 Flash that writes new Python tools on demand. When the orchestrator encounters a request it cannot handle, it dispatches to the tool-coder, which generates valid Python code, validates it, saves it to disk, and loads it into the registry.
In this step, get ready to:
- Create a tool-coder module with a specialized system prompt that enforces a strict Python tool template.
- Implement a generation pipeline that validates AI-generated code with ast.parse() before saving it.
- Test the full loop by asking the agent to create a factorial tool and immediately use it.
Create the tool-coder module with a specialized system prompt
The tool-coder's intelligence comes entirely from its system prompt. The prompt tells Gemini 2.5 Flash exactly what structure a valid tool file must follow. Every generated file must define three things: tool_name, tool_description, and tool_func.
- Create a new file called tool_coder.py in your ~/self-extending-agent/ directory by running:
touch tool_coder.py
- Open tool_coder.py in your text editor and add the imports, system prompt, and class skeleton:
import os
import ast
from tool_registry import TOOLS_DIR
CODER_SYSTEM_PROMPT = """You are a Python tool generator. When given a description of a tool, generate a Python file that follows this exact template:
```python
tool_name = "name_of_tool"
tool_description = "Brief description of what the tool does"
def tool_func(parameter1, parameter2="default"):
\"\"\"Detailed docstring.\"\"\"
# Implementation here
return result
```
Rules:
- The file MUST define tool_name (str), tool_description (str), and tool_func (callable)
- tool_func must have a docstring
- tool_func must return a value (never None without reason)
- Use only standard library imports unless specified otherwise
- Handle errors gracefully with try/except
- Output ONLY the Python code, no markdown fences, no explanation
"""
class ToolCoder:
def __init__(self, client):
self.client = client
What does this code do?
The CODER_SYSTEM_PROMPT is the entire instruction set for the code-generating model. It shows Gemini 2.5 Flash exactly what a valid tool file looks like, including the required variables and function signature.
The TOOLS_DIR import pulls the shared constant from tool_registry.py so both modules agree on where tool files live. The ToolCoder class stores the GenAI client so every method can call the API.
Why use Gemini 2.5 Flash for code generation?
The orchestrator uses Gemma 4 for conversation because it has higher free-tier rate limits. The tool-coder uses gemini-2.5-flash because it is Google's best price-performance reasoning model, ideal for code generation tasks.
The tool-coder makes far fewer calls than the orchestrator (only when creating a new tool), so the lower rate limit is acceptable.
Implement the tool generation pipeline
The pipeline has four stages: generate code from the AI, clean off any markdown formatting, validate the code structure with Python's AST parser, then write and load the tool.
- In tool_coder.py, add the create_tool method below the __init__ method:
def create_tool(self, description, registry):
# Ask Gemini 2.5 Flash to generate tool code
response = self.client.models.generate_content(
model="gemini-2.5-flash",
contents=[{"role": "user", "parts": [{"text": f"Create a Python tool that: {description}"}]}],
config={"system_instruction": CODER_SYSTEM_PROMPT}
)
code = response.text.strip()
code = self._clean_code(code)
# Validate the generated code structure
valid, error = self._validate_code(code)
if not valid:
return False, f"Generated code failed validation: {error}"
# Extract the tool name from the code
tool_name = self._extract_tool_name(code)
if not tool_name:
return False, "Could not extract tool_name from generated code"
# Save the tool file to disk
filename = f"{tool_name}.py"
filepath = os.path.join(TOOLS_DIR, filename)
with open(filepath, "w") as f:
f.write(code)
# Dynamically load the tool into the registry
registry._load_tool_file(filepath)
if tool_name in registry.tools:
registry.save_manifest_entry(
tool_name,
registry.tools[tool_name]["description"],
filename
)
return True, tool_name
return False, "Tool was saved but failed to load into registry"
What does this method do?
- It sends the user's tool description to gemini-2.5-flash with the specialized system prompt, getting back raw Python code.
- It cleans any markdown fences the model might wrap around its output, then validates the code has the required structure.
- If everything passes, it writes the code to a .py file inside tools/, calls _load_tool_file() to dynamically import it, and records the tool in the manifest.
- Now add the three helper methods below create_tool. These handle code cleanup, validation, and name extraction:
def _clean_code(self, code):
# Strip markdown code fences that LLMs often add
if code.startswith("```python"):
code = code[9:]
elif code.startswith("```"):
code = code[3:]
if code.endswith("```"):
code = code[:-3]
return code.strip()
def _validate_code(self, code):
# Use Python's AST parser to verify code structure
try:
tree = ast.parse(code)
has_tool_name = False
has_tool_description = False
has_tool_func = False
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name):
if target.id == "tool_name":
has_tool_name = True
elif target.id == "tool_description":
has_tool_description = True
elif isinstance(node, ast.FunctionDef) and node.name == "tool_func":
has_tool_func = True
if not has_tool_name:
return False, "Missing tool_name assignment"
if not has_tool_description:
return False, "Missing tool_description assignment"
if not has_tool_func:
return False, "Missing tool_func function definition"
return True, None
except SyntaxError as e:
return False, f"Syntax error: {e}"
def _extract_tool_name(self, code):
# Pull the tool_name value from the AST
try:
tree = ast.parse(code)
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "tool_name":
if isinstance(node.value, ast.Constant):
return node.value.value
return None
except Exception:
return None
What do these helpers do?
- _clean_code() strips markdown code fences. LLMs frequently wrap output in ```python ... ``` even when told not to.
- _validate_code() parses the code into an abstract syntax tree and walks it, checking that tool_name, tool_description, and tool_func all exist. This catches malformed output before it reaches your filesystem.
- _extract_tool_name() reads the string value assigned to tool_name so the file can be named correctly (e.g., factorial.py).
- Save tool_coder.py.
- Confirm the file exists by running:
ls tool_coder.py
- You should see tool_coder.py listed in the output.
Seeing a syntax error when you save?
- Check that the triple-quoted string for CODER_SYSTEM_PROMPT opens and closes with exactly three double quotes (""").
- Make sure the escaped quotes inside the prompt (\"\"\") each have a backslash. Without the backslashes, they close the string early.
- Verify that every method is indented with exactly 4 spaces inside the ToolCoder class.
Help me debug a syntax error in my tool_coder.py file
✔️ Awesome, I've got everything!
Great. Double check you have saved tool_coder.py.
ⓧ I'd like to double check the full code
import os
import ast
from tool_registry import TOOLS_DIR
CODER_SYSTEM_PROMPT = """You are a Python tool generator. When given a description of a tool, generate a Python file that follows this exact template:
```python
tool_name = "name_of_tool"
tool_description = "Brief description of what the tool does"
def tool_func(parameter1, parameter2="default"):
\"\"\"Detailed docstring.\"\"\"
# Implementation here
return result
```
Rules:
- The file MUST define tool_name (str), tool_description (str), and tool_func (callable)
- tool_func must have a docstring
- tool_func must return a value (never None without reason)
- Use only standard library imports unless specified otherwise
- Handle errors gracefully with try/except
- Output ONLY the Python code, no markdown fences, no explanation
"""
class ToolCoder:
def __init__(self, client):
self.client = client
def create_tool(self, description, registry):
response = self.client.models.generate_content(
model="gemini-2.5-flash",
contents=[{"role": "user", "parts": [{"text": f"Create a Python tool that: {description}"}]}],
config={"system_instruction": CODER_SYSTEM_PROMPT}
)
code = response.text.strip()
code = self._clean_code(code)
valid, error = self._validate_code(code)
if not valid:
return False, f"Generated code failed validation: {error}"
tool_name = self._extract_tool_name(code)
if not tool_name:
return False, "Could not extract tool_name from generated code"
filename = f"{tool_name}.py"
filepath = os.path.join(TOOLS_DIR, filename)
with open(filepath, "w") as f:
f.write(code)
registry._load_tool_file(filepath)
if tool_name in registry.tools:
registry.save_manifest_entry(
tool_name,
registry.tools[tool_name]["description"],
filename
)
return True, tool_name
return False, "Tool was saved but failed to load into registry"
def _clean_code(self, code):
if code.startswith("```python"):
code = code[9:]
elif code.startswith("```"):
code = code[3:]
if code.endswith("```"):
code = code[:-3]
return code.strip()
def _validate_code(self, code):
try:
tree = ast.parse(code)
has_tool_name = False
has_tool_description = False
has_tool_func = False
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name):
if target.id == "tool_name":
has_tool_name = True
elif target.id == "tool_description":
has_tool_description = True
elif isinstance(node, ast.FunctionDef) and node.name == "tool_func":
has_tool_func = True
if not has_tool_name:
return False, "Missing tool_name assignment"
if not has_tool_description:
return False, "Missing tool_description assignment"
if not has_tool_func:
return False, "Missing tool_func function definition"
return True, None
except SyntaxError as e:
return False, f"Syntax error: {e}"
def _extract_tool_name(self, code):
try:
tree = ast.parse(code)
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "tool_name":
if isinstance(node.value, ast.Constant):
return node.value.value
return None
except Exception:
return None
Test tool creation with the orchestrator
Your main.py already handles the [CREATE_TOOL:] dispatch from the previous step. When the orchestrator detects a tool-creation request, it calls coder.create_tool() automatically. Now you can test the entire pipeline end-to-end.
- Start the agent by running:
python main.py
- You should see the startup message: Self-Extending Agent ready. Type 'quit' to exit.
- Type this message to request a new tool:
I need a tool that calculates the factorial of a number
- The agent should respond with something like Agent: Creating new tool: calculates the factorial of a number followed by a success confirmation.
- Now test the newly created tool by typing:
what is the factorial of 10?
- You should see the agent use the tool and return 3628800.
- Type quit to exit the agent.
- Confirm the tool file was created by running:
ls tools/
- You should see factorial.py and manifest.json in the output.
What just happened?
The orchestrator recognized it did not have a factorial tool. It asked Gemini 2.5 Flash to generate one. The tool-coder wrote valid Python, validated it with ast.parse(), saved it to tools/factorial.py, dynamically imported it, and registered it in the tool registry.
The agent grew a new capability mid-conversation without restarting.
Agent not creating the tool?
- If the agent responds conversationally instead of creating a tool, try rephrasing: "Create me a tool that calculates factorial." The orchestrator needs to produce a [CREATE_TOOL:] tag in its response for the dispatch to trigger.
- If you see Generated code failed validation, the LLM produced code that did not follow the template. Try the request again. LLM responses can vary between runs.
- If you get an import error for tool_coder, make sure tool_coder.py is in the same directory as main.py.
Help me debug why my self-extending agent is not creating tools
Your agent can now write its own tools on demand. Next up, you will make these tools persist across sessions and give the agent the ability to extend itself without being asked.
Implement Self-Extension and Persistence
Your agent can already create tools on demand. But every time you stop and restart the agent, those tools vanish from memory.
In this step, you will validate that the persistence layer you built actually works across restarts. You will also trace through the self-extension and safety systems to confirm the agent can grow its own capabilities safely.
In this step, get ready to:
- Verify that tools persist across agent restarts.
- Confirm the self-extension loop triggers tool creation autonomously.
- Validate safety guardrails that protect against malformed tool code.
Verify persistence across restarts
The load_saved_tools() method in your ToolRegistry scans the tools/ directory on startup, dynamically imports every .py file, and registers the tools it finds. This means any tool the agent created in a previous session should be instantly available when you restart.
- In the terminal where your agent is running, press Ctrl+C to stop the agent.
- Restart the agent by running:
python main.py
You should see output like this:
Self-Extending Agent ready. Type 'quit' to exit.
Loaded 3 tools: datetime_now, list_files, factorial
The key detail is that factorial appears in the loaded tools list. You did not recreate it. The agent loaded it from the saved tools/factorial.py file.
How does dynamic loading work?
The _load_tool_file() method uses Python's importlib module to load each .py file as an isolated module. It looks for three required attributes: tool_name, tool_description, and tool_func.
If any attribute is missing, the file is skipped with a warning rather than crashing the agent. This makes the system resilient to partially generated or corrupted tool files.
- Test the persisted tool by typing this into the agent chat:
what is the factorial of 10?
The agent should respond with 3628800 by dispatching to the factorial tool. No recreation needed.
Factorial tool not showing up after restart?
- Check that tools/factorial.py exists by running ls tools/ in a separate terminal.
- Make sure the file defines tool_name, tool_description, and tool_func at the module level.
- Verify your virtual environment is activated. The (venv) prefix should appear in your terminal prompt.
Why isn't my factorial tool loading after I restart the agent?
Trace the self-extension loop
Persistence keeps tools alive across sessions. The self-extension loop goes further. It lets the agent propose and create new tools without you explicitly asking.
- In main.py, find this line near the end of the while True loop:
if config.get("auto_extend") and config["interaction_count"] % 5 == 0:
self_reflect(client, config, conversation_history)
How does self-extension work?
Every 5 interactions, the agent enters a reflection cycle. The self_reflect() function sends the last 10 conversation messages to gemini-2.5-flash with a prompt asking it to improve the system prompt.
The guard condition checks that the new prompt still contains {tool_list} and [TOOL: markers. Without those, the agent would lose its ability to dispatch tools. This prevents the reflection from accidentally breaking the agent's core behavior.
- Now find the self_reflect() function at the bottom of main.py and locate this validation check:
new_prompt = response.text.strip()
if "{tool_list}" in new_prompt and "[TOOL:" in new_prompt:
config["system_prompt"] = new_prompt
with open(CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2)
print("\n[System: Agent has updated its own system prompt via self-reflection]\n")
Why validate before applying the new prompt?
If the reflection model returns a prompt that drops the [TOOL:] format instruction, the orchestrator would stop knowing how to call tools. The validation ensures the agent can only evolve within safe boundaries. The updated prompt also persists to config.json so changes survive restarts.
Confirm safety guardrails and test autonomous tool creation
Before trusting the agent to write its own code, you need to understand the safety layers that prevent it from executing malformed or dangerous code.
- Open tool_coder.py and find the _validate_code() method:
def _validate_code(self, code):
try:
tree = ast.parse(code)
has_tool_name = False
has_tool_description = False
has_tool_func = False
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name):
if target.id == "tool_name":
has_tool_name = True
elif target.id == "tool_description":
has_tool_description = True
elif isinstance(node, ast.FunctionDef) and node.name == "tool_func":
has_tool_func = True
if not has_tool_name:
return False, "Missing tool_name assignment"
if not has_tool_description:
return False, "Missing tool_description assignment"
if not has_tool_func:
return False, "Missing tool_func function definition"
return True, None
except SyntaxError as e:
return False, f"Syntax error: {e}"
What are the three safety layers?
- ast.parse() validation catches syntax errors before the code ever runs. If the generated code is not valid Python, it is rejected immediately.
- Isolated module loading via importlib.util means each tool runs in its own module namespace. A tool cannot accidentally overwrite variables in the main agent.
- Manifest tracking in manifest.json records what the agent created, when it was created, and which file stores it. This gives you a full audit trail.
Now let's test all three systems working together. You will ask the agent to create a weather tool, then verify it persists and passes all safety checks.
- In the running agent, type this message:
make yourself a tool for getting the weather from wttr.in
The agent should respond with something like: "Creating new tool: gets the weather from wttr.in" followed by "Successfully created and registered tool 'get_weather'."
- Verify the tool works by asking:
what is the weather in London?
The agent should call the get_weather tool and return current weather data from wttr.in.
- Verify the safety guardrails worked by checking the generated file. Open a second terminal and run:
cat tools/get_weather.py
You should see a well-structured Python file with tool_name, tool_description, and tool_func defined. The tool uses urllib.request (standard library only, no external dependencies) to fetch weather data.
- Check the manifest to confirm tracking by running:
cat tools/manifest.json
You should see both factorial and get_weather entries with their descriptions, filenames, and creation timestamps.
- Do a final persistence check. Stop the agent with Ctrl+C and restart it by running:
python main.py
You should now see 4 loaded tools: datetime_now, list_files, factorial, and get_weather. The agent remembered everything across restarts.
Weather tool creation failed?
- If you see "Generated code failed validation", the model may have included markdown fences in its response. The _clean_code() method strips these, but occasionally the format varies. Try the request again.
- If the tool saves but fails to load, check that the tools/ directory is writable and that the generated file has valid Python syntax.
- If weather data returns an error about the network, confirm the VM has internet access by running curl wttr.in/London?format=3 in a separate terminal.
Why is my agent failing to create or load the weather tool?
What just happened end to end?
You asked the agent for a capability it did not have. The orchestrator (powered by gemma-4-26b-a4b-it) recognized this and dispatched to the tool-coder agent (powered by gemini-2.5-flash).
The tool-coder generated valid Python, passed it through ast.parse() validation, saved the file to tools/, dynamically imported it, registered it in the tool registry, and logged it to manifest.json. The agent grew its own capabilities in real time.
Your agent now persists its tools, extends itself autonomously, and validates everything it writes before running it. Next, you will push this further by letting the agent rewrite its own system prompt and orchestration logic.
Secret mission
Self-Modifying Orchestration
Your agent can already create tools on demand. But what if it could rewrite its own brain? In this mission, you will push the agent past its self-reflection threshold and watch it evolve its own system prompt based on your conversation patterns.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally inside a VM, and the API calls use Google's free tier. There are no ongoing cloud costs. The main resource to consider is the ~40GB of disk space the VM uses on your host machine.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Fedora Workstation 43 QEMU/KVM VM (~40GB disk, 8GB RAM when running).
- Google AI Studio API key (free tier, no cost).
- QEMU/KVM and virt-manager packages on your Fedora host.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the agent further.
- Your VM stays defined and can be started at any time with virt-manager or the terminal.
- Your API key remains active on the free tier with no charges.
- QEMU/KVM and virt-manager remain installed on your host.
Pause - I'll come back to this later
Shut down the VM to free up RAM while keeping all files and configuration intact on disk.
- If the agent is still running inside the VM, type quit in the agent prompt to exit cleanly.
- Shut down the VM from inside the GNOME desktop, or run this on your host:
virsh shutdown <your-vm-name>
- Your VM disk image stays on the host. When you are ready to resume, open virt-manager and start the VM.
- Your API key stays active. No further action needed.
Delete - I don't want to use this again
Remove all project resources completely. This frees ~40GB of disk space and removes the VM definition from your host.
Delete the VM and its storage
- If the VM is running, stop it first by running:
virsh destroy <your-vm-name>
- Remove the VM definition and delete its disk image by running:
virsh undefine <your-vm-name> --remove-all-storage
What do these commands do?
The virsh destroy command forcefully powers off the VM (like pulling the plug). The virsh undefine command removes the VM's configuration from libvirt. The --remove-all-storage flag also deletes the VM's disk image file so you reclaim the full ~40GB.
Delete the Google AI Studio API key
- Open the Google Cloud API Credentials page in your browser.
- Find the API key you created for this project and click the Actions icon next to it.
- Select Delete API key.
- Confirm deletion in the modal. The key will stop working within a few minutes.
Remove QEMU/KVM and virt-manager from your host
- Remove the virtualization packages by running:
sudo dnf remove @virtualization virt-manager
- Stop and disable the libvirtd service by running:
sudo systemctl disable --now libvirtd
- Optionally, delete the downloaded Fedora ISO if you saved it locally.
Nice Work!
Nice Work!
You just built a self-extending AI agent system that writes its own tools, loads them at runtime, and grows its capabilities through conversation. All of it running inside a sandboxed Fedora VM for safe experimentation with self-modifying code.
You learned how to:
- Create a multi-agent orchestrator powered by Gemma 4 with a dynamic tool registry that dispatches tasks to specialized sub-agents.
- Build a tool-coder sub-agent powered by Gemini 2.5 Flash that generates validated Python tools on demand from natural language descriptions.
- Implement dynamic tool loading and persistence so generated tools survive across sessions and the agent autonomously extends itself when it encounters gaps.
- Secret Mission: Made the agent capable of rewriting its own system prompt and behavior configuration through a self-reflection cycle.
Ready to quiz yourself?