Learn Claude Code
Back to Tools

The Agent LoopTools

s01 (154 LOC) → s02 (237 LOC)

LOC Delta

+83lines

New Tools

3

read_filewrite_fileedit_file
New Classes

0

New Functions

4

safePathrunReadrunWriterunEdit

The Agent Loop

Bash is All You Need

154 LOC

1 tools: bash

tools

Tools

One Handler Per Tool

237 LOC

4 tools: bash, read_file, write_file, edit_file

tools

Source Code Diff

s01 (s01_agent_loop.ts) -> s02 (s02_tool_use.ts)
11#!/usr/bin/env node
22/**
3- * s01_agent_loop.ts - The Agent Loop
3+ * s02_tool_use.ts - Tools
44 *
5- * The entire secret of an AI coding agent in one pattern:
5+ * The loop from s01 does not change. We add more tools and a dispatch map:
66 *
7- * while (stopReason === "tool_use") {
8- * response = LLM(messages, tools)
9- * executeTools()
10- * appendResults()
11- * }
7+ * { tool_name: handler }
8+ *
9+ * Key insight: adding a tool means adding one handler.
1210 */
1311
1412import { spawnSync } from "node:child_process";
13+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1514import process from "node:process";
1615import { createInterface } from "node:readline/promises";
16+import { resolve } from "node:path";
1717import type Anthropic from "@anthropic-ai/sdk";
1818import "dotenv/config";
1919import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared";
2020
21-type ToolUseName = "bash";
21+type ToolUseName = "bash" | "read_file" | "write_file" | "edit_file";
2222
2323type ToolUseBlock = {
2424 id: string;
2525 type: "tool_use";
2626 name: ToolUseName;
2727 input: Record<string, unknown>;
2828};
2929
3030type TextBlock = {
3131 type: "text";
3232 text: string;
3333};
3434
3535type ToolResultBlock = {
3636 type: "tool_result";
3737 tool_use_id: string;
3838 content: string;
3939};
4040
4141type MessageContent = string | Array<ToolUseBlock | TextBlock | ToolResultBlock>;
4242
4343type Message = {
4444 role: "user" | "assistant";
4545 content: MessageContent;
4646};
4747
4848const WORKDIR = process.cwd();
4949const MODEL = resolveModel();
5050const client = createAnthropicClient();
5151
52-const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use bash to solve tasks. Act, don't explain.`);
52+const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use tools to solve tasks. Act, don't explain.`);
5353
54+function safePath(relativePath: string): string {
55+ const filePath = resolve(WORKDIR, relativePath);
56+ const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`;
57+ if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) {
58+ throw new Error(`Path escapes workspace: ${relativePath}`);
59+ }
60+ return filePath;
61+}
62+
5463function runBash(command: string): string {
5564 const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"];
5665 if (dangerous.some((item) => command.includes(item))) {
5766 return "Error: Dangerous command blocked";
5867 }
5968
6069 const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
6170 const args = process.platform === "win32"
6271 ? ["/d", "/s", "/c", command]
6372 : ["-lc", command];
6473
6574 const result = spawnSync(shell, args, {
6675 cwd: WORKDIR,
6776 encoding: "utf8",
6877 timeout: 120_000,
6978 });
7079
7180 if (result.error?.name === "TimeoutError") {
7281 return "Error: Timeout (120s)";
7382 }
7483
7584 const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
7685 return output.slice(0, 50_000) || "(no output)";
7786}
7887
88+function runRead(path: string, limit?: number): string {
89+ try {
90+ let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/);
91+ if (limit && limit < lines.length) {
92+ lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`);
93+ }
94+ return lines.join("\n").slice(0, 50_000);
95+ } catch (error) {
96+ return `Error: ${error instanceof Error ? error.message : String(error)}`;
97+ }
98+}
99+
100+function runWrite(path: string, content: string): string {
101+ try {
102+ const filePath = safePath(path);
103+ mkdirSync(resolve(filePath, ".."), { recursive: true });
104+ writeFileSync(filePath, content, "utf8");
105+ return `Wrote ${content.length} bytes`;
106+ } catch (error) {
107+ return `Error: ${error instanceof Error ? error.message : String(error)}`;
108+ }
109+}
110+
111+function runEdit(path: string, oldText: string, newText: string): string {
112+ try {
113+ const filePath = safePath(path);
114+ const content = readFileSync(filePath, "utf8");
115+ if (!content.includes(oldText)) {
116+ return `Error: Text not found in ${path}`;
117+ }
118+ writeFileSync(filePath, content.replace(oldText, newText), "utf8");
119+ return `Edited ${path}`;
120+ } catch (error) {
121+ return `Error: ${error instanceof Error ? error.message : String(error)}`;
122+ }
123+}
124+
79125const TOOL_HANDLERS: Record<ToolUseName, (input: Record<string, unknown>) => string> = {
80126 bash: (input) => runBash(String(input.command ?? "")),
127+ read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined),
128+ write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")),
129+ edit_file: (input) =>
130+ runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")),
81131};
82132
83133const TOOLS = [
84134 {
85135 name: "bash",
86136 description: shellToolDescription(),
87137 input_schema: {
88138 type: "object",
89139 properties: {
90140 command: { type: "string" },
91141 },
92142 required: ["command"],
93143 },
94144 },
145+ {
146+ name: "read_file",
147+ description: "Read file contents.",
148+ input_schema: {
149+ type: "object",
150+ properties: {
151+ path: { type: "string" },
152+ limit: { type: "integer" },
153+ },
154+ required: ["path"],
155+ },
156+ },
157+ {
158+ name: "write_file",
159+ description: "Write content to file.",
160+ input_schema: {
161+ type: "object",
162+ properties: {
163+ path: { type: "string" },
164+ content: { type: "string" },
165+ },
166+ required: ["path", "content"],
167+ },
168+ },
169+ {
170+ name: "edit_file",
171+ description: "Replace exact text in file.",
172+ input_schema: {
173+ type: "object",
174+ properties: {
175+ path: { type: "string" },
176+ old_text: { type: "string" },
177+ new_text: { type: "string" },
178+ },
179+ required: ["path", "old_text", "new_text"],
180+ },
181+ },
95182];
96183
97184function assistantText(content: Array<ToolUseBlock | TextBlock | ToolResultBlock>) {
98185 return content
99186 .filter((block): block is TextBlock => block.type === "text")
100187 .map((block) => block.text)
101188 .join("\n");
102189}
103190
104191export async function agentLoop(messages: Message[]) {
105192 while (true) {
106193 const response = await client.messages.create({
107194 model: MODEL,
108195 system: SYSTEM,
109196 messages: messages as Anthropic.Messages.MessageParam[],
110197 tools: TOOLS as Anthropic.Messages.Tool[],
111198 max_tokens: 8000,
112199 });
113200
114201 messages.push({
115202 role: "assistant",
116203 content: response.content as Array<ToolUseBlock | TextBlock>,
117204 });
118205
119206 if (response.stop_reason !== "tool_use") {
120207 return;
121208 }
122209
123210 const results: ToolResultBlock[] = [];
124211
125212 for (const block of response.content) {
126213 if (block.type !== "tool_use") continue;
127214
128215 const handler = TOOL_HANDLERS[block.name as ToolUseName];
129216 const output = handler
130217 ? handler(block.input as Record<string, unknown>)
131218 : `Unknown tool: ${block.name}`;
132219
133220 console.log(`> ${block.name}: ${output.slice(0, 200)}`);
134221 results.push({
135222 type: "tool_result",
136223 tool_use_id: block.id,
137224 content: output,
138225 });
139226 }
140227
141228 messages.push({
142229 role: "user",
143230 content: results,
144231 });
145232 }
146233}
147234
148235async function main() {
149236 const rl = createInterface({
150237 input: process.stdin,
151238 output: process.stdout,
152239 });
153240
154241 const history: Message[] = [];
155242
156243 while (true) {
157244 let query = "";
158245 try {
159- query = await rl.question("\x1b[36ms01 >> \x1b[0m");
246+ query = await rl.question("\x1b[36ms02 >> \x1b[0m");
160247 } catch (error) {
161248 if (
162249 error instanceof Error &&
163250 (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError")
164251 ) {
165252 break;
166253 }
167254 throw error;
168255 }
169256 if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) {
170257 break;
171258 }
172259
173260 history.push({ role: "user", content: query });
174261 await agentLoop(history);
175262
176263 const last = history[history.length - 1]?.content;
177264 if (Array.isArray(last)) {
178265 const text = assistantText(last);
179266 if (text) console.log(text);
180267 }
181268 console.log();
182269 }
183270
184271 rl.close();
185272}
186273
187274void main();