2. Tool Use
"Adding a tool means adding one handler"
New to this?
What's a dispatch map?
A dictionary that maps tool names to their handler functions. When the model calls 'read_file', the dispatch map looks up which Python function handles that β like a phone directory for tools.
Why do we sandbox file paths?
To prevent the agent from reading or writing files outside the project directory. The safe_path() function checks that any requested path stays within the workspace β a basic security boundary.
What's path traversal?
A trick where someone uses '../' in a file path to escape the intended directory. For example, '../../etc/passwd' tries to read system files. Our sandbox blocks this.
The Problem
The Agent Loop session gave the agent one tool: bash. That works, but itβs a blunt instrument. Every file read requires cat, every write requires echo >, every edit requires sed. The model wastes tokens on shell syntax when it could use purpose-built tools.
The Solution
Add tools to the array. Add handlers to the dispatch map. The loop doesnβt change.
TOOL_HANDLERS = {
"bash": run_bash,
"read_file": run_read,
"write_file": run_write,
"edit_file": run_edit,
}
Thatβs the key insight: the loop stays identical from the first session. Only the tools array and dispatch map grow.
The Dispatch Map
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_read(path: str, limit: int = None) -> str:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit]
return "\n".join(lines)[:50000]
def run_write(path: str, content: str) -> str:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1))
return f"Edited {path}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
}
What Changed from The Agent Loop
| Component | The Agent Loop | Tool Use |
|---|---|---|
| Loop | while True + stop_reason | Same |
| Tools | 1 (bash) | 4 (bash, read, write, edit) |
| Dispatch | Direct call | Map: {name: handler} |
| Safety | Command blocklist | + Path sandboxing |
The loop is identical. The only growth is in the tools array and the dispatch map. This pattern scales indefinitely β subsequent sessions keep adding tools without touching the loop.
Key Takeaway
Adding a tool to an agent means two things: (1) a JSON schema the model sees, (2) a handler function the harness calls. The loop never changes. This is the foundation of harness engineering β the model gets more capable without the core architecture growing more complex.
Interactive Code Walkthrough
1def safe_path(p: str) -> Path:2 path = (WORKDIR / p).resolve()3 if not path.is_relative_to(WORKDIR):4 raise ValueError(f"Path escapes workspace: {p}")5 return path6 7def run_read(path: str, limit: int = None) -> str:8 text = safe_path(path).read_text()9 lines = text.splitlines()10 if limit and limit < len(lines):11 lines = lines[:limit]12 return "\n".join(lines)[:50000]13 14def run_write(path: str, content: str) -> str:15 fp = safe_path(path)16 fp.parent.mkdir(parents=True, exist_ok=True)17 fp.write_text(content)18 return f"Wrote {len(content)} bytes to {path}"19 20def run_edit(path: str, old_text: str, new_text: str) -> str:21 fp = safe_path(path)22 content = fp.read_text()23 if old_text not in content:24 return f"Error: Text not found in {path}"25 fp.write_text(content.replace(old_text, new_text, 1))26 return f"Edited {path}"27 28TOOL_HANDLERS = {29 "bash": lambda **kw: run_bash(kw["command"]),30 "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),31 "write_file": lambda **kw: run_write(kw["path"], kw["content"]),32 "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),33}34 safe_path() is the security boundary. It resolves the path and checks it stays inside WORKDIR. Any '../' escape attempt raises an error before touching the filesystem.The dispatch map pattern maps tool names to functions. What would happen if the model requested a tool name that doesn't exist in TOOL_HANDLERS? How would you handle that gracefully?
Hint
Try TOOL_HANDLERS.get(block.name) with a default fallback that returns an error message.
Add a fifth tool β list_files β that lists files in a directory. Write the schema, handler, and add it to the dispatch map.
Hint
Use os.listdir() in the handler. The schema needs a path parameter.
Implement rate limiting for the bash tool: max 3 bash calls per minute. If exceeded, return a 'rate limit exceeded, wait N seconds' message instead of executing.
Hint
Use a list of timestamps and filter to those within the last 60 seconds.