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
| Element | Choice |
|---|---|
| Sandbox | just-bash, repo mounted readwrite |
| Tools | ls, grep, read, edit, write, bash |
| Safety (stateless) | bashBlocklistPlugin() on spec.plugins |
| Safety (stateful) | loopDetectPlugin({ window: 3 }) per run via options.plugins |
| Optional fence | writePolicyPlugin({ scopedWritePaths }) |
| Closing step | lifecycle.postSuccess → run the test suite |
| Budget | quota — turns, cost, wall-clock |
-
Define the spec
The output is the contract: which files changed, a summary, and the test result. The
postSuccessstep is a literalpromptblock telling the agent to run the suite once it believes it is done.coding-agent.tstsimport { 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 }, }); -
Mount the repo read-write
The default sandbox already mounts the run’s
cwdread-write. Construct it explicitly when you want to mount a path other thancwd, fence the toolchain, or set a per-call timeout.coding-agent.tstsimport { 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 outsidesource. An unmounted path does not exist. -
Construct stateful plugins per run
loopDetectPluginkeeps a per-run counter of consecutive identical tool calls.stepTracerPluginaccumulates astepsarray. Both are stateful.bashBlocklistPluginis config-only (no per-run accumulator), so it stays onspec.plugins. The loop detector and tracer are built inside the run function:coding-agent.tstsimport { 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`] }); -
Run and read the result
The promise never rejects — inspect
result.status.outputis defined only onsuccess. Onquotathe agent ran out of turns, cost, or wall-clock; onerrora tool or lifecycle step failed.coding-agent.tstsconst { 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
postSuccessruns. The closing test turn cannot rewritechanged_files/summary/tests_passafter the fact. For the result to reflect the suite, the agent must run tests during the main loop and settests_passfrom what it saw. Treat thepostSuccessstep as a belt-and-braces re-run + report, not the source of truth. - It shares
quota.maxDurationMs. The wall-clock timer stays armed acrossinit, the loop, andpostSuccess— 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:
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-bashsandbox,readwritemount — a real boundary; the agent reaches only the mounted repo.capabilities.shell: truemeansbashcomposes with no opt-in.ls/grep/read/edit/write/bash— the full read + write + shell toolset a coding task needs.bashBlocklistPlugin()onspec.plugins— stateless, deniesrm -rf /,sudo,curl|sh, fork bombs.loopDetectPlugin({ window: 3 })per run — stateful; built fresh inoptions.pluginsso its counter never leaks between runs.stepTracerPlugin()per run — stateful; collectsstepsfor observability.writePolicyPlugin(optional) — fences writes tighter than the mount.lifecycle.postSuccessprompt step — re-runs the suite and reports, only on success, before output is locked, inside the duration budget.quota—maxTurns: 25,maxCostUsd: 1,maxDurationMs. A coding loop is the most expensive recipe on this ladder; cap it.