Edit Strategies & the Test-Driven Repair Loop
Now the agent changes code and proves the change works. Search/replace versus full-file rewrites and why the choice matters; then the heart of the capstone: a red-to-green repair loop with bounded retries that writes a failing test, makes it pass, and never spins forever.
With a plan and the relevant files in hand, the agent must actually edit code — and then prove the edit fixes the bug without breaking anything else. Two sub-problems: how the model expresses an edit, and how the loop verifies and iterates. Both are where naive coding agents fall apart, so both deserve care.
Search/replace vs. full-file rewrite
| Strategy | How | Pros | Cons |
|---|---|---|---|
| Search/replace | Model emits an exact old block + new block; you patch in place | Small diffs, cheap tokens, reviewable, low collateral risk | Fails if the old block doesn't match exactly (whitespace, drift) |
| Full-file rewrite | Model emits the entire new file | Robust to matching issues; simple to apply | Expensive on large files; risks silently dropping unrelated code; noisy diffs |
| Unified diff | Model emits a patch; you apply with git/patch | Standard, precise, git-native | Models generate malformed diffs surprisingly often; needs validation + retry |
# Colab cell — pure Python, no key needed. Builds a tiny sandbox repo so
# the edit tool runs against a real file.
import pathlib, shutil
REPO = pathlib.Path("sandbox/repo").resolve() # absolute so containment checks work
REPO.mkdir(parents=True, exist_ok=True)
(REPO / "pricing.py").write_text(
"def calculate_discount(price, pct):\n"
" return price * (pct / 100)\n")
def apply_edit(rel: str, old: str, new: str) -> str:
"""Replace an exact block. Returns a clear error string on any mismatch."""
target = (REPO / rel).resolve()
if REPO not in target.parents:
return "error: path escapes the repo"
if not target.is_file():
return f"error: {rel} does not exist"
text = target.read_text()
count = text.count(old)
if count == 0:
# The single most common failure: block drifted. Tell the model to re-read.
return ("error: old block not found. Re-read the file and copy the "
"exact current text (including indentation) before editing.")
if count > 1:
return (f"error: old block appears {count} times; include more "
"surrounding context to make it unique.")
backup = target.with_suffix(target.suffix + ".bak")
shutil.copy(target, backup) # cheap rollback point
target.write_text(text.replace(old, new, 1))
return f"applied: 1 replacement in {rel}"
# a valid edit, then the two recoverable failure branches:
print(apply_edit("pricing.py",
" return price * (pct / 100)\n",
" return min(price, price * (pct / 100))\n"))
print(apply_edit("pricing.py", "return refund(order)\n", "x")) # not found
print(apply_edit("pricing.py", "\n", "x")) # many matches.bak copy is a trivial rollback point if the test loop later decides to revert. Crucially the tool refuses ambiguous edits rather than guessing — an agent silently editing the wrong of three identical blocks is a nasty, hard-to-trace bug. The demo shows all three outcomes in order: a clean applied, a block not found, and an appears N times refusal.Why exact-match beats line numbers — and the staleness trap
A tempting alternative to search/replace is editing by line number — the model says 'replace lines 40–42' and you slice the file. Resist it: line numbers are only valid against the exact file state the model read them from, and in an iterative loop that state keeps moving. The first edit in a multi-edit turn inserts or deletes lines, which shifts every line number below it; the model reasoned about both edits from one read, so its second set of line numbers is now silently wrong. Search/replace never has this failure mode because it re-locates its own anchor text on every apply, no matter what line it currently sits on — the representation is verifiable and cheap to check, and it fails loudly (an explicit 'block not found' error) instead of silently patching the wrong five lines. The same logic extends beyond the agent's own edits: a staleness check — capture a hash of the file's content when read_file last read it, and have apply_edit reject the write if the current on-disk hash doesn't match — catches the case where anything else (a formatter, a human, a parallel run) touched the file between read and write. Reject-on-stale is strictly cheaper than debugging a wrong result after the fact.
# Colab cell — run the previous cell first (it defines REPO). Pure Python.
import hashlib
LAST_READ_HASH: dict[str, str] = {} # populated by read_file on each read
def file_hash(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
def apply_edit_guarded(rel: str, old: str, new: str) -> str:
target = (REPO / rel).resolve()
if REPO not in target.parents:
return "error: path escapes the repo"
if not target.is_file():
return f"error: {rel} does not exist"
text = target.read_text()
expected = LAST_READ_HASH.get(rel)
if expected is not None and file_hash(text) != expected:
# The file changed since the model last read it — don't guess why.
return ("error: file changed on disk since it was last read. "
"Re-read " + rel + " before editing again.")
count = text.count(old)
if count == 0:
return "error: old block not found. Re-read the file before editing."
if count > 1:
return f"error: old block appears {count} times; add more context."
target.write_text(text.replace(old, new, 1))
LAST_READ_HASH.pop(rel, None) # invalidate — the next read repopulates it
return f"applied: 1 replacement in {rel}"
# reset the file to a known state (the earlier cell's demo mutated it):
(REPO / "pricing.py").write_text(
"def calculate_discount(price, pct):\n return price * (pct / 100)\n")
# A) recorded hash doesn't match what's on disk -> rejected, sent to re-read:
LAST_READ_HASH["pricing.py"] = "stale-hash-that-wont-match"
print("A:", apply_edit_guarded("pricing.py",
" return price * (pct / 100)\n", " return 0\n"))
# B) recorded hash matches current content -> applies cleanly:
LAST_READ_HASH["pricing.py"] = file_hash((REPO / "pricing.py").read_text())
print("B:", apply_edit_guarded("pricing.py",
" return price * (pct / 100)\n",
" return min(price, price * (pct / 100))\n"))read_file. This matters because the model's broader understanding of the file (what else is nearby, what the function above does) could be stale even when the specific old-block text still happens to match. Popping the cached hash after a successful write forces a fresh read before the next edit to the same file, so a chain of edits can never compound on assumptions from the original read. The demo shows both paths: (A) a stale recorded hash rejects the write and points the model back to read_file, while (B) a matching hash lets the same edit apply.apply_edit for line-number edits 'to save tokens on quoting large blocks.' On the very first repair attempt that needs two edits to the same file, the second edit silently lands on the wrong lines. Why?def apply_edit_by_lines(rel: str, start: int, end: int, new_lines: list[str]) -> str:
target = (REPO / rel).resolve()
lines = target.read_text().splitlines()
lines[start - 1:end] = new_lines # replace by line number
target.write_text("\n".join(lines))
return f"applied: lines {start}-{end} replaced in {rel}"The red-to-green repair loop
This is the capstone's beating heart and the discipline that separates a real fix from a plausible-looking one. The agent must write a test that reproduces the bug and fails first (red), then make its fix, then run the suite until that test — and all existing tests — pass (green). A fix with no reproducing test is unverified; it might do nothing, or fix the symptom while missing the cause. And the loop must be bounded: a hard cap on retries (the README says max 5) so a confused agent can't burn your budget forever.
# Colab cell — run the first cell first (defines REPO). Installs pytest and
# drops a passing test into the sandbox so the runner has something to run.
!pip install -q pytest
import subprocess, sys, pathlib
REPO = pathlib.Path("sandbox/repo").resolve()
(REPO / "test_pricing.py").write_text(
"from pricing import calculate_discount\n"
"def test_discount():\n"
" assert calculate_discount(100, 10) == 10\n")
def run_tests(target: str = "") -> dict:
"""Run pytest in the sandbox; return pass/fail + trimmed output."""
cmd = [sys.executable, "-m", "pytest", "-q", "--no-header"] # this interpreter
if target:
cmd.append(target)
proc = subprocess.run(
cmd, cwd=REPO, capture_output=True, text=True, timeout=600,
)
output = proc.stdout + proc.stderr
# Trim so a huge traceback dump doesn't blow the context budget.
tail = "\n".join(output.splitlines()[-60:])
return {
"passed": proc.returncode == 0,
"returncode": proc.returncode,
"output_tail": tail,
}
result = run_tests()
print("passed:", result["passed"])
print(result["output_tail"])timeout so a hanging test suite can't wedge the agent, and trimming output to the last ~60 lines because pytest tracebacks can be enormous and the failure summary lives at the bottom. Returning a dict (not a raw string) lets the loop branch cleanly on passed while still feeding the model the output_tail to reason about.# Colab cell — run once. Set your key in the 🔑 panel (name it
# ANTHROPIC_API_KEY) or paste it. This is the assembled repair loop: it needs
# read_file/apply_edit/run_tests wired into REPAIR_TOOLS (schemas) and a
# run_repair_tool dispatcher, plus a sandboxed repo — read and adapt.
!pip install -q anthropic
import os
try:
from google.colab import userdata
os.environ["ANTHROPIC_API_KEY"] = userdata.get("ANTHROPIC_API_KEY")
except Exception:
from getpass import getpass
os.environ.setdefault("ANTHROPIC_API_KEY", getpass("Anthropic API key: "))
import anthropic
client = anthropic.Anthropic()
MAX_ATTEMPTS = 5
def repair(plan: dict, issue_text: str) -> dict:
system = (
"Fix the bug per the plan. FIRST write a test that reproduces the "
"issue and fails (red). Then edit source with apply_edit until that "
"test AND all existing tests pass (green). Read failing output before "
"each edit. Tools: read_file, apply_edit, run_tests."
)
messages = [{"role": "user",
"content": f"Issue:\n{issue_text}\n\nPlan:\n{plan}"}]
for attempt in range(1, MAX_ATTEMPTS + 1):
resp = client.messages.create(
model="claude-sonnet-5", max_tokens=4096,
system=system, tools=REPAIR_TOOLS, messages=messages,
output_config={"effort": "high"}, # hard repair steps earn depth
)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
# stop_reason == "end_turn": the model THINKS it's done —
# verify independently, never take its word.
final = run_tests()
if final["passed"]:
return {"status": "success", "attempts": attempt}
messages.append({"role": "user", "content":
f"You stopped but tests still fail:\n{final['output_tail']}\n"
"Keep fixing."})
continue
results = []
for block in resp.content:
if block.type == "tool_use":
results.append(run_repair_tool(block)) # dispatches to the 3 tools
messages.append({"role": "user", "content": results})
# Bounded: give up cleanly rather than loop forever.
return {"status": "exhausted", "attempts": MAX_ATTEMPTS,
"last_tests": run_tests()}temperature; depth is spent via the effort knob instead.The verify loop is the quality mechanism, not a formality
Almost all of this agent's reliability comes from the tightness and honesty of run tests → feed failures back → repair, not from a smarter first-pass edit. Treat the loop's inputs as carefully as its outputs — starting with flaky tests. A test that fails ~10% of the time regardless of the fix looks exactly like real signal to a model that has never seen it before: it reads the failure, invents a plausible-sounding cause, and 'fixes' code that was never broken, sometimes making the actual bug worse in the process. The defense is a rerun-for-confirmation pattern: before feeding a failure back as ground truth, rerun that specific test 2–3 times with no code change; if it flips outcome with nothing changed, quarantine it for this repair session (skip it, log it, exclude it from the pass/fail decision) so it can't consume retry budget or steer an edit.
The other half is knowing when to stop repairing and report — a judgment call the retry cap only partially covers. Two signals matter more than the attempt count itself: are successive attempts converging (fewer failing tests, a narrowing diff) or oscillating (fixing test A breaks test B, then fixing B re-breaks A)? Oscillation is the tell that the agent's model of the bug is wrong, not that it needs one more try — continuing just burns budget rehearsing the same mistake. When the cap trips or oscillation is detected, stop and return the diagnostic state (which tests still fail, what the last few diffs were) rather than silently retrying past the point of usefulness; that diagnostic dump is also what turns an 'exhausted' outcome into something a human can pick up in seconds.
score_issue-style checks asks whether the test itself changed. This is Goodhart's law arriving inside your agent loop. It's not fixable from inside this stage — the guardrail belongs at the gate that reviews the diff, covered next lesson.Whiteboard drills
- ▸Default to search/replace edits: tiny reviewable diffs, cheap, low collateral risk; recover from match failures by re-reading.
- ▸Refuse ambiguous edits (zero or multiple matches) rather than guessing.
- ▸Line-number edits are fragile — earlier edits shift later offsets, silently. Search/replace re-locates its anchor on every apply; add a staleness hash check to reject writes to a file that changed since it was last read.
- ▸The repair loop must write a failing test first (red), then fix to green — no reproducing test means no verified fix.
- ▸Bound retries (max ~5) so a confused agent fails cleanly instead of burning budget.
- ▸Never trust the model's 'done' — run the tests yourself and push failures back.
- ▸Rerun a failing test before trusting it as signal — flaky tests poison the loop; quarantine ones that flip with no code change.
- ▸Distinguish converging attempts from oscillating ones; oscillation means stop and report, not retry again.
- ▸Test-gaming (weakening or deleting the test instead of fixing the bug) is the loop's proxy-metric failure mode — the guardrail is an external diff check, not a prompt instruction.
- ▸All edits and test runs happen in a sandbox (worktree/container), never the real tree.