Autonomous coding agents that run inside Firecracker microVMs, fix failing builds and issues, and open a pull request with the evidence attached.
In Norse myth, Brokkr forged Mjölnir while Loki, as a fly, stung him to spoil the work. The hammer came out whole anyway. That is the design brief: the agent's code is untrusted, the task may be adversarial, and the result still has to be sound.
A patch is only evidence if the verifier can be shown to reject a broken one.
Status: early. The verifier and a first agent work on small fixture tasks with a local 7B model. Nothing below is claimed unless a script on disk reproduces it.
- Go control plane. Task intake (failed CI jobs, GitHub issues), queue, sandbox scheduling, the approval gate, and PRs opened as drafts.
- Rust sandbox runner. Boots and restores Firecracker microVMs and runs the agent inside the guest behind seccomp, cgroup quotas, per-task network policy and short-lived credentials.
- Verify before anything leaves the sandbox. Tests, type checks, lint and a security scan. Deliberately broken patches (negative controls) must fail them before a green result counts.
- An evidence bundle for every run. Repro command, diff, logs, trace and hashes, enough for a reviewer to re-run the verdict.
- Humans approve every merge and every destructive action. The agent can only propose.
- Pluggable models. Local (Ollama, vLLM) or hosted, routed by task size.
| Gate | What it proves | Instrument |
|---|---|---|
| 0 | This host can boot a Firecracker microVM; the check fails without /dev/kvm |
scripts/kvm-smoke.sh |
| 1 | The verifier goes red: of six patches, only the real fix passes | scripts/gate1.sh |
Gate 1 on the reference machine:
| Case | What the patch does | Verdict | Caught by |
|---|---|---|---|
| none | nothing; the bug is still there | FAIL | tests fail |
| good | the real fix | PASS | (none) |
| broken | a plausible fix that is wrong for most inputs | FAIL | tests fail |
| tamper | edits the tests to expect the buggy value | REJECTED | protected files changed; no VM is booted |
| early-exit | calls os._exit(0) on import, before any test runs |
FAIL | required tests missing |
| forge | tries to overwrite the sandbox result and reach the network | FAIL | write denied, network unreachable, tests fail |
The early-exit case is the reason the required-test rule exists. Its test command
exits 0 with no output, so a verifier that only looks at the exit code, the usual
approach, would record a pass. Remove the rule (required_tests: []) and it does.
- The repository is copied and the patch applied with
git apply, which refuses absolute paths and... - Every file under the task's protected paths is hashed before and after. Any difference rejects the patch, whatever its diff headers say.
- The patched copy goes onto a throwaway ext4 drive and a fresh microVM boots
with it.
brokkr-init(PID 1, in the read-only root image) runs the test command asnobody, with no network device, under a timeout. - Only
brokkr-initcan write the result, and the runner reads it from the drive after the VM has exited. - PASS requires exit 0 and a reported pass for every required test.
evidence.jsonrecords the verdict, the reasons, hashes of the repo, patch and patched tree, the per-test results, the kernel, rootfs and Firecracker versions, and the machine it ran on.
brokkr fix gives a model a task (an issue, and for practice tasks the failing
output) and these tools:
| Tool | What it does | Runs code? |
|---|---|---|
list_dir, search, read_file |
look around a large repository: one directory at a time, regex search (50 hits), line ranges (300 lines) | no |
replace_in_file |
exact text replacement, tolerant of a uniformly wrong indent | no |
replace_lines |
replace a numbered line range (0.5+) | no |
run_tests |
runs the tests on the current diff in a fresh microVM; on SWE-bench tasks, only the visible ones | in the sandbox only |
run_python |
runs a Python program from the repository root in a fresh microVM (to reproduce the issue); never part of the diff | in the sandbox only |
submit |
ends the run, with the agent's claim that it fixed the task | no |
Edits refuse protected paths (the tests) and paths outside the repository. The
harness records its version (HarnessVersion in internal/agent/agent.go, with a
changelog), and results from different versions are never pooled. Other safeguards:
- Loops end the run, and it is scored. Six identical failing calls in a row, or
five identical calls with no edit in between, stop the run as
stuck(0.5, 0.6). - A claimed fix needs a reproduction. On hidden-test tasks,
fixed=truewith norun_pythonsince the last edit gets one reminder (0.4+). - Output that fails to parse is the model's failure. A malformed model reply is resampled twice and then scored as a failure, not excused as infrastructure.
The model never runs code on the host. When it stops, Brokkr takes the final diff
and verifies it again from scratch. That verdict is the result; the agent's claim is
recorded next to it, so over-claims (said fixed, wasn't) are counted rather than
hidden. Each run leaves transcript.jsonl, final.patch, the evidence for every
sandboxed test run, and summary.json.
Models are reached over the OpenAI-compatible chat API: Ollama, vLLM, llama.cpp or
a hosted provider (--model-url, BROKKR_API_KEY).
scripts/eval.sh runs the agent K times per task. Before a task is scored, it
checks that the unpatched repo FAILS and the reference patch PASSES; a task that
fails either check is reported invalid and excluded.
brokkr bundle --run RUN_DIR --out DIR turns a finished brokkr fix run into what
a reviewer needs:
patch.diff;pr.md: the issue title, the agent's own summary, whether its claim matches the verdict, the required tests passing, and hashes of every input (repo, patch, patched tree, hidden test patch, kernel, rootfs, env).
Brokkr never pushes, opens or merges anything. For a verified run it prints the
git apply and gh pr create --draft commands for a person to run. A run that did
not verify is marked NOT READY. That is the approval gate.
Benchmarks come with hidden tests. Real open issues do not. In live mode the agent
must also write the proof: a regression test in a new tests/test_brokkr_*.py
file. Brokkr then checks that proof itself, in two fresh microVMs:
| Run | Contents | What must happen |
|---|---|---|
| A | original code + the agent's new test file | the new test fails |
| B | original code + the whole patch | the new test passes, and every existing test that passed in A still passes |
A test that already passes without the fix proves nothing, so it cannot be the
evidence. Existing tests stay read-only: create_file makes new files only,
and under tests/ only a new test_brokkr_*.py. The self-test rejects any
other change to a protected path. If no existing test ran in A (a collection
error, say), the result is ERROR rather than PASS: "no regressions" among zero
tests means nothing.
/usr/bin/python3 scripts/gh/live_task.py issue pydantic/pydantic 13754 # in the VM
brokkr fix --task ~/.cache/brokkr-gh/live/pydantic__pydantic-13754/task.json ...
brokkr bundle --run RUN_DIR --out DIR # pr.md carries the self-test evidence
brokkr selftest --task T.json --repo DIR --patch P --out DIR # any patch, no modelChecked in real microVMs on pydantic#13692, with a hand-written test and the maintainers' fix:
| Patch | Verdict |
|---|---|
| fix and test | PASS: 1 new test fails before and passes after, no regressions |
| test only | FAIL: the new test does not pass |
| fix only | FAIL: no regression test |
For a live task at pydantic's HEAD, the regression set is pydantic's own suite
(tests/test_*.py): 4954 test ids in 93 s per run. live_task.py variant
makes a live copy of a historical task that keeps its hidden tests. A run of
that copy records both verdicts, which measures whether the agent's own test
agrees with the maintainers' tests.
The plan was snapshots (restore a booted microVM) and a pool of parallel VMs. Measurements on the M4 Mac changed it:
- Per-run overhead is small. A Django run spends about 2.8 s outside the
tests: 1.1 s boot, 0.9 s
chown, 0.6 s shutdown, 0.2 s host-side. A snapshot could save about 1 s of a roughly 50 s run, and restored VMs would share memory and random-number state. Not built. - Nested virtualization gives all microVMs about one core together. 8 CPU-bound microVMs each ran 8x slower. One microVM with 4 vCPUs ran 4 jobs no faster than with 1. Plain processes in the Lima VM do scale.
- So too many VMs at once only makes each one slower. This explained the 22 s median "overhead" in past runs, which ranged up to 90 s.
Brokkr now caps microVMs running at once, host-wide, across processes, with
lock files (BROKKR_SANDBOX_SLOTS, default 2 in scripts/env.sh). A run over
the cap waits, and the wait is recorded as sandbox_queue_ms, apart from the
VM time. 8 concurrent real Django verifies:
| Batch | VM time per run | Wait | |
|---|---|---|---|
| no cap | 457 s | 448 s | 0 s |
| 2 slots | 339 s | 82 s | 123 s |
On a bare-metal Linux server, raise the cap toward the core count. That is where a VM pool pays off.
Brokkr now verifies fixes in Go and C++ repositories from SWE-bench
Multilingual (scripts/ml/prepare.py). Its log parsers (gotest, cargo,
googletest, jq, redis, micropython) are ports of SWE-bench's own parsers,
checked against the originals' output on sample logs.
- Go: one environment drive per repository with every Go version and
module its tasks need, so the guest runs offline (
GOPROXY=off,GOTOOLCHAIN=local). A warm build cache is symlinked into the guest. That takes 3.5 s; copying it took 123 s. Building the cache compiles test binaries withgo test -cand never runs them outside a microVM. - C/C++: a second guest image with gcc 13, cmake, Tcl and autotools
(
scripts/build-rootfs-cc.sh), chosen per task ("rootfs"). Its hash is recorded in the evidence. The build runs inside the microVM.
Validity (unpatched FAILs, maintainers' fix PASSes):
| Repo | Tasks | Valid | VM time per gold run |
|---|---|---|---|
| gin-gonic/gin (Go) | 8 | 8 | 2.4–8.8 min |
| fmtlib/fmt (C++) | 2 checked of 11 built | 2 | 16–17 min (cold build on one shared core) |
Not done yet: the other Go repos (caddy, prometheus, hugo, terraform) need gigabytes of modules and the VM disk is nearly full; jq and redis build during SWE-bench's image setup, which is not in the dataset; Rust; any model run on these tasks.
brokkr replay --run RUN_DIR --out replay.html turns a finished brokkr fix
run into one self-contained HTML page with no network requests. It shows:
- the verdict, and the agent's claim against it, with over-claims highlighted;
- every turn, with tool calls, edits as diffs, and sandbox runs with their log tails;
- harness interventions;
- a chart of prompt size, a tools-over-time strip, the final patch and the evidence.
Run data is embedded as escaped JSON and drawn with textContent only, so
hostile transcript text cannot run script. --compare B shows two runs side
by side. Samples are in docs/replays/.
brokkr mutate --task T.json --repo DIR --patch P --out DIR breaks the fix in
small ways and runs only the agent's new tests against each broken version in
a fresh microVM. Mutations: revert a hunk, flip a comparison, swap and/or,
negate a condition, change an integer by one, return None, empty a string,
delete a statement.
- A mutant is killed when a new test that passed with the real fix no longer passes.
- Surviving mutants are listed in
mutate.md: they show what the test does not check. - On pydantic#13460, the maintainers' tests killed 8 of 8 non-equivalent mutants. A weaker test killed 7 of 8, missing the fallback for unknown types.
- A score measures how much of the fix the test pins down, not whether the fix is right.
brokkr bundleshows the score whenmutate.jsonis present.
brokkr minimize --task T.json --repo DIR --patch P --out DIR [--max-runs N] [--granularity hunk|line] shrinks a verified patch to the smallest one that
still verifies. It runs Zeller's ddmin over hunks (and with line, then over
changed lines), using the real verifier in fresh microVMs as the oracle:
- The full patch must PASS first.
- A subset that no longer applies counts as FAIL without booting a VM.
- Verdicts are cached by patch hash.
- If the run budget ends first, the best verified patch is returned, marked as not proven minimal.
Measured:
- A calc fix bundled with a debug print, a comment, an unused helper and a stray file went from 10 changed lines to the 2-line fix, in 15 VM runs.
- A past Codestral PASS patch on sympy-19637 went from 8 changed lines to 2.
- Line mode on real patches has not finished a run yet.
--code-tools adds find_definition, find_usages and outline. They are
answered from an ast index built with python3 on the host, which never
imports or runs repository code. After each .py edit, a static check
reports only problems the edit introduced: undefined names, use before
definition and duplicate arguments. Offline results:
- On the patched files of 209 historical tasks (285 files), the check reported nothing.
find_definitionfound 402 of 403 gold-modified functions by qualified name.find_usagespointed at another changed file in 22 of 33 multi-file fixes.
No model has been run with the tools yet.
brokkr fix --routes dev/routes.json sends the agent's model calls through a
router instead of a single model:
- Per task, pick by evidence. Backends are ordered by estimated pass rate on
the task's repository, from every scored run in
results/. A backend's record on this repository is shrunk toward its record on all real repositories, weighted as 4 runs, so one lucky run doesn't win. A repository nobody has tried is judged by each backend's general record. Backends that don't answer are skipped."objective": "pass_per_hour"also weighs speed. - Per call, fall back. A parked free tier, a rate limit, a 5xx or an unreachable server moves the conversation to the next backend. The switch happens mid-run, with no restart.
- Escalate. A conversation that outgrows a backend's window (the local model's 32K) moves up to a larger one (Codestral's 250K) instead of stopping. The router never moves a conversation into a window too small to hold it.
- Not routed around: a malformed reply (the model's failure, scored) and other 4xx errors (a bug in the request).
- Tool-call ids made by one backend are rewritten, only when another backend is sent the conversation, to the 9-character form every provider accepts. Mistral rejects Ollama's.
brokkr route --for owner/repo shows the evidence and the order. With
today's results:
| Repository | Local Qwen 3.5 9B | Codestral | Order |
|---|---|---|---|
| django/django | 12/23 (est. 52%) | 38/209 (18%) | Qwen, then Codestral |
| sympy/sympy | untried (52% from its record) | 9/75 (12%) | Qwen, then Codestral |
| pydantic/pydantic | untried (52%) | 6/14 (37%) | Qwen, then Codestral |
These estimates are for choosing a backend, not an evaluation: they pool
harness versions and task sets, which the evaluation never does. Every
routed run records served_by and each switch. A run served by more than one
backend is a mixed run, and report.py and compare.py leave it out of model
comparisons.
Hosted free tiers run through freetier, via a
small proxy on the Mac (tools/freetier_proxy.py). The agent in the VM talks to it as
an ordinary OpenAI-compatible endpoint and never holds a key.
- Pacing and one shared ledger. Every call is paced to the provider's published limits and booked in freetier's machine-wide ledger, shared with any other project on the machine that uses the same key.
- A spent allowance stops the eval; it is never scored. When the allowance is gone,
freetier parks the call instead of retrying into 429s. The proxy answers
budget_parkedwith the UTC time the allowance returns,brokkr fixexits 4, andscripts/eval.shstops, writesPARTIALand scores nothing from the parked run. - The key stays on the Mac. It comes from
BROKKR_UPSTREAM_API_KEYor the macOS Keychain, the proxy binds127.0.0.1only, and nothing logs the key.
security add-generic-password -s brokkr-upstream -a "$USER" -w # once; prompts for the key
BROKKR_UPSTREAM_URL=https://openrouter.ai/api/v1 tools/.venv/bin/python tools/freetier_proxy.py
# in the VM:
BROKKR_MODEL_URL=http://host.lima.internal:11500/v1 BROKKR_MODEL=<model> scripts/eval.shWhich free tier can carry an agent. Every turn resends the whole conversation, so an agent loop spends tokens quickly. On the five fixtures, 15 runs used 507K tokens (median 25K per run), and the largest single request was 7.2K tokens. Against freetier's limits table:
| Provider (free) | Limits | Fits? |
|---|---|---|
| Groq | 8K tokens/min, 200K tokens/day | No. 15 toy runs are 2.5 days of allowance, and real-repo requests exceed the per-minute cap, so they can never be sent |
| Cloudflare Workers AI | 10K neurons/day (about 300K input tokens) | No |
OpenRouter :free models |
20 req/min, 50 req/day; no token caps | About 4 runs a day: enough to test the setup |
OpenRouter :free, after $10 of credit has ever been bought |
20 req/min, 1000 req/day | About 90 runs a day: the only free route that fits |
Tests: tools/.venv/bin/python -m unittest tools/test_freetier_proxy.py runs with no key
and all outbound traffic routed to a dead address, using a throwaway ledger.
Snapshot taken 2026-09-27 12:52 local from results/REPORT.md (regenerate with
scripts/make-report.sh). The counts below are
final for Codestral; qwen3.5 is paused. Every verdict is Brokkr's own check against SWE-bench's hidden
tests. Held-out tasks were never used to shape the harness (results/swe/plan.json).
Django 4.x, held-out (82 valid tasks):
| Run | Verified | Notes |
|---|---|---|
| Codestral, harness 0.3.1 (pre-registered baseline) | 11 / 79 (14%) | complete. 29% of "<15 min" tasks, 1 of 36 "15 min–1 h", 0 of 8 harder. Claimed a fix 40 times; 30 were wrong |
| Codestral, harness 0.6.1 | 18 / 82 (22%) | complete |
| qwen3.5-9B, local on the Mac, harness 0.3.1 | 9 / 18 (50%) | paused after the first 18 tasks in pre-registered order |
Paired, on the same tasks (exact McNemar test):
- Codestral 0.6.1 vs 0.3.1: 0.6.1 18/79 vs 0.3.1 11/79 on 79 shared tasks (8–1 discordant, p = 0.039)
- qwen3.5-9B vs Codestral: qwen3.5-9B 9/18 vs Codestral 3/18 on 18 shared tasks (6–0 discordant, p = 0.031)
Neither comparison is below the Bonferroni threshold (α = 0.025 for 2 comparisons), though the direction is consistent. The qwen3.5 comparison covers only its first 18 tasks (paused) and was looked at repeatedly, so treat it as provisional.
SymPy, held-out (65 valid tasks): Codestral, harness 0.6.1: 7 / 65 (11%), complete.
Across both repositories, 167 of the 169 prepared tasks passed the validity check: Django 92/94 and SymPy 75/75. The Brokkr ports of SWE-bench's log parsers agree with the originals on every validation log (338 logs).
brokkr-qwen2.5-7b-16k (qwen2.5 7B Instruct, Q4, via Ollama, 16K context) on the
five fixtures, 3 runs each, temperature 0, on the reference machine. The same model
and tasks were run with the original exact-match edit tool (strict) and with the
indentation-tolerant one (reindent):
| Task | strict: PASS | reindent: PASS | reindent: over-claims |
|---|---|---|---|
| calc | 3/3 | 3/3 | 0 |
| inventory | 0/3 | 1/3 | 0 |
| lru | 0/3 | 2/3 | 1 |
| slugify | 0/3 | 0/3 | 3 |
| workdays | 0/3 | 0/3 | 1 |
| Total | 3/15 | 6/15 | 5 of 11 claims |
How to read it:
- This is a small sample, not a benchmark. At temperature 0, repeated runs are close to duplicates, so there are closer to 5 independent data points than 15. 3/15 against 6/15 is a direction, not a significant difference.
- The verifier is what makes the agent usable. With the reindent tool, the
agent claimed a fix 11 times and was right 6. Without independent
verification, 5 of 11 pull requests would have been broken code presented as a
fix. In one slugify run the agent saw its own tests fail twice, hit the test
budget and submitted
fixed=trueanyway. - An earlier run is invalid and kept as a record
(
results/20260924T115935-qwen2.5_7b/INVALID.md). Ollama served the model with a 4K window and silently truncated prompts; runs in the valid evals reached 7.2K prompt tokens. The agent now stops such runs as infra errors (scripts/probe-context.pyshows the truncation).
These runs used harness 0.1. Two issues they exposed are fixed in 0.2 (see
HarnessVersion in internal/agent/agent.go): a run_tests call with no changes no
longer spends the test budget, and the budget-refusal message no longer tells the
model to "submit now". Harness 0.2 also records claim_against_own_tests: a "fixed"
claim made when the agent's own last test run had not passed. Results from different
harness versions are not pooled.
scripts/swe/prepare.py turns SWE-bench Verified instances into Brokkr tasks. So far
that means Django 4.0, 4.1 and 4.2: 94 tasks.
- Environments. One read-only ext4 image per repo version, mounted at
/opt/envin the guest. Each follows SWE-bench v4.1.0's specs (pinned; current SWE-bench no longer carries them in code) with two recorded differences: Python comes from uv's standalone builds, since there is no conda on arm64, and the package is not installed, so the working copy is imported throughPYTHONPATH. - Hidden tests, as in SWE-bench. The agent sees only the issue text. The verifier
applies SWE-bench's test patch itself, after the candidate patch, and requires every
FAIL_TO_PASS and PASS_TO_PASS test to pass. The agent's own
run_testsruns the repository's existing tests only. - Same test names as SWE-bench. Test logs are read by a Go port of SWE-bench's
parse_log_django.scripts/swe/crosscheck_parser.pyruns the original on every validation log and compares the results. - Task validity.
scripts/swe/validate.shrequires, per task: no patch → FAIL, gold patch → PASS, and parsers agree. Invalid tasks are listed, never scored. - Dev/held-out split.
results/swe/split.jsonwas fixed before any agent ran: 10 dev tasks for debugging the harness, 84 held-out. It was chosen by sha256(instance_id). - Sandbox. The guest has loopback only, for test servers on localhost, and no network device.
Stated plainly, so nobody has to find them:
- Output forgery by code under test. The code under test runs in the same process as the test runner, so a patch written specifically to print fake "... ok" lines could fool the parser. It would also be an obvious diff for the human who approves every merge. A trusted out-of-process test reporter is planned.
- Standard-library shadowing.
python3 -m unittestputs the repo first onsys.path, so a patch addingunittest.pycould replace the runner. Protecting such names, or running tests from a trusted harness, is planned. - No jailer, cgroup quotas or snapshots yet. Firecracker's default seccomp filters apply. The jailer, cgroups v2 limits and snapshot restore come next.
- Two log formats so far. Python
unittestand Django's runner (a port of SWE-bench's parser). pytest, Go and Cargo come later. - One repository so far. The SWE-bench pipeline covers Django 4.x. Other repos need their own environment specs and, for pytest-based ones, a pytest parser.
- Live mode trusts the agent's test to be about the issue. Brokkr proves the test fails without the fix and passes with it; it cannot prove the test checks what the issue asks for. The reviewer reads the test. Live mode has no model results yet.
- pytest ids with spaces are cut at the first space (as in SWE-bench's parser, kept for fidelity), so a few parametrized cases share an id. A regression in one of them can be hidden by another that still passes.
- Toy tasks are only a smoke test. The five fixtures are small Python bugs written for this project. Results that mean something come from the SWE-bench tasks above.
cmd/brokkr/ Go control plane (CLI): brokkr verify, brokkr fix, brokkr bundle
internal/verify/ patch staging, protected-path check, verdict, evidence
internal/agent/ the tool-calling loop and the model's workspace
internal/model/ OpenAI-compatible chat client
internal/bundle/ review bundle (patch + PR description with evidence)
tools/ freetier proxy (Python) and its tests
runner/ Rust sandbox runner: one command, one fresh microVM
guest/brokkr-init PID 1 inside the guest
scripts/ gates and image build
fixtures/ five small buggy repos with task specs and reference patches
(calc also has the adversarial patches used by gate 1)
results/ eval outputs, one directory per run; REPORT.md is generated
from them by scripts/make-report.sh
dev/lima.yaml the reference dev VM
Firecracker needs Linux with KVM. On an Apple M3 or later running macOS 15+, a Lima VM with nested virtualization provides it:
limactl start --name brokkr dev/lima.yaml
limactl shell brokkr
scripts/kvm-smoke.sh # gate 0; expect PASS
BROKKR_NO_KVM=1 scripts/kvm-smoke.sh # negative control, expect FAIL
scripts/build-rootfs.sh # guest image with brokkr-initBuild the runner inside the VM, the control plane on the Mac, then run gate 1:
# in the VM
(cd runner && CARGO_TARGET_DIR=~/.cache/brokkr/target cargo build --release)
# on the Mac
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o bin/brokkr-linux-arm64 ./cmd/brokkr
# in the VM
mkdir -p ~/.cache/brokkr/bin && cp bin/brokkr-linux-arm64 ~/.cache/brokkr/bin/brokkr
scripts/gate1.shThe reference machine is an Apple M4 (16 GiB) running this Lima VM. Every published number names the machine it was measured on. Timings here go through two layers of virtualization, so they are slower than bare metal; comparisons are only made between runs on the same machine. A bare-metal Linux run is planned and will be reported separately, not mixed in.
Apache-2.0. See LICENSE.