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.
| Tool | Class | What it does |
|---|---|---|
read_file | read | Read a workspace file. Returns { content, bytes }. |
write_file | read | Write a workspace file. Returns { bytesWritten }. |
apply_diff | read | Apply a unified diff via POSIX patch -p0. Returns { hunksApplied, conflicts }. |
search_files | read | Pattern-match across the workspace. Uses rg when available, falls back to grep -rn. |
list_files | read | Enumerate via find with -printf '%y\t%s\t%p\n'. |
run_tests | exec | Run a test command; parse jest / vitest / pytest / cargo / go test output. |
git_clone | exec | git clone --depth 1 <url> + resolve commit SHA + branch. |
git_diff | exec | git 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);| Input | Output |
|---|---|
{ 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);| Input | Output |
|---|---|
{ 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);| Input | Output |
|---|---|
{ 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);| Input | Output |
|---|---|
{ 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);| Input | Output |
|---|---|
{ 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);| Input | Output |
|---|---|
{ 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 / vitest —
Tests: N failed, M skipped, K passed, T total - pytest —
===== 3 passed, 1 failed, 2 skipped in 0.05s ===== - cargo test —
test 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);| Input | Output |
|---|---|
{ 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);| Input | Output |
|---|---|
{ 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
Sandbox providers
The SandboxProvider facade over @pleach/sandbox's three endpoint-shape adapters, plus the SandboxComposite high-level tool surface coding agents reach for.
Multi-synthesize per turn
The per-runtime maxSynthesizePerTurn knob — contract widened in @pleach/core, default policy shipped in @pleach/coding-agent. Host support depends on the counter implementation.