kuluru vineeth
01

Part I · The Loop · 10 min

The Three Tools: Bash, Read, Edit

A language model does exactly one thing: given text, it produces more text. That is all it can do. It cannot open a file, run a test, or change a line of code — it can only say that it wants to. A coding agent is the layer that turns those words into actions and feeds the results back, and the whole rest of this book builds outward from that layer. It starts here, with the smallest set of actions that can do real engineering work.

That set has three members. To work in a codebase you need to run things — a build, a test, a grep. You need to see things — the contents of a file you are about to change. And you need to change things — edit that file. Run, see, change: bash, read, edit. Everything else an agent does, however elaborate, is built from these three. This chapter builds all three and pins down the one contract that keeps the third from doing damage.

1.1 The shape of a tool

Before the tools, the shape they share. A tool is a thing the model can ask for by name, with typed arguments, that runs and hands back a result.

tools.ts
export interface ToolDef {
  name: string;
  description: string;
  parameters: Record<string, unknown>;
  run(args: Record<string, unknown>, context: ToolContext): Promise<ToolResult>;
}

Listing 1.1 · every tool is this shape — a name, a description the model reads, a JSON-schema for its arguments, and a run that returns a result.

The name and description are what the model sees when it decides what to do; the parameters are a JSON schema that constrains the arguments it may pass. run takes those arguments and a context — the working directory and the sandbox mode, which the next chapters build on — and returns a ToolResult: an output string and an error flag. That result is the tool’s entire report back to the model. It is also the first place danger enters, because output is text from the outside world — a command’s stdout, a file’s contents — and later chapters treat it as exactly that: untrusted. One guard is visible already: every tool passes its output through a cap that truncates anything past fifty thousand characters, so a single runaway command can never flood the model’s context.

1.2 Bash: run a command

The first tool runs a shell command and returns what it printed.

tools.ts
export const bash: ToolDef = {
  name: "bash",
  description: "Run a shell command in the working directory and return its output.",
  parameters: {
    type: "object",
    properties: { command: { type: "string" } },
    required: ["command"],
  },
  run(args, context) {
    const command = stringArg(args, "command");
    if (command === null) {
      return Promise.resolve({ output: "bash needs a string `command`", error: true });
    }
    const invocation = wrapCommand(command, context);
    if (invocation === null) {
      return Promise.resolve({
        output:
          "no sandbox backend on this platform; refusing to run " +
          "(danger-full-access overrides, deliberately)",
        error: true,
      });
    }
    return new Promise((settle) => {
      execFile(
        invocation.file,
        invocation.args,
        { cwd: context.cwd, timeout: 60_000, maxBuffer: 10 * 1024 * 1024 },
        (failure, stdout, stderr) => {
          const merged = capped(`${stdout}${stderr}`);
          if (failure === null) {
            settle({ output: merged, error: false });
            return;
          }
          const code = typeof failure.code === "number" ? failure.code : "signal";
          settle({ output: `${merged}\n[exit ${code}]`, error: true });
        },
      );
    });
  },
};

Listing 1.2 · run a command in the working directory, merge its output, and report the exit code on failure.

Read run top to bottom. It pulls the command string from the arguments, then hands it to wrapCommand, which returns how to actually invoke it — wrapped in a sandbox. If wrapCommand returns null, there is no sandbox backend on this platform, and the tool refuses to run rather than fall back to an unsandboxed shell. That is a deliberate stance the sandbox chapter makes good on: no confinement, no execution. Given an invocation, execFile runs it with the working directory, a sixty-second timeout, and a ten-megabyte output ceiling — three bounds so a command cannot hang the agent, run forever, or return more than it can hold. stdout and stderr are merged and capped; on success that merged text comes back with error: false, and on failure the exit code is appended and error: true. The model sees the same thing a person would: what the command printed, and whether it worked.

1.3 Read: see a file

The second tool reads a file — the agent’s eyes on the code before it changes anything.

tools.ts
export const readFileTool: ToolDef = {
  name: "read_file",
  description: "Read a file relative to the working directory.",
  parameters: {
    type: "object",
    properties: { path: { type: "string" } },
    required: ["path"],
  },
  async run(args, context) {
    const path = stringArg(args, "path");
    if (path === null) return { output: "read_file needs a string `path`", error: true };
    const target = confinedPath(context, path);
    if (target === null) return { output: `path escapes the workspace: ${path}`, error: true };
    try {
      return { output: capped(await readFile(target, "utf8")), error: false };
    } catch (failure) {
      return { output: String(failure), error: true };
    }
  },
};

Listing 1.3 · resolve the path inside the workspace, read it, cap it. A path that escapes the workspace is refused.

The work is one line — readFile — wrapped in two guards. confinedPath resolves the requested path against the working directory and returns null if it lands outside the workspace, so read_file ../../etc/passwd is refused before anything opens. The read itself is capped like bash’s output, and any failure — a missing file, a permission error — comes back as error: true with the message, rather than throwing. A tool must always return a result the model can read; an unhandled exception would break the loop that the next chapter builds.

1.4 Edit: change a file, exactly once

The third tool changes a file. It is the one that can do harm, and it carries the contract that prevents it.

tools.ts
export const editFile: ToolDef = {
  name: "edit_file",
  description:
    "Replace `old` with `new` in a file; `old` must appear exactly once. " +
    "An empty `old` creates or overwrites the file with `new`.",
  parameters: {
    type: "object",
    properties: {
      path: { type: "string" },
      old: { type: "string" },
      new: { type: "string" },
    },
    required: ["path", "old", "new"],
  },
  async run(args, context) {
    const path = stringArg(args, "path");
    const before = stringArg(args, "old");
    const after = stringArg(args, "new");
    if (path === null || before === null || after === null) {
      return { output: "edit_file needs string `path`, `old` and `new`", error: true };
    }
    if (context.sandbox === "read-only") {
      return { output: "the read-only sandbox refuses edits", error: true };
    }
    const target = confinedPath(context, path);
    if (target === null) return { output: `path escapes the workspace: ${path}`, error: true };
    if (before === "") {
      await mkdir(dirname(target), { recursive: true });
      await writeFile(target, after);
      return { output: `wrote ${path}`, error: false };
    }
    let text: string;
    try {
      text = await readFile(target, "utf8");
    } catch (failure) {
      return { output: String(failure), error: true };
    }
    const occurrences = text.split(before).length - 1;
    if (occurrences !== 1) {
      return { output: `\`old\` appears ${occurrences} times; it must appear exactly once`, error: true };
    }
    await writeFile(target, text.replace(before, after));
    return { output: `edited ${path}`, error: false };
  },
};

Listing 1.4 · replace old with new — but only if old occurs exactly once. An empty old writes the whole file.

Edit works by find-and-replace: the model supplies the old text and the new text, and the tool swaps one for the other. The empty-old case is the escape hatch — it creates the file, or overwrites it whole. Everything turns on the check in the middle. The tool counts how many times old appears, and if that count is anything other than one, it refuses. Not zero — there is nothing to change, and a silent no-op would let the model believe an edit landed when it did not. Not two or more — the model meant one specific place, and a blind replace would change only the first, leaving the file half-edited and the model misinformed. Exactly one occurrence is the only case where the intent is unambiguous, so it is the only case the tool allows. This is the difference between an agent that edits code and an agent that corrupts it: when the target is ambiguous, edit fails loudly and makes the model look closer, instead of guessing.

1.5 Correct: the three contracts hold

Three tools, three contracts: bash returns what a command printed, read returns what a file holds, and edit changes exactly one unambiguous match or refuses. The companion driver exercises all three against the real tools in a throwaway workspace — it runs a command and checks the output, writes a file and reads it back, makes a one-match edit and confirms it, and hands edit an ambiguous target to confirm it is refused:

$ bun hoist_check.ts

tool contracts 4/4 PARITY OK

Four contracts, four passes. The driver runs the tools with full access so it can test their behavior in isolation; the sandbox that would confine bash and read to the workspace is a later chapter, and here it is out of the way on purpose. What this proves is the part that is easy to get wrong and easy to test: that each tool does what it says, and that edit holds its exactly-once line.

Three tools are enough to act, but on their own they do nothing — something has to let the model choose one, call it, read the result, and decide what to do next. That something is the loop at the center of every agent. It is the next chapter.