pleach
Coding agent

File tools

The eight vendor-neutral file / diff / exec tool factories — read_file, write_file, apply_diff, search_files, list_files, run_tests, git_clone, git_diff.

@pleach/coding-agent/tools ships eight tool factories the coding-agent loop drives. Each takes a SandboxClient (the 3-method contract from @pleach/coding-agent/sandbox) and returns a PluginToolDefinition from @pleach/core — the canonical defineTool shape.

The factories do NOT import @pleach/sandbox directly. They are vendor-neutral by construction: anything they need flows through the 3-method SandboxClient, which any vendor adapter satisfies.

What ships

import {
  createReadFileTool,
  createWriteFileTool,
  createApplyDiffTool,
  createSearchFilesTool,
  createListFilesTool,
  createRunTestsTool,
  createGitCloneTool,
  createGitDiffTool,
} from "@pleach/coding-agent/tools";

Five read-side primitives + three exec-side primitives. Eight total.

ToolClassWhat it does
read_filereadRead a workspace file. Returns { content, bytes }.
write_filereadWrite a workspace file. Returns { bytesWritten }.
apply_diffreadApply a unified diff via POSIX patch -p0. Returns { hunksApplied, conflicts }.
search_filesreadPattern-match across the workspace. Uses rg when available, falls back to grep -rn.
list_filesreadEnumerate via find with -printf '%y\t%s\t%p\n'.
run_testsexecRun a test command; parse jest / vitest / pytest / cargo / go test output.
git_cloneexecgit clone --depth 1 <url> + resolve commit SHA + branch.
git_diffexecgit diff + --numstat; returns raw diff text + per-file counts.

The "read-side" labels mean the tool reads from or writes to the sandbox workspace — they're still load-bearing for the agent's behavior. The "exec-side" labels mean the tool drives non-trivial process execution (long-running tests, git operations) and surfaces exit codes / timing back to the LLM.

Wiring against a sandbox client

import { createCodingAgentRuntime } from "@pleach/coding-agent/runtime";
import {
  createPooledSandboxProvider,
  createSandboxComposite,
} from "@pleach/coding-agent/sandbox";
import {
  createReadFileTool,
  createWriteFileTool,
  createRunTestsTool,
  createGitCloneTool,
} from "@pleach/coding-agent/tools";
import { createHttpStreamSandboxProvider } from "@pleach/sandbox";

const adapter = createHttpStreamSandboxProvider({
  baseURL: process.env.MODAL_SANDBOX_BASE_URL!,
  auth: async () => ({
    headers: { Authorization: `Bearer ${process.env.MODAL_TOKEN!}` },
  }),
});

const provider = createPooledSandboxProvider({ adapter });
const session = await provider.acquire();

const tools = [
  createReadFileTool(session.client),
  createWriteFileTool(session.client),
  createRunTestsTool(session.client),
  createGitCloneTool(session.client),
  // ...four more
];

const runtime = createCodingAgentRuntime({
  sandboxProvider: provider,
  // ...rest of config; runtime registers the tools
});

The tools close over session.client — when the session changes (a fresh acquire after a release), reconstruct the tools.

read_file

const readFile = createReadFileTool(session.client);
InputOutput
{ path: string, encoding?: "utf8" | "base64" }{ content: string, bytes: number }

bytes is the byte length of the decoded payload — utf-8 byte length for text; raw byte length of the binary payload for base64. The tool computes byte counts locally (Buffer.byteLength) so the SandboxClient contract doesn't need to surface byte counts (many vendor adapters don't).

write_file

const writeFile = createWriteFileTool(session.client);
InputOutput
{ path: string, content: string, encoding?: "utf8" | "base64" }{ bytesWritten: number }

bytesWritten is computed locally via Buffer.byteLength before the sandbox call — the public output shape stays stable even when the vendor adapter returns void.

apply_diff

const applyDiff = createApplyDiffTool(session.client);
InputOutput
{ path: string, diff: string }{ hunksApplied: number, conflicts: string[] }

Wire protocol: writes the diff to .pleach-apply-diff.patch in the workspace, then runs patch -p0 <path> < .pleach-apply-diff.patch.

hunksApplied is parsed from patch stdout (Hunk #N succeeded lines); falls back to counting @@ ... @@ headers in the input diff when patch applied silently. conflicts is the array of Hunk #N FAILED lines from stdout + stderr. Non-empty conflicts indicates a partial-success — the LLM gets to decide whether to retry with a fresh diff or surface the rejects.

search_files

const searchFiles = createSearchFilesTool(session.client);
InputOutput
{ pattern: string, glob?: string, maxMatches?: number }Array<{ path, line, snippet }>

Probes for rg (command -v rg) and uses it when available; falls back to grep -rn. Both produce the canonical path:line:content format the parser consumes.

maxMatches defaults to 100 to keep the LLM-visible payload tractable. Raise it when the model needs a wider sweep, but expect context pressure to climb proportionally. The shell pipeline uses | head -n N to cap upstream — the parser doesn't have to.

Pattern is treated as a regex; escape metacharacters for literal matching.

list_files

const listFiles = createListFilesTool(session.client);
InputOutput
{ dir: string, recursive?: boolean, glob?: string }Array<{ path, kind: "file" | "dir", size? }>

Invokes find <dir> [-maxdepth 1] [-name <glob>] -printf '%y\t%s\t%p\n'.

recursive: false (default) limits to immediate children via -maxdepth 1. glob matches the basename only — consumers wanting full-path glob semantics should post-filter the result.

Symlinks and devices are dropped from the result so the LLM sees a clean two-kind axis (file / dir).

run_tests

const runTests = createRunTestsTool(session.client);
InputOutput
{ command?: string, cwd?: string, timeout?: number }{ exitCode, stdout, stderr, durationMs, passed?, failed?, skipped? }

Default command is npm test. The output parser is a best-effort heuristic across five runners:

  • jest / vitestTests: N failed, M skipped, K passed, T total
  • pytest===== 3 passed, 1 failed, 2 skipped in 0.05s =====
  • cargo testtest result: ok. 5 passed; 0 failed; 1 ignored;
  • go test verbose — counts --- PASS: / --- FAIL: / --- SKIP: lines

The parser deliberately refuses to invent counts when no signature matched. passed / failed / skipped are OMITTED rather than reported as zero — by design. Downstream code should treat undefined as "no signal" and not "no tests".

import { parseTestRunnerOutput } from "@pleach/coding-agent/tools";

// Exposed for callers who want to parse output without driving the tool.
const counts = parseTestRunnerOutput(stdout + "\n" + stderr);

git_clone

const gitClone = createGitCloneTool(session.client);
InputOutput
{ url: string, dest?: string, depth?: number, branch?: string }{ commitSha, branch, path }

Defaults: destination = repo basename (strips .git suffix and trailing slash); no --depth cap unless supplied. Clones, then runs git rev-parse HEAD and git rev-parse --abbrev-ref HEAD in the cloned directory to surface the resolved SHA and branch.

The tool throws on any non-zero exit code — git_clone is a setup step; the LLM sees an exception, not a { exitCode: 1, ... } row.

git_diff

const gitDiff = createGitDiffTool(session.client);
InputOutput
{ cwd: string, paths?: string[], staged?: boolean }{ diff: string, files: Array<{ path, additions, deletions }> }

Runs git diff [--staged] [--numstat] [-- <paths>] twice in parallel — once for --numstat to get the per-file counts, once for the raw diff text. Binary files surface as additions: -1, deletions: -1 (git emits - instead of numeric counts for binaries; the tool preserves the signal as a negative sentinel rather than dropping the file).

import { parseNumstat } from "@pleach/coding-agent/tools";

// Exposed for callers who want to parse `git diff --numstat` output
// without driving the tool.
const files = parseNumstat(numstatStdout);

Output-shape stability

All eight tools return validated outputs through Zod schemas (defineTool). Schema drift between an LLM-emitted output and the declared outputSchema is caught at the tool boundary — the runtime throws before downstream code sees the malformed shape.

When you wire your own variant of one of these tools (a custom read_file with rate limiting, for instance), use the published schema as the floor — match the input/output shapes and the LLM's existing tool-use grammar continues to apply.

Where to go next

On this page