Skip to content
7 min read · 1,499 words

Agent Tools

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 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.

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 }