2. Tool Use

"Adding a tool means adding one handler"

15 min read
πŸ’‘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

ComponentThe Agent LoopTool Use
Loopwhile True + stop_reasonSame
Tools1 (bash)4 (bash, read, write, edit)
DispatchDirect callMap: {name: handler}
SafetyCommand 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

The Dispatch Map
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 path
6 
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.
Step 1 of 5
πŸ§ͺ Try it yourself
πŸ”₯ Warm-up ~5 min

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.

πŸ”¨ Build ~20 min

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.

πŸš€ Stretch ~45 min

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.

Found a mistake? Report it β†’