A Coding Agent That Edits and Tests

Assemble a sandboxed agent that edits a repo, runs its test suite, and reports whether tests pass.

This recipe builds the canonical coding task: an agent that reads a repo, makes a small change, runs the test suite, and reports whether it passed. You mount the repo read-write under just-bash, give the agent the full file + shell toolset, fence it with safety plugins, and bolt a postSuccess step that runs the tests after the agent finishes.

Level 3 of 7. It assumes Level 2 — Search a codebase, where the agent had read-only tools. Here you add write + shell, a real sandbox mount, run-level stateful plugins, and a closing test step.

The shape

ElementChoice
Sandboxjust-bash, repo mounted readwrite
Toolsls, grep, read, edit, write, bash
Safety (stateless)bashBlocklistPlugin() on spec.plugins
Safety (stateful)loopDetectPlugin({ window: 3 }) per run via options.plugins
Optional fencewritePolicyPlugin({ scopedWritePaths })
Closing steplifecycle.postSuccess → run the test suite
Budgetquota — turns, cost, wall-clock
  1. Define the spec

    The output is the contract: which files changed, a summary, and the test result. The postSuccess step is a literal prompt block telling the agent to run the suite once it believes it is done.

    coding-agent.ts
    ts
    import { defineAgent } from "@drover/core";
    import { bashBlocklistPlugin } from "@drover/plugins";
    import { Type } from "@sinclair/typebox";
    
    export const codingAgent = defineAgent({
      id: "coding-agent",
      description: "Implements a small repo change and makes tests pass.",
      systemPrompt: [
        "You edit a code repository to satisfy a task.",
        "Read before you write. Make the smallest change that works.",
        "Use `bash` to run the project's tooling. Do not invent commands —",
        "inspect package.json / Makefile / pyproject first.",
      ].join("\n"),
      inputSchema: Type.Object({
        task: Type.String(),
      }),
      outputSchema: Type.Object({
        changed_files: Type.Array(Type.String()),
        summary: Type.String(),
        tests_pass: Type.Boolean(),
      }),
      // Coding wants a stronger model than the rest of this ladder.
      // `mini` works for trivial edits; reach for `sonnet` for real diffs.
      model: "sonnet",
      tools: ["ls", "grep", "read", "edit", "write", "bash"],
      // Stateless → safe on the spec. Reused by reference across runs is fine.
      plugins: [bashBlocklistPlugin()],
      lifecycle: {
        postSuccess: [
          {
            kind: "prompt",
            text: [
              "Run the project's test suite now.",
              "Report the exact command you ran and whether it passed.",
            ].join(" "),
          },
        ],
      },
      quota: { maxTurns: 25, maxCostUsd: 1, maxDurationMs: 300_000 },
    });
  2. Mount the repo read-write

    The default sandbox already mounts the run’s cwd read-write. Construct it explicitly when you want to mount a path other than cwd, fence the toolchain, or set a per-call timeout.

    coding-agent.ts
    ts
    import { createJustBashSandbox } from "@drover/sandbox-just-bash";
    
    const repo = "/work/my-project";
    
    const sandbox = createJustBashSandbox({
      mounts: [{ source: repo, target: repo, mode: "readwrite" }],
      timeoutMs: 120_000, // test runs can be slow
    });

    Writes inside the mount hit disk. The agent cannot escape the mount root — no .., absolute, or symlink traversal reaches outside source. An unmounted path does not exist.

  3. Construct stateful plugins per run

    loopDetectPlugin keeps a per-run counter of consecutive identical tool calls. stepTracerPlugin accumulates a steps array. Both are stateful.

    bashBlocklistPlugin is config-only (no per-run accumulator), so it stays on spec.plugins. The loop detector and tracer are built inside the run function:

    coding-agent.ts
    ts
    import { runAgent } from "@drover/facade";
    import { loopDetectPlugin, stepTracerPlugin } from "@drover/plugins";
    
    export async function runCodingTask(task: string) {
      // fresh state each call
      const tracer = stepTracerPlugin();
    
      const handle = runAgent(codingAgent, { task }, {
        cwd: repo,
        sandbox,
        plugins: [
          loopDetectPlugin({ window: 3 }), // deny after 3 identical calls
          tracer.plugin,                   // observe every step
        ],
      });
    
      const result = await handle.result;
      return { result, steps: tracer.steps };
    }

    Optionally fence writes tighter than the mount — useful when the mount is broad but the agent should only touch one subtree:

    ts
    import { writePolicyPlugin } from "@drover/plugins";
    
    // write-policy is stateless → could live on spec.plugins,
    // or pass per run alongside the others:
    writePolicyPlugin({ scopedWritePaths: [`${repo}/src`, `${repo}/test`] });
  4. Run and read the result

    The promise never rejects — inspect result.status. output is defined only on success. On quota the agent ran out of turns, cost, or wall-clock; on error a tool or lifecycle step failed.

    coding-agent.ts
    ts
    const { result, steps } = await runCodingTask(
      "Add a `slugify` helper to src/strings.ts and a test for it.",
    );
    
    console.log(`status: ${result.status}`);
    console.log(`steps observed: ${steps.length}`);
    console.log(`turns: ${result.turns}  cost: $${result.usage.costUsd}`);
    
    if (result.status === "success" && result.output) {
      const { changed_files, summary, tests_pass } = result.output;
      console.log(`tests_pass: ${tests_pass}`);
      console.log(`changed: ${changed_files.join(", ")}`);
      console.log(summary);
    }

How postSuccess fits

postSuccess runs only when the terminal status is success — skipped on quota, cancelled, error, paused. It is not an unconditional finally.

Two consequences matter here:

  • Output is captured before postSuccess runs. The closing test turn cannot rewrite changed_files / summary / tests_pass after the fact. For the result to reflect the suite, the agent must run tests during the main loop and set tests_pass from what it saw. Treat the postSuccess step as a belt-and-braces re-run + report, not the source of truth.
  • It shares quota.maxDurationMs. The wall-clock timer stays armed across init, the loop, and postSuccess — a slow suite can trip the budget.

If you want the host to drive the exact test command (not trust the model to pick it), swap the prompt step for a command step and add the command id to spec.commands. See Lifecycle and Commands.

Observing the run

tracer.steps gives a flat after-the-fact trace. For live progress, iterate handle.events — a discriminated union on .kind:

ts
const handle = runAgent(codingAgent, { task }, { cwd: repo, sandbox, plugins });

for await (const e of handle.events) {
  if (e.kind === "tool_call_start") console.log(`→ ${e.toolName}`);
  if (e.kind === "assistant_text") console.log(e.text);
  if (e.kind === "error") console.error(e.message);
}

See Events and streams for the full kind list.

What you assembled

  • just-bash sandbox, readwrite mount — a real boundary; the agent reaches only the mounted repo. capabilities.shell: true means bash composes with no opt-in.
  • ls / grep / read / edit / write / bash — the full read + write + shell toolset a coding task needs.
  • bashBlocklistPlugin() on spec.plugins — stateless, denies rm -rf /, sudo, curl|sh, fork bombs.
  • loopDetectPlugin({ window: 3 }) per run — stateful; built fresh in options.plugins so its counter never leaks between runs.
  • stepTracerPlugin() per run — stateful; collects steps for observability.
  • writePolicyPlugin (optional) — fences writes tighter than the mount.
  • lifecycle.postSuccess prompt step — re-runs the suite and reports, only on success, before output is locked, inside the duration budget.
  • quotamaxTurns: 25, maxCostUsd: 1, maxDurationMs. A coding loop is the most expensive recipe on this ladder; cap it.

Level up

Type to search…

↑↓ navigate open esc close