---
url: 'https://adk.nht.io/batteries/dev-tools/forge.md'
description: >-
  Two surfaces, no gate option, and a granular tool that writes before it
  returns — because a stateless tool that pretends to hold a workspace is lying
  to the model.
---

# Agent Tools

## LLM summary — Dev Tools forge

* `forgeDevTools(pipeline, { surface, overrides })` from `@nhtio/adk/batteries/dev-tools/forge` → `Record<string, Tool>`.
* NO `gate` option: the PIPELINE owns the gate and calls it once per step, so a forged tool gates identically to a direct call. A second gate would double-prompt or suppress one. This diverges from the media forge deliberately. A pipeline built without a gate throws at construction.
* Composite: `dev_plan {paths, ops}` — no `q`, there is no pipe grammar. Granular: one tool per step, schema from the arg spec, producing native ARRAY parameters.
* Granular tools are STATELESS: each acquires, runs one step, returns. No session handle, no workspace between calls. Consequence: acquisition gates on EVERY granular call.
* A granular invocation whose step can produce a `WorkspaceDelta` runs `⟨step⟩ → write` — stated by EFFECT, not by name: `edit`, `apply_patch`, `format`, `lint`. `read_lines` and `check` cannot mutate and get no write. `write` is NOT forged granularly at all — standalone it would acquire a clean workspace and persist nothing.
* Gate fires EXACTLY TWICE for a granular mutating tool: `acquire`, then the step whose `args` carry the synthesized `persists: true`. No third write gate. This is the ONLY exception to "every write gates first" — the full A6b write path still runs, minus the separate gate.
* `args` is authored input verbatim; `targets` is the runtime's resolution. `apply_patch` parses FIRST, so `targets` names both endpoints of a move and `mayCreate` lists concrete paths; an unparseable patch throws with no gate call.
* Every granular tool returns the same `DevResult`, spooled as `SpooledJsonArtifact`: summary inline, full diagnostics behind a handle. Git-style change summary scoped to files that CHANGED, not touched. Line counts need the optional `diff` peer; when absent they are OMITTED (not zeroed) and `lineCountsAvailable: false` says so.
* Two-tier narrowing: tier 1 declarative `requires`, checked with the omitted-argument probe — unmet means the step is NOT ADVERTISED. Tier 2 runtime: unmatched extensions are an info-diagnostic NO-OP, not a failure. Builder methods exist on the type and throw when unconfigured; the TOOL surface narrows properly, which is where it matters.

There is no `gate` option on `forgeDevTools`, and that absence is the most important thing on this page. The pipeline owns the gate. It calls it once per step, so a forged tool's invocation gates exactly as a direct builder call does. Adding a second gate at the forge would either prompt twice for one action or silently swallow one of the two, and both are worse than having one owner.

The media forge does gate at the forge, because its pipeline has no gate concept. The divergence is deliberate, and it is stated here rather than left for you to discover by counting prompts.

The dev tools pipeline is an engine-backed execution core; `forgeDevTools` turns that core into agent-facing tools for the ADK turn loop. It exports a single factory function that accepts a configured pipeline and emits ready-to-register [`Tool`](https://adk.nht.io/api/@nhtio/adk/forge/classes/Tool) records.

```typescript
import { createDevPipeline } from '@nhtio/adk/batteries/dev-tools'
import { forgeDevTools } from '@nhtio/adk/batteries/dev-tools/forge'

const dp = await createDevPipeline({
  handle,
  fileSystem,
  pathTranslator,
  gate,
  root: '/workspace',
  engines: [biomeEngine(), typescriptEngine()],
})

const tools = forgeDevTools(dp, { surface: 'granular' })
// -> { read_lines, edit, apply_patch, format, lint, check } -- note: no write
```

Notice what is missing from the forge options: there is no `gate` parameter. The media forge accepts a gate because media pipelines have no concept of operation gating; here, the pipeline owns the gate and enforces it across every execution path. Putting a second gate on the forge would either prompt an operator twice for the same file edit or silently hide one of the two checks. A pipeline constructed without a gate throws immediately at construction; the forge trusts the pipeline's gate and adds nothing in front of it.

## Two surfaces, inverted recommendations

The forge provides two surface layouts:

```typescript
// Granular: one tool per step (default)
const granularTools = forgeDevTools(dp, { surface: 'granular' })

// Composite: one multi-step execution tool
const compositeTools = forgeDevTools(dp, { surface: 'composite' })
// -> { dev_plan }
```

**`granular`** produces individual tools matching available pipeline operations: `read_lines`, `edit`, `apply_patch`, `format`, `lint`, and `check`. Schemas are generated directly from each step's argument specification. This gives models native JSON array parameters—such as an array of exact replacement blocks for `edit`—guaranteeing lossless input without string-encoding acrobatics.

**`composite`** exposes a single tool, `dev_plan`, taking `{ paths, ops }`. Unlike the media pipeline, there is no pipe DSL grammar (`verb name=value`). Code operations require arbitrary string content, raw newlines, regex literals, and multi-line replacements; shoving those through a pipe string grammar is an invitation to escape-character corruption. `dev_plan` accepts structured operation objects directly.

::: info Inverted recommendation for code tooling
In media processing, composite tools are recommended because chaining format transforms in one prompt round-trip saves tokens and latency. For dev tools, the recommendation is inverted: **use `granular` tools by default**, especially for small models.

Small models handle flat, single-purpose tool schemas with typed arrays far more reliably than constructing nested operation trees. `dev_plan` exists for frontier models capable of orchestrating multi-step mutations without paying the multi-turn round-trip penalty.
:::

## The stateless tool and implicit persistence

Every granular tool call runs a complete, isolated, one-step plan: it acquires target paths, runs its operation, and returns. There is no hidden session handle and no persistent workspace carried between tool calls.

A persistent workspace between agent turns would require a lifetime management policy, an eviction cache, and an answer for what happens when a model turns off mid-refactor. None of those belong in a stateless harness. Furthermore, an implicit acquisition mechanism would prevent the model from controlling or inspecting what files enter the workspace.

Stateless execution forces an unambiguous rule: **any granular tool whose step can mutate workspace content must persist before it returns.**

If a granular `edit` tool did not persist, its in-memory changes would evaporate the moment the call returned. A subsequent granular `write` would acquire a clean workspace from the filesystem, see zero dirty files, and do nothing. To prevent this, every granular step that can produce a `WorkspaceDelta` runs `⟨step⟩ → write` internally.

This persistence rule is keyed by effect, not by name:

* `edit` and `apply_patch` produce deltas and persist to disk before returning. This matches the standard agent editing contract: invoking an edit mutates the target file on disk immediately.
* `format` and `lint` (when configured with a `fix` option) produce deltas and persist to disk before returning. A non-fixing lint run produces no delta and writes nothing.
* `read_lines` and `check` are strictly diagnostic steps; they cannot produce deltas and never write.
* **`write` is never forged as a granular tool.** A standalone `write` tool would acquire an unmodified workspace, find nothing dirty, and write zero bytes. `write` exists solely as a step within composite `dev_plan` calls, where a workspace survives across sequential operations.

## The gate sequence: exactly two prompts

A granular mutating tool executes two distinct phases: acquisition and mutation. Consequently, the operator gate is called **exactly twice**:

1. Once for `acquire`, specifying the resolved target paths to load.
2. Once for the mutating step (`edit`, `apply_patch`, `format`, `lint`), carrying the persistence payload.

There is no third synthetic gate call for the trailing `write`. Requiring three approval prompts for a single `edit` tool call creates operator approval fatigue, destroying the safety guarantees gates exist to provide.

The gate payload explicitly informs the operator that persistence is bundled into the step:

```typescript
// Gate payload for granular edit
{
  step: 'edit',
  args: { ...validatedArgs, persists: true },
  targets: ['src/index.ts'],
  mayCreate: [],
  engines: undefined
}
```

`persists: true` is the only synthesized argument in the entire pipeline runtime; all other `args` fields reflect authored user input verbatim. This flag allows security policies and UI renderers to distinguish a self-persisting granular tool call from an intermediate step inside `dev_plan`.

Granular persistence executes the full workspace write path—path translation, workspace containment checks, file-system `canWrite` verification, parent directory creation, symlink traversal prevention, ordered writes, and state bookkeeping. The only step bypassed is the redundant second gate invocation, because the mutating step's gate already authorized the disk modification.

## `targets` versus `args`

Gate evaluation strictly separates caller-authored input from runtime resolution:

* `args` contains the authored input verbatim.
* `targets` contains the runtime's resolved host file paths.

When a step runs against default selectors, `args` remains `{}` while `targets` contains the resolved list of forty files to be processed. Combining these fields would make it impossible for a security gate to determine whether the model explicitly targeted a sensitive file or whether a broad selector resolved to it.

```typescript
// apply_patch parses before gating
{
  step: 'apply_patch',
  args: { patch: '*** src/old.ts\n--- src/new.ts\n...' },
  targets: ['src/old.ts', 'src/new.ts'],
  mayCreate: ['src/new.ts'],
  engines: undefined
}
```

For `apply_patch`, the patch is parsed and validated before the gate fires. `targets` lists every concrete path referenced by the patch (including both source and destination endpoints of file moves). `mayCreate` lists exact creation destinations, never ambiguous glob patterns. If a patch contains syntax errors or malformed diff headers, the pipeline throws immediately without invoking the gate.

## Result rendering: SpooledJsonArtifact

Every granular tool returns a uniform `DevResult` structure rendered as a `SpooledJsonArtifact`. Tools do not return divergent, per-step payload shapes; one standard output contract serves all operations.

Large operations, such as a whole-workspace `check`, can generate megabytes of type diagnostics. Inlining these outputs directly into the turn context exhausts the model's context window. `SpooledJsonArtifact` places the full diagnostic list behind an artifact reference handle while returning an inline summary containing execution status (`ok`), diagnostic counts categorized by severity, and a git-style change summary.

```text
2 files changed, 7 insertions(+), 3 deletions(-)
 src/index.ts   | 5 ++++-
 src/util.ts    | 5 ++++--
```

The change summary tracks files that were actually modified, ignoring clean files that were merely inspected. When a formatter evaluates fifty files and modifies two, the inline summary reports two changed files. The model learns the knock-on effects of its edits without context-flooding; full file contents remain accessible on demand via `read_lines`.

Diff statistics depend on the optional `diff` peer dependency. If the package is not installed, line counts are omitted from the summary entirely—never emitted as `0`, which would falsely indicate zero net changes—and `lineCountsAvailable: false` is recorded in the result metadata. The pipeline refuses to fail an edit operation simply because an optional formatting diff utility is absent.

## Two-tier capability narrowing

The tools exposed to a model reflect the actual capabilities of configured engines. Narrowing operates across two tiers:

```typescript
// Step advertisement depends on configured engines
const dp = await createDevPipeline({
  handle,
  fileSystem,
  pathTranslator,
  gate,
  root: '/workspace',
  engines: [biomeEngine()], // provides format and lint, but no check
})

const tools = forgeDevTools(dp, { surface: 'granular' })
// 'check' is omitted from returned tools; 'format' and 'lint' are present
```

**Tier 1: Declarative filtering.** Each step declares engine requirements via `requires`. During tool minting, the forge executes an omitted-argument capability probe ("is any engine configured for formatting?", not "is an engine configured for this specific file extension?"). If no engine satisfies the requirement, the tool is omitted entirely from the granular tool set and excluded from composite schemas. If a model attempts to invoke an unadvertised step explicitly, the pipeline throws an error containing available alternatives and an explicit do-not-retry instruction.

**Tier 2: Runtime file negotiation.** Dynamic, extension-specific capabilities are evaluated inside the step when files are processed. If an operation runs across a heterogeneous workspace where some file extensions lack engine support, the unsupported files are skipped as a no-op with an informational diagnostic. Running `format` across a mixed repository is normal behavior; throwing a step failure for unhandled file extensions would render broad file selectors unusable. A step fails only when an engine encounters a genuine execution error.

On the static TypeScript builder, method names remain available on the interface but throw when invoked without engine backing. TypeScript cannot narrow static method signatures based on runtime engine arrays. Throwing a runtime error with a clear configuration message provides better DX than artificially mutating builder prototypes. The agent tool surface, where model visibility matters, narrows cleanly.

## Overrides

The `overrides` option allows tools to be renamed or re-described when adapting to specific harness conventions:

```typescript
const tools = forgeDevTools(dp, {
  surface: 'granular',
  overrides: {
    edit: {
      name: 'apply_text_edits',
      description: 'Apply exact line replacements to workspace files.',
    },
  },
})
// -> { read_lines, apply_text_edits, apply_patch, format, lint, check }
```
