Capstone: build a coding agent
A harness that edits a project, runs its tests and stops when they pass.
Create a free account Try part of it here
Why it matters
This is the payoff for the phase. You'll build a small but complete coding agent: it reads and edits a project, runs its tests, and keeps going until they pass, or stops cleanly when it can't. The same architecture, scaled up, runs inside the coding tools professionals use every day. Once you've built one, you can read, tune and extend any of them.
What you'll be able to do
- Combine the loop, tools, permissions, a stop check and an event log into one coding agent
- Give the agent file tools that stay inside the project and refuse to change the tests
- Make the harness, not the model, decide when the work is done: when the tests pass
- Connect the finished harness to a real model with a small adapter
What it covers
- The design
- Tests the agent can't touch
- The harness decides when it's done
- endturn isn't the end
- Running tests in the browser
- The test runner you'll use
- Plugging in a real model
- Why the prompt isn't enough
- Don't trust "done"
The design
You've already built every part. This is the parts list, with the lesson each one came from:
| part | from lesson | in this agent |
|---|---|---|
| injected model | what a harness is | model(system, messages, tools), scripted in tests |
| loop | the core loop | tool_use runs tools; end_turn triggers the check |
| tools | registry, file tools, execution | read_file, write_file, edit_file, run_tests |
| workspace jail | file tools | every path must resolve inside the project |
| permissions | permissions | test files are read-only, so the agent can't "fix" a test by weakening it |
| stop check | hooks | on end_turn, the harness runs the tests itself |
| turn budget | the core loop | stops with a clear status |
| event log | production | one event per model call, tool call and test run |
In plain words: the model suggests what to do next, and your code does it, checks it and decides when the job is over.
Tests the agent can't touch
If the agent can edit the tests, the fastest way to make them pass is to weaken them. So test files are read-only, and the check runs before any write.
from pathlib import PurePosixPath def may_write(path): parts = PurePosixPath(path).parts if parts and parts[0] == "tests": return False, "tests are read-only: change the code, not the test" return True, "ok" for path in ["calc.py", "tests/test_calc.py", "src/util.py"]: print(path, may_write(path))
The refusal message matters. It tells the model what to do instead, so it goes back to fixing the code. You'll use this rule in the exercise.
The harness decides when it's done
Students don't mark their own exams. A model saying "Fixed!" is a claim, not a result. So when the model ends its turn, the harness runs the tests itself, like a Stop hook:
- Tests pass: the run succeeded.
- Tests fail: add a user message with the failures and "keep working", and loop again. The model sees exactly what's still wrong.
- Still failing after two such checks: stop with status
"failed", so a person can look.
Combined with read-only tests, this closes the obvious shortcut: the only way to pass is to fix the code.
end_turn isn't the end
The model says it's done on the first try here, but it isn't. The harness runs the tests itself and sends it back.
replies = iter([
{"stop_reason": "end_turn", "text": "All done!", "fix": False},
{"stop_reason": "end_turn", "text": "Fixed the off-by-one.", "fix": True},
])
code_is_fixed = False
def run_tests():
return code_is_fixed
for turn in range(1, 6):
reply = next(replies)
code_is_fixed = reply["fix"]
print(f"turn {turn}: model says {reply['text']!r}")
if run_tests():
print("tests pass: finished")
break
print("tests fail: sending the failure back")The first reply is confident and wrong. If the harness had believed it, you'd have shipped a bug. A check the model can't talk its way past is what makes an agent trustworthy.
model_says_done = True tests_pass = False print(model_says_done and tests_pass)
Show the answer
False
True and False is False, so it prints False. The model's opinion isn't enough. The harness runs the tests itself.
The model proposes and the harness decides. That's the whole idea of this phase.
Running tests in the browser
The run_tests helper is given. It imports each test_*.py file in the project and calls its test_* functions, a tiny version of pytest. On a real machine you'd run pytest through the execution tool, in a sandbox.
Show the answer
The harness runs the tests itself when the model ends its turn
A model's "done" is a claim. The harness turns it into a fact by running the tests.
The test runner you'll use
It returns whether everything passed and a short summary for the model.
import importlib, sys, tempfile from pathlib import Path def run_tests(root): root = Path(root) passed, failures = 0, [] sys.path.insert(0, str(root)) old_flag, sys.dont_write_bytecode = sys.dont_write_bytecode, True try: for test_file in sorted(root.glob("test_*.py")): for name in [m for m in list(sys.modules) if (root / f"{m}.py").exists()]: del sys.modules[name] importlib.invalidate_caches() ns = {"__name__": "tests"} try: exec(compile(test_file.read_text(), str(test_file), "exec"), ns) except Exception as err: failures.append(f"ERROR {test_file.name}: {type(err).__name__}: {err}") continue for name, fn in list(ns.items()): if name.startswith("test_") and callable(fn): try: fn() passed += 1 except Exception as err: failures.append(f"FAIL {name}: {err}") finally: sys.path.remove(str(root)) sys.dont_write_bytecode = old_flag summary = f"{passed} passed, {len(failures)} failed" + "".join("\n" + f for f in failures) return {"passed": passed > 0 and not failures, "summary": summary} root = Path(tempfile.mkdtemp()) (root / "calc.py").write_text("def add(a, b):\n return a - b\n") (root / "test_calc.py").write_text('from calc import add\n\ndef test_add():\n assert add(2, 2) == 4, f"add(2, 2) gave {add(2, 2)}"\n') print(run_tests(root))
Removing the project's modules from sys.modules, and not writing bytecode, makes each run import the current files, so edits take effect right away.
Show the answer
It never got that far: edits to test files are denied, so the model must fix calc.py instead
Read-only tests plus a harness-run check means the only way to pass is a real fix.
Plugging in a real model
Remember the flight simulator. The pilot's controls stay the same when they move to the real plane. Because the model is injected, switching from the scripted model to Claude is one small adapter. With an API key on your own machine, using the official SDK:
import anthropic client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment def claude(system, messages, tools): reply = client.messages.create(model="claude-opus-5-5", max_tokens=4000, system=system, tools=tools, messages=messages) return {"stop_reason": reply.stop_reason, "content": [block.model_dump(exclude_none=True) for block in reply.content]} agent = CodingAgent(claude, "path/to/project") print(agent.run("The tests in test_calc.py fail. Find the bug and fix it."))
model_dump(exclude_none=True) turns the SDK's block objects into the plain dicts the harness uses. From there, add the pieces from this phase one at a time, and run your eval suite after each, to see what actually helps.
model_dump(exclude_none=True) do in the adapter?Show the answer
Turns the SDK's block objects into plain dicts, without empty fields
The harness works with plain dicts, the same shape the API accepts back in the next request.
Why the prompt isn't enough
A "do not touch" sign isn't a lock. You could write "do not edit the tests" in the system prompt. It would work most of the time, and that's the problem. Under pressure from a failing test, with several attempts already used, a model may decide that the test is what's wrong. It may even be right sometimes. But your check on the agent's work is worthless if the agent can rewrite it.
So the harness enforces the rule. Test files are read-only at the tool level, and no amount of clever reasoning changes that. As a general rule, use prompts to guide and code to guarantee.
What happens when it keeps failing
Without a limit, an agent that can't solve a problem keeps trying forever, spending money each turn. So cap the turns, and when the budget runs out, stop and report honestly: what it tried, which tests still fail, and what state it left the files in. Reporting failure clearly is a feature. A person can pick up from there, which is much better than a silent loop, or a confident claim that everything works.
Show the answer
Stop and report what it tried and which tests still fail
A clear report lets a person pick up from there. Without a limit, the agent spends money forever, and a false claim of success is worse than an honest failure.
Don't trust "done"
A model saying it has finished is a statement, not a fact. It may have stopped because it believed it was done, or ran out of ideas, or got confused. The harness runs the tests itself on every end_turn. If they fail, it sends the failures back and continues. Only the test results decide.
The real model changes nothing
When you plug in the real API, the harness stays exactly the same. The scripted model and the real one are both functions that take messages and tools, and return a reply. That's the payoff of injecting the model from the start: every path you tested with a script, including errors, timeouts and bad tool calls, is still tested. Real models do add one thing, which is variety. Run each task several times, as you learned in the eval lesson, since a single pass doesn't mean much.
Where to take it next
From here, you could add a real sandbox for the commands, a smarter way to pick which files to read, or the ability to ask the person a question when it's stuck. Each is a small change to one part. The loop in the middle stays as it is, and that's what makes a well-designed harness easy to grow.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
Why does the harness run the tests itself when the model says it's done?