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
- Pure refactor, no new behavior: move the tool functions, their JSON specs, and a name→function dispatch dict into their own module.
main.pyshould shrink to importing that module's specs + dispatcher instead of growing anif/elifchain.
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.