Certified Agent Harness Engineer
Course sections

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

Mechanism

Filesystem (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

Start drill