Back to Tools
The Agent Loop → Tools
s01 (84 LOC) → s02 (120 LOC)
LOC Delta
+36lines
New Tools
3
read_filewrite_fileedit_file
New Classes
0
New Functions
4
safe_pathrun_readrun_writerun_edit
The Agent Loop
Bash is All You Need
84 LOC
1 tools: bash
toolsTools
One Handler Per Tool
120 LOC
4 tools: bash, read_file, write_file, edit_file
toolsSource Code Diff
s01 (s01_agent_loop.py) -> s02 (s02_tool_use.py)
| 1 | 1 | #!/usr/bin/env python3 | |
| 2 | - | # Harness: the loop -- the model's first connection to the real world. | |
| 2 | + | # Harness: tool dispatch -- expanding what the model can reach. | |
| 3 | 3 | """ | |
| 4 | - | s01_agent_loop.py - The Agent Loop | |
| 4 | + | s02_tool_use.py - Tools | |
| 5 | 5 | ||
| 6 | - | The entire secret of an AI coding agent in one pattern: | |
| 6 | + | The agent loop from s01 didn't change. We just added tools to the array | |
| 7 | + | and a dispatch map to route calls. | |
| 7 | 8 | ||
| 8 | - | while stop_reason == "tool_use": | |
| 9 | - | response = LLM(messages, tools) | |
| 10 | - | execute tools | |
| 11 | - | append results | |
| 9 | + | +----------+ +-------+ +------------------+ | |
| 10 | + | | User | ---> | LLM | ---> | Tool Dispatch | | |
| 11 | + | | prompt | | | | { | | |
| 12 | + | +----------+ +---+---+ | bash: run_bash | | |
| 13 | + | ^ | read: run_read | | |
| 14 | + | | | write: run_wr | | |
| 15 | + | +----------+ edit: run_edit | | |
| 16 | + | tool_result| } | | |
| 17 | + | +------------------+ | |
| 12 | 18 | ||
| 13 | - | +----------+ +-------+ +---------+ | |
| 14 | - | | User | ---> | LLM | ---> | Tool | | |
| 15 | - | | prompt | | | | execute | | |
| 16 | - | +----------+ +---+---+ +----+----+ | |
| 17 | - | ^ | | |
| 18 | - | | tool_result | | |
| 19 | - | +---------------+ | |
| 20 | - | (loop continues) | |
| 21 | - | ||
| 22 | - | This is the core loop: feed tool results back to the model | |
| 23 | - | until the model decides to stop. Production agents layer | |
| 24 | - | policy, hooks, and lifecycle controls on top. | |
| 19 | + | Key insight: "The loop didn't change at all. I just added tools." | |
| 25 | 20 | """ | |
| 26 | 21 | ||
| 27 | 22 | import os | |
| 28 | 23 | import subprocess | |
| 24 | + | from pathlib import Path | |
| 29 | 25 | ||
| 30 | 26 | from anthropic import Anthropic | |
| 31 | 27 | from dotenv import load_dotenv | |
| 32 | 28 | ||
| 33 | 29 | load_dotenv(override=True) | |
| 34 | 30 | ||
| 35 | 31 | if os.getenv("ANTHROPIC_BASE_URL"): | |
| 36 | 32 | os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) | |
| 37 | 33 | ||
| 34 | + | WORKDIR = Path.cwd() | |
| 38 | 35 | client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL")) | |
| 39 | 36 | MODEL = os.environ["MODEL_ID"] | |
| 40 | 37 | ||
| 41 | - | SYSTEM = f"You are a coding agent at {os.getcwd()}. Use bash to solve tasks. Act, don't explain." | |
| 38 | + | SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain." | |
| 42 | 39 | ||
| 43 | - | TOOLS = [{ | |
| 44 | - | "name": "bash", | |
| 45 | - | "description": "Run a shell command.", | |
| 46 | - | "input_schema": { | |
| 47 | - | "type": "object", | |
| 48 | - | "properties": {"command": {"type": "string"}}, | |
| 49 | - | "required": ["command"], | |
| 50 | - | }, | |
| 51 | - | }] | |
| 52 | 40 | ||
| 41 | + | def safe_path(p: str) -> Path: | |
| 42 | + | path = (WORKDIR / p).resolve() | |
| 43 | + | if not path.is_relative_to(WORKDIR): | |
| 44 | + | raise ValueError(f"Path escapes workspace: {p}") | |
| 45 | + | return path | |
| 53 | 46 | ||
| 47 | + | ||
| 54 | 48 | def run_bash(command: str) -> str: | |
| 55 | 49 | dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] | |
| 56 | 50 | if any(d in command for d in dangerous): | |
| 57 | 51 | return "Error: Dangerous command blocked" | |
| 58 | 52 | try: | |
| 59 | - | r = subprocess.run(command, shell=True, cwd=os.getcwd(), | |
| 53 | + | r = subprocess.run(command, shell=True, cwd=WORKDIR, | |
| 60 | 54 | capture_output=True, text=True, timeout=120) | |
| 61 | 55 | out = (r.stdout + r.stderr).strip() | |
| 62 | 56 | return out[:50000] if out else "(no output)" | |
| 63 | 57 | except subprocess.TimeoutExpired: | |
| 64 | 58 | return "Error: Timeout (120s)" | |
| 65 | 59 | ||
| 66 | 60 | ||
| 67 | - | # -- The core pattern: a while loop that calls tools until the model stops -- | |
| 61 | + | def run_read(path: str, limit: int = None) -> str: | |
| 62 | + | try: | |
| 63 | + | text = safe_path(path).read_text() | |
| 64 | + | lines = text.splitlines() | |
| 65 | + | if limit and limit < len(lines): | |
| 66 | + | lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"] | |
| 67 | + | return "\n".join(lines)[:50000] | |
| 68 | + | except Exception as e: | |
| 69 | + | return f"Error: {e}" | |
| 70 | + | ||
| 71 | + | ||
| 72 | + | def run_write(path: str, content: str) -> str: | |
| 73 | + | try: | |
| 74 | + | fp = safe_path(path) | |
| 75 | + | fp.parent.mkdir(parents=True, exist_ok=True) | |
| 76 | + | fp.write_text(content) | |
| 77 | + | return f"Wrote {len(content)} bytes to {path}" | |
| 78 | + | except Exception as e: | |
| 79 | + | return f"Error: {e}" | |
| 80 | + | ||
| 81 | + | ||
| 82 | + | def run_edit(path: str, old_text: str, new_text: str) -> str: | |
| 83 | + | try: | |
| 84 | + | fp = safe_path(path) | |
| 85 | + | content = fp.read_text() | |
| 86 | + | if old_text not in content: | |
| 87 | + | return f"Error: Text not found in {path}" | |
| 88 | + | fp.write_text(content.replace(old_text, new_text, 1)) | |
| 89 | + | return f"Edited {path}" | |
| 90 | + | except Exception as e: | |
| 91 | + | return f"Error: {e}" | |
| 92 | + | ||
| 93 | + | ||
| 94 | + | # -- The dispatch map: {tool_name: handler} -- | |
| 95 | + | TOOL_HANDLERS = { | |
| 96 | + | "bash": lambda **kw: run_bash(kw["command"]), | |
| 97 | + | "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), | |
| 98 | + | "write_file": lambda **kw: run_write(kw["path"], kw["content"]), | |
| 99 | + | "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | TOOLS = [ | |
| 103 | + | {"name": "bash", "description": "Run a shell command.", | |
| 104 | + | "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}, | |
| 105 | + | {"name": "read_file", "description": "Read file contents.", | |
| 106 | + | "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}, | |
| 107 | + | {"name": "write_file", "description": "Write content to file.", | |
| 108 | + | "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}, | |
| 109 | + | {"name": "edit_file", "description": "Replace exact text in file.", | |
| 110 | + | "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}, | |
| 111 | + | ] | |
| 112 | + | ||
| 113 | + | ||
| 68 | 114 | def agent_loop(messages: list): | |
| 69 | 115 | while True: | |
| 70 | 116 | response = client.messages.create( | |
| 71 | 117 | model=MODEL, system=SYSTEM, messages=messages, | |
| 72 | 118 | tools=TOOLS, max_tokens=8000, | |
| 73 | 119 | ) | |
| 74 | - | # Append assistant turn | |
| 75 | 120 | messages.append({"role": "assistant", "content": response.content}) | |
| 76 | - | # If the model didn't call a tool, we're done | |
| 77 | 121 | if response.stop_reason != "tool_use": | |
| 78 | 122 | return | |
| 79 | - | # Execute each tool call, collect results | |
| 80 | 123 | results = [] | |
| 81 | 124 | for block in response.content: | |
| 82 | 125 | if block.type == "tool_use": | |
| 83 | - | print(f"\033[33m$ {block.input['command']}\033[0m") | |
| 84 | - | output = run_bash(block.input["command"]) | |
| 85 | - | print(output[:200]) | |
| 86 | - | results.append({"type": "tool_result", "tool_use_id": block.id, | |
| 87 | - | "content": output}) | |
| 126 | + | handler = TOOL_HANDLERS.get(block.name) | |
| 127 | + | output = handler(**block.input) if handler else f"Unknown tool: {block.name}" | |
| 128 | + | print(f"> {block.name}: {output[:200]}") | |
| 129 | + | results.append({"type": "tool_result", "tool_use_id": block.id, "content": output}) | |
| 88 | 130 | messages.append({"role": "user", "content": results}) | |
| 89 | 131 | ||
| 90 | 132 | ||
| 91 | 133 | if __name__ == "__main__": | |
| 92 | 134 | history = [] | |
| 93 | 135 | while True: | |
| 94 | 136 | try: | |
| 95 | - | query = input("\033[36ms01 >> \033[0m") | |
| 137 | + | query = input("\033[36ms02 >> \033[0m") | |
| 96 | 138 | except (EOFError, KeyboardInterrupt): | |
| 97 | 139 | break | |
| 98 | 140 | if query.strip().lower() in ("q", "exit", ""): | |
| 99 | 141 | break | |
| 100 | 142 | history.append({"role": "user", "content": query}) | |
| 101 | 143 | agent_loop(history) | |
| 102 | 144 | response_content = history[-1]["content"] | |
| 103 | 145 | if isinstance(response_content, list): | |
| 104 | 146 | for block in response_content: | |
| 105 | 147 | if hasattr(block, "text"): | |
| 106 | 148 | print(block.text) | |
| 107 | 149 | print() |