Base agent

6. Extract tools module

View this stage's code on GitHub →

Three tools and main.py already has a growing if/elif chain mixing agent-loop logic with tool-dispatch logic. This stage is pure refactor, no new behavior: tools.py now owns the three tool functions, their JSON specs, and a TOOL_FUNCTIONS dict that execute_tool looks up by name. Every tool added from here on touches one file, not two.

Your task

  1. Pure refactor, no new behavior: move the tool functions, their JSON specs, and a name→function dispatch dict into their own module.
  2. main.py should shrink to importing that module's specs + dispatcher instead of growing an if/elif chain.

The solution

Try the task above first. When you want to compare, this is exactly what changed since stage 5 (just diff 5 6 shows the same).

Show the solutionHide the solution +109 −92 lines
main.py+5 −92
⋯ """Stage 5: the Bash toolStage 6: extract a tools module Shell access: subprocess.run captures stdout and stderr, and the combinedoutput goes back to the model as the tool's result (empty on a silentsuccess, like `rm file` with no output).main.py is now just the agent loop — tool specs and implementations livein tools.py (see that file for why this split happened).""" import argparseimport jsonimport osimport subprocessimport sys from openai import OpenAI from tools import TOOLS, execute_tool API_KEY = os.getenv("OPENROUTER_API_KEY")BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")MODEL = os.getenv("MODEL", "anthropic/claude-haiku-4.5")MAX_TURNS = 20 TOOLS = [    {        "type": "function",        "function": {            "name": "Read",            "description": "Read and return the contents of a file",            "parameters": {                "type": "object",                "required": ["file_path"],                "properties": {                    "file_path": {                        "type": "string",                        "description": "The path to the file to read",                    }                },            },        },    },    {        "type": "function",        "function": {            "name": "Write",            "description": "Write content to a file, creating it if needed or overwriting it if it exists",            "parameters": {                "type": "object",                "required": ["file_path", "content"],                "properties": {                    "file_path": {                        "type": "string",                        "description": "The path of the file to write to",                    },                    "content": {                        "type": "string",                        "description": "The content to write to the file",                    },                },            },        },    },    {        "type": "function",        "function": {            "name": "Bash",            "description": "Execute a shell command",            "parameters": {                "type": "object",                "required": ["command"],                "properties": {                    "command": {                        "type": "string",                        "description": "The command to execute",                    }                },            },        },    },]  def Read(file_path):    with open(file_path) as f:        return f.read()  def Write(file_path, content):    with open(file_path, "w") as f:        f.write(content)    return f"Wrote to {file_path}"  def Bash(command):    completed = subprocess.run(command, shell=True, capture_output=True, text=True)    output = completed.stdout + completed.stderr    if completed.returncode != 0:        output += f"\n(exit code {completed.returncode})"    return output  def execute_tool(name, arguments):    if name == "Read":        return Read(arguments["file_path"])    if name == "Write":        return Write(arguments["file_path"], arguments["content"])    if name == "Bash":        return Bash(arguments["command"])    raise RuntimeError(f"unknown tool: {name}")  def main():    parser = argparse.ArgumentParser(description="petite-harness: a tiny AI coding assistant")
tools.py+104 −0
⋯ """Stage 6: extract a tools module Three tools in and the `if/elif` in execute_tool, plus the hand-writtenTOOLS list, are two places that have to stay in sync by hand. That's thesignal to extract: one module owns both the specs the model sees and thefunctions that back them, with a dict instead of a growing if/elif chain. Adding a fourth tool from here on is: write the function, add its spec toTOOLS, add one line to TOOL_FUNCTIONS. Nothing in main.py changes.""" import subprocess  def Read(file_path):    with open(file_path) as f:        return f.read()  def Write(file_path, content):    with open(file_path, "w") as f:        f.write(content)    return f"Wrote to {file_path}"  def Bash(command):    completed = subprocess.run(command, shell=True, capture_output=True, text=True)    output = completed.stdout + completed.stderr    if completed.returncode != 0:        output += f"\n(exit code {completed.returncode})"    return output  TOOLS = [    {        "type": "function",        "function": {            "name": "Read",            "description": "Read and return the contents of a file",            "parameters": {                "type": "object",                "required": ["file_path"],                "properties": {                    "file_path": {                        "type": "string",                        "description": "The path to the file to read",                    }                },            },        },    },    {        "type": "function",        "function": {            "name": "Write",            "description": "Write content to a file, creating it if needed or overwriting it if it exists",            "parameters": {                "type": "object",                "required": ["file_path", "content"],                "properties": {                    "file_path": {                        "type": "string",                        "description": "The path of the file to write to",                    },                    "content": {                        "type": "string",                        "description": "The content to write to the file",                    },                },            },        },    },    {        "type": "function",        "function": {            "name": "Bash",            "description": "Execute a shell command",            "parameters": {                "type": "object",                "required": ["command"],                "properties": {                    "command": {                        "type": "string",                        "description": "The command to execute",                    }                },            },        },    },] TOOL_FUNCTIONS = {    "Read": Read,    "Write": Write,    "Bash": Bash,}  def execute_tool(name, arguments):    fn = TOOL_FUNCTIONS.get(name)    if fn is None:        raise RuntimeError(f"unknown tool: {name}")    return fn(**arguments)

Try it

Run this stage's own code:

just stage 6
cd .stages/06
just run "List the files in this directory with ls, then tell me how many there are."

Expect: The same behaviour as stage 5 — the file count is higher only because tools.py (and, once it is imported, __pycache__/) now exist; this stage changes where the code lives, not what it does

.stages/06 is a git worktree: your checkout stays on main, so just keeps working there. Compare with the previous stage with just diff 5 6, or browse it on GitHub; just clean-stages removes the worktrees.

Where this fits

The whole agent; this stage builds the highlighted part. Click any part to jump to its stage.

← 5. Bash tool 7. Tool classes with Pydantic →