Skip to content
Guides · Agent cookbook

How Do I Check That an Agent's Change Improved a Metric?

Updated October 28, 2026 .md

Short answer: Prepare the repo and a scoring script once in a sandbox, then fork it. Score the unchanged code and the agent's change on the same forked disk, several rounds each, interleaved. Treat a drop in a deterministic score as real. Count a speed change only if it beats the baseline's own spread. Keep the diff, delete the forks.

Tests tell you the code runs. They don't tell you whether a "make search better" or "make this faster" change did what it claims. An agent can ship a change that passes every test, runs 2.6 times faster and ranks results worse. You only catch that if you measure the metric, before and after, on the same machine and the same data.

This page shows that check end to end. It ran on prod on 2026-10-28 on small sandboxes (2 vCPU, 4 GB). No model ran: the agent's two attempts are scripted files, so you can see one rejected and one kept. To run many attempts at once and keep the one that passes the most tests, see best-of-N agent attempts with parallel forks. To check a UI change in a browser, see let a coding agent test its UI change. This page is about numbers that aren't pass or fail.

The pattern#

Step Command Measured on prod
Create the source, copy in the repo, build the data, commit a base sbx new, sbx exec 1.2 s create, 0.5 s setup
Freeze the source sbx stop <src> --wait 2.2 s
A/A check: score the base in its own fork sbx fork <src> --wait, ./bench.sh 5 base=. 3.3 s to ready, 6.3 s fork to deleted
A/B: base and the agent's attempts in one fork, interleaved git worktree add, ./bench.sh 5 base=../base try1=../try1 try2=. 3.3 s to ready, 5.2 s to scored
Decide, keep the diff, delete everything decide.py, git diff, sbx delete --yes 2.1 s for the fork's delete

The commands took about 21 seconds in total. Wall clock from the first sbx new to the last delete was 57 seconds, because we paused between steps to read the output. These are single runs, not benchmarks.

1. A metric worth checking#

The example repo ranks Python standard library modules for a plain-English query, such as "read and write zip archive files". The corpus is each module's docstring, built once in the sandbox. There are 30 labeled queries, each with the module that should come first.

search.py is the file the agent is asked to improve. The baseline counts matching words:

# search.py: rank documents for a query. This is the file the agent is asked to improve.
import re

def tokens(text):
    return re.findall(r"[a-z0-9]+", text.lower())

class Index:
    def __init__(self, docs):
        self.docs = {name: tokens(name + " " + text) for name, text in docs.items()}

    def search(self, query, k=10):
        q = set(tokens(query))
        scores = {name: sum(1 for t in toks if t in q) for name, toks in self.docs.items()}
        return sorted(scores, key=lambda n: (-scores[n], n))[:k]

eval.py scores it. It reports two kinds of number, and they need different treatment:

  • Quality: mean reciprocal rank (mrr), and how many queries have the right module first (hit1) or in the top 3 (hit3). For a fixed corpus and code, these are the same on every run.
  • Speed: milliseconds per query, the median of 15 timed passes. This changes from run to run.
# eval.py: score search.py on the labeled queries. Prints one JSON line.
import json, os, statistics, time
from search import Index

docs = json.load(open("data/docs.json"))
queries = json.load(open("data/queries.json"))   # [["read and write zip archive files", "zipfile"], …]
t = time.perf_counter(); index = Index(docs); build_ms = (time.perf_counter() - t) * 1000

rr, hit1, hit3 = [], 0, 0
for q, want in queries:
    ranked = index.search(q)
    rank = ranked.index(want) + 1 if want in ranked else None
    rr.append(1 / rank if rank else 0.0)
    hit1 += rank == 1
    hit3 += bool(rank and rank <= 3)

passes = []
for _ in range(int(os.environ.get("REPEATS", "15"))):
    t = time.perf_counter()
    for q, _want in queries:
        index.search(q)
    passes.append((time.perf_counter() - t) * 1000 / len(queries))

print(json.dumps({"mrr": round(sum(rr) / len(rr), 3), "hit1": hit1, "hit3": hit3, "n": len(queries),
                  "msPerQuery": round(statistics.median(passes), 3), "buildMs": round(build_ms, 1)}))

bench.sh runs eval.py in several checkouts, taking turns, for N rounds. Taking turns matters: if the machine slows down for a moment, every arm feels it, not just one.

#!/bin/bash
# bench.sh <rounds> <label=dir>...   Run eval.py in each checkout, interleaved, for N rounds.
rounds=$1; shift
for r in $(seq 1 "$rounds"); do
  for arm in "$@"; do
    label=${arm%%=*}; dir=${arm#*=}
    (cd "$dir" && python3 eval.py) | sed "s/^{/{\"arm\":\"$label\",\"round\":$r,/"
  done
done

2. Prepare the source once and freeze it#

sbx new --name search-src --type small --ttl 3600
src=sb_imdxffhhfg       # the id sbx new printed

tar czf repo.tgz repo
sbx exec $src "echo $(base64 -w0 repo.tgz) | base64 -d | tar xz"
sbx exec $src --timeout 120 "cd repo && python3 --version && python3 build_corpus.py \
  && git init -q && git add -A && git -c user.name=agent -c user.email=agent@example.com commit -qm base \
  && git status --short && python3 eval.py"
Python 3.12.3
79 docs, 50859 chars
{"mrr": 0.44, "hit1": 10, "hit3": 16, "n": 30, "msPerQuery": 0.177, "buildMs": 1.0}

Build the data inside the sandbox and commit it with the code. Here the corpus comes from the installed Python's docstrings. The same script on a laptop with Python 3.14 gave mrr 0.458, not 0.44, because the docstrings differ. If the baseline and the candidate don't read the same bytes, the comparison is meaningless.

Then stop the source:

sbx stop $src --wait

A stopped sandbox has no running processes, so nothing can change its disk between forks. You can still fork it: sbx fork copies the source's latest snapshot, and a stop always takes one. Ours forked 3.3 s after each request.

3. The A/A check: how noisy is the metric?#

Before you trust a "2x faster", score the unchanged code twice and see how much it moves on its own. The first fork scores only the base:

id=$(sbx fork $src --name search-aa --type small --ttl 900 --wait --json \
  | jq -r 'select(.event=="ready") | .id')
sbx exec $id --timeout 120 "cd ~/repo && ./bench.sh 5 base=." | tee -a runs.jsonl
sbx delete $id --yes
{"arm":"base","round":1,"mrr": 0.44, "hit1": 10, "hit3": 16, "n": 30, "msPerQuery": 0.172, "buildMs": 1.1}
{"arm":"base","round":2,"mrr": 0.44, "hit1": 10, "hit3": 16, "n": 30, "msPerQuery": 0.178, "buildMs": 1.0}
{"arm":"base","round":3,"mrr": 0.44, "hit1": 10, "hit3": 16, "n": 30, "msPerQuery": 0.172, "buildMs": 2.7}
{"arm":"base","round":4,"mrr": 0.44, "hit1": 10, "hit3": 16, "n": 30, "msPerQuery": 0.179, "buildMs": 2.6}
{"arm":"base","round":5,"mrr": 0.44, "hit1": 10, "hit3": 16, "n": 30, "msPerQuery": 0.18, "buildMs": 2.6}

What this tells you:

  • mrr was 0.44 in all five rounds, and in the second fork below too. A quality score that is deterministic needs one run. Any change in it is caused by the code.
  • msPerQuery moved between 0.172 and 0.18 inside one fork, even though each figure is already a median of 15 passes. The base in the second fork had a median of 0.174, against 0.178 here. That gap is smaller than either fork's own spread, so the two forks agree.
  • buildMs swung from 1.0 to 2.7 ms. A single run of a number this small tells you almost nothing.

A fork copies the disk only. It boots fresh, with no memory, page cache or warm processes from the source, and it can land on a different machine. Speed figures from two different forks are two different machines. That's why the next step puts the base and the attempts in the same fork.

4. The A/B run: base and attempts in one fork#

The second fork holds three checkouts of the same commit, made with git worktree. ~/base stays untouched. The agent's two attempts go into ~/try1 and ~/repo:

id=$(sbx fork $src --name search-ab --type small --ttl 900 --wait --json \
  | jq -r 'select(.event=="ready") | .id')
sbx exec $id "cd ~/repo && git worktree add -q ../base HEAD && git worktree add -q ../try1 HEAD \
  && echo $(base64 -w0 try1.py) | base64 -d > ../try1/search.py \
  && echo $(base64 -w0 try2.py) | base64 -d > search.py && git worktree list"
sbx exec $id --timeout 120 "cd ~/repo && ./bench.sh 5 base=../base try1=../try1 try2=." > runsB.jsonl
/home/user/repo  ecbe4c3 [master]
/home/user/base  ecbe4c3 (detached HEAD)
/home/user/try1  ecbe4c3 (detached HEAD)

The two attempts:

Attempt What the "agent" did Does it run?
try1 A speed-up: one compiled regex, a Counter per document, and it dropped .lower() because "queries are lowercase already" Yes, no errors
try2 BM25 ranking with IDF weights, a short stopword list and plain suffix stemming Yes, no errors

The first and last of the 15 lines in runsB.jsonl, trimmed:

{"arm":"base","round":1,"mrr": 0.44, "hit1": 10, "hit3": 16, "n": 30, "msPerQuery": 0.174, …}
{"arm":"try1","round":1,"mrr": 0.407, "hit1": 9, "hit3": 15, "n": 30, "msPerQuery": 0.067, …}
{"arm":"try2","round":1,"mrr": 0.906, "hit1": 26, "hit3": 28, "n": 30, "msPerQuery": 0.057, …}
…
{"arm":"base","round":5,"mrr": 0.44, "hit1": 10, "hit3": 16, "n": 30, "msPerQuery": 0.174, …}
{"arm":"try1","round":5,"mrr": 0.407, "hit1": 9, "hit3": 15, "n": 30, "msPerQuery": 0.067, …}
{"arm":"try2","round":5,"mrr": 0.906, "hit1": 26, "hit3": 28, "n": 30, "msPerQuery": 0.057, …}

5. Decide with a written rule#

Write the rule down before you see the numbers, so neither you nor the agent argues with it later. decide.py compares one candidate with the base from the same fork. Any drop in quality rejects it. A speed change only counts if it is bigger than the spread of the base's own rounds.

# decide.py <runs.jsonl> <base-label> <candidate-label>: keep or discard the candidate.
import json, statistics, sys
runs = [json.loads(l) for l in open(sys.argv[1]) if l.strip()]
base = [r for r in runs if r["arm"] == sys.argv[2]]
cand = [r for r in runs if r["arm"] == sys.argv[3]]
med = lambda rs, k: statistics.median(r[k] for r in rs)
spread = max(r["msPerQuery"] for r in base) - min(r["msPerQuery"] for r in base)
b_mrr, c_mrr = med(base, "mrr"), med(cand, "mrr")
b_ms, c_ms = med(base, "msPerQuery"), med(cand, "msPerQuery")
quality = "better" if c_mrr > b_mrr else "worse" if c_mrr < b_mrr else "same"
speed = "faster" if c_ms < b_ms - spread else "slower" if c_ms > b_ms + spread else "within noise"
keep = quality != "worse" and speed != "slower" and (quality == "better" or speed == "faster")
print(f"mrr {b_mrr} -> {c_mrr} ({quality}); ms/query {b_ms} -> {c_ms}, baseline spread {spread:.3f} ({speed})")
print("KEEP" if keep else "DISCARD")
sys.exit(0 if keep else 1)
python3 repo/decide.py runsB.jsonl base try1
python3 repo/decide.py runsB.jsonl base try2
mrr 0.44 -> 0.407 (worse); ms/query 0.174 -> 0.067, baseline spread 0.011 (faster)
DISCARD
mrr 0.44 -> 0.906 (better); ms/query 0.174 -> 0.057, baseline spread 0.011 (faster)
KEEP

try1 is the change a test suite waves through. It runs, it is 2.6 times faster, and it ranks worse. Without .lower(), the tokenizer skips uppercase letters, so a word like "SMTP" vanishes from both the query and the docs. try2 puts the right module first for 26 of 30 queries instead of 10.

The same script also gives the A/A result from step 3. Fed the base rounds from both forks, it reports mrr 0.44 -> 0.44 (same) and ms/query 0.178 -> 0.174, baseline spread 0.008 (within noise), then DISCARD. That is the right answer: nothing changed.

The exit code is the verdict, so an agent loop or a CI job can act on it directly.

6. Keep the diff, delete the forks#

sbx exec $id "cd ~/repo && git diff --stat"
sbx exec $id "cd ~/repo && git diff" > try2.diff
sbx delete $id --yes
sbx delete $src --yes
 search.py | 28 ++++++++++++++++++++++++----
 1 file changed, 24 insertions(+), 4 deletions(-)

try2.diff is a normal git diff against the base commit. Apply it in your checkout with git apply try2.diff. All three sandboxes returned 404 not_found afterwards.

Make the comparison honest#

  • Freeze the data with the code. Commit the corpus, fixtures or sample inputs in the base. The 0.44 versus 0.458 gap in step 2 came only from a different Python version.
  • Repeat timed runs and take turns. Five interleaved rounds were enough here because the gap was large. For a change of a few percent, run more rounds and expect some to come out "within noise".
  • Compare speed inside one fork. Each fork boots fresh and can run on a different machine. Compare quality across forks if you like, since it is deterministic. Compare speed only between arms in the same fork.
  • Watch the size of the test set. With 30 queries, one query moves hit1 by 1 and mrr by up to 0.033. The rule rejects any drop, which is right for a fixed set, but a small gain on 30 queries may not hold on real traffic. Grow the set before trusting small wins.
  • Hide the labels if the agent could game them. An agent that can read queries.json can tune for those 30 queries. Copy the labels in with sbx exec after the attempt is finished.
  • Pass a short --ttl to every fork. A fork gets one hour by default, not the source's TTL. A short TTL stops it if your script dies before sbx delete.

FAQ#

Why not compare the agent's numbers with a baseline I measured last week?#

Last week's number came from a different machine, maybe a different dependency version, and maybe different data. Score the base again next to the candidate, in the same fork, in the same run. It costs one more checkout.

Does the fork carry over the source's warm cache or running services?#

No. A fork copies the disk only and boots fresh. Start anything the scorer needs, such as a database, from a script or an enabled systemd unit. Expect the first round in a new fork to be a cold start, and leave it out if your metric is sensitive to that.

Can I keep the prepared source around for the next change?#

Yes. Save it as a template with sbx snapshot <id> search-base-v1 and start later rounds with sbx new --from search-base-v1. That works after the source is deleted. Re-save it when the base commit changes.

How much does one comparison cost?#

Here, each of the three small sandboxes existed for under a minute, counting our pauses between steps. At $0.018 per hour for small, that is a fraction of a cent. Your scorer's run time is what sets the real cost.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.

Read the guide
Best E2B alternatives in 2026, ranked

If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.

Read the guide