M3.7
The core tool families
748 words · about 5 min
Concept
Five families cover nearly every coding agent: filesystem, shell/subprocess, search, web fetch, and structured editing. Each has a characteristic hazard that its design must answer.
Mechanism and worked examples
MechanismFilesystem (packages/fs/). The hazard is stale writes: the model reads a
file, reasons about it, and writes back — but something changed in between.
dsh splits this across packages by role: fs (the interface), fs-local (the
backend), fs-sandbox (path policy), fs-observation-policy (the read-before-write
gate), and tool-fs (the model-facing tools). The observation policy is an
fs/* event gate that adds no schema change — the read-before-write
requirement is invisible in the tool schema and enforced at the boundary. That
placement is deliberate: a deployment can drop the policy plugin without editing
any tool.
Shell / subprocess (packages/shell/, packages/subprocess/). The hazard is
unbounded, stateful, ambient authority. dsh's answers are visible in the bash
schema and description: a fresh shell per call (no ambient cwd or variable state
to corrupt), workdir as a parameter instead of cd, timeoutMs, tail
truncation with spill, run_in_background returning a job id, and sandbox denials
reported as policy rather than as command errors. Note that the shipped surface
includes both one-shot (tool-bash, tool-pwsh) and persistent
(tool-bash-persistent, tool-pwsh-persistent) variants — the persistence
tradeoff is a deployment choice, not baked into one tool. pwsh mirrors bash
call-for-call, which is itself a schema-design lesson: cross-platform siblings
should be identical in shape so the model's learned calling pattern transfers.
Search (packages/fs/tool-fs-search/). The hazard is result explosion. The
constants in search-core.ts show a layered defense: RAW_OUTPUT_MAX_BYTES of
20 MB on the subprocess pipe, SEARCH_TIMEOUT_MS of 30 seconds, a 3-second grace
period, a 64 KB stderr cap, and a 65 KB metadata cap. Exceeding the raw pipe cap
fails as SEARCH_RAW_OUTPUT_OVERFLOW rather than silently returning partial
matches — the distinction between "here are 250 of 900 matches" (a capped success)
and "the search produced more raw output than can be processed" (a failure) is one
the model must be able to make.
Web fetch (packages/web/). The hazard is untrusted remote content injected
into context. Fetched HTML is arbitrary text from the open internet, arriving in
a user-role message. dsh splits the transport (web-fetch-http) from the search
providers (web-search-deepseek, -exa, -perplexity) behind one web service,
so a deployment picks providers without changing the model-facing tools. The
MAX_CONVERSION_DEPTH = 512 guard on HTML-to-text conversion is a reminder that a
hostile page is an adversarial input to your parser, not just to your model.
Structured editing (packages/fs/tool-fs/, packages/fs/tool-str-replace-editor/).
The hazard is ambiguous or destructive application. The shipped edit schema is
worth reading as a complete design:
edit(file_path, old_string, new_string, replace_all?)
old_string — "Literal text to replace. Must match exactly."
new_string — "Use an empty string to delete the match."
replace_all — "Replace all matches. Defaults to false;
when false, old_string must appear exactly once."Every clause closes a failure. Literal rules out regex ambiguity. Must match
exactly sets the expectation before the failure occurs. Empty string to delete
answers a question the model would otherwise resolve by inventing a delete: true
parameter. And when false, old_string must appear exactly once is the key
safety property: an ambiguous match is an error, not a coin flip. The alternative
design — line-range edits — is worse precisely because line numbers go stale the
moment anything above them changes.
That dsh ships a second editor package (tool-str-replace-editor) alongside is
itself the honest observation: the field has not settled on one edit format, so a
harness benefits from being able to swap it.
Tradeoffs
bash can do everything the other four families do. So why ship the others at all?
Because a specialized tool can enforce policy the shell cannot: read can enforce
observation and truncation, edit can enforce uniqueness, grep can enforce a
result cap. Shipping only bash means shipping zero enforceable guarantees. The
opposite error is a tool per operation, which floods the prefix. The shipped
surface — roughly a dozen tools plus bash as the escape hatch — is the practical
middle.
How others differ
Cline's surface (bash, editor, read_files, apply_patch, search,
fetch_web, ask_question) is nearly a one-to-one mapping onto the same five
families, plus a user-question tool. The convergence across independent harnesses
is strong evidence the taxonomy is real. ask_question is the interesting
divergence: it makes "ask the human" a tool call rather than a loop-level
interrupt. Goose diverges hardest by making every tool an MCP server, including
the built-ins — uniform extensibility bought with per-call IPC overhead.
Check yourself
1 question on M3.7