The agent harness: everything around the model
A harness is the program around a language model that lets it act. The model only produces text. The harness decides what the model is shown, reads its reply, decides whether the reply is allowed to do anything, does it, looks at what happened, and decides whether to go round again. Claude Code, Codex CLI, Aider and SWE-agent are harnesses; so is a forty-line while loop around an API call.
The useful way to think about it: the model proposes, the harness disposes. The model is the one random, untrusted part of the system. Every guarantee you want, such as “never deletes files outside the repo” or “only says done when the tests pass”, has to live in the code around it, because nothing inside the model enforces it.
Here is one trip round that loop, with a concrete job: a tiny program adds numbers wrong, and the agent has been asked to fix it. Follow the numbers; each box says in plain words what happens, and the small grey tag is the symbol the rest of the article uses for it.
Not every reply is usable. The next figure shows the two ways a reply gets bounced: it isn’t an instruction at all, or it asks for something the rules forbid. Either way nothing runs; the model is told why and tries again on the next trip.
The loop, part by part
One step of the loop runs through these parts in order. The symbol column is the small grey tag under each box in the figures, so the table doubles as their key.
| Part | Symbol | What it does | In a coding agent |
|---|---|---|---|
| State | s = (q, w) | Everything that decides what happens next. Split into what the harness can see (q) and the real world it can’t fully see (w). | q: the transcript, the plan, counters, permissions. w: the files on disk, the servers, the database. |
| Prompt builder | π | Turns the visible state into the model’s input: picks which history, files and tool descriptions go in. | System prompt, the last N tool results, the tool schemas on offer. |
| Model call | M | The only random step. Reads the prompt, writes text. | One API call. |
| Readout | Y | Parses the text into a proposed action. Can fail (⊥Y): the reply isn’t valid JSON, names no tool, or is cut off. | A tool call, or prose where a tool call was expected. |
| Gate | γ | Decides, with plain code and no model, whether the proposed action may run. A refusal (⊥A) goes back to the model as a message. | Allow and deny lists, permission prompts, hooks. |
| Environment | QE | Runs the allowed action against the real world, which changes it (w → w′), and returns a receipt o. | The shell command actually runs; its output comes back. |
| Verify and fold back | ρ | Checks the receipt and writes it into the visible state, so the next step sees what really happened. | The tool result is appended to the transcript; a test is re-run. |
| Halt | H, τH | The set of states where the loop stops, for any reason, and the step at which it does. | The model says it’s done, the step budget runs out, or the user stops it. |
Two details in that table do most of the work.
The state must be complete after fold-back. The model has no memory between calls; whatever it needs next step has to be in q by the time the loop comes round. Formally the loop is only Markov (the next step depends on the current state and nothing else) once ρ has run. In practice this is why a harness that forgets to append an error message makes the model repeat the same mistake: from its point of view the mistake never happened.
The gate is deterministic on purpose. If an LLM decided what the LLM is allowed to do, the permission would be as random as the request. A gate is a function of the action and the configuration: the same proposed command gets the same answer every time, and it can be tested.
What the harness sees, and what it doesn’t
The split between q and w is the reason harnesses are hard. The harness knows what it sent and what came back. It does not know the world. A command can succeed and print nothing; it can print “ok” and have failed; a file can change because something else touched it; a test can pass against a cached copy of the code. Every receipt o is a report about w, not w itself.
That has a sharp consequence for when to stop.
Three things can be true when a run stops, and only the first is what you want.
| Set | Meaning | Who decides |
|---|---|---|
| H | All halts: every state where the loop stops, whether it accepted, ran out of budget or was interrupted. | The harness, from q. |
| Hacc | Accepted halts, a subset of H: the run stopped and was reported as done. | The harness, from q. |
| G | Real success: the task is actually done in the world. | The world, w. Usually only checkable afterwards, or by a test. |
| B | Bad or unsafe states: a deleted directory, a leaked secret, a broken deploy. | Judged on (q, w), so it can be true while nothing the harness can see says so. |
The aim is that accepted halts land in G and never in B. The two failures are different and need different defences:
- Accepted but not successful (Hacc outside G): the model said “done, tests pass” and they don’t. The defence is ρ: the harness checks for itself before it accepts.
- Halted in a bad state (B ∩ H): the run finished and looked fine, and something is broken. The defence is mostly γ, refusing the action before it runs, because after the fact the harness may not be able to see the damage at all.
A complete harness
A harness small enough to read in one go. The model is replaced by a script of canned replies so the run is repeatable; swapping scripted_model for an API call changes nothing else. The task: fix add.py, which subtracts instead of adding. Run twice: once trusting the model’s “done”, once verifying it.
import json, pathlib, subprocess, tempfile
def scripted_model(script):
"""Stands in for the LLM: returns the next canned reply, whatever the prompt says."""
replies = iter(script)
return lambda prompt: next(replies)
ALLOWED = {"read", "write", "run_tests", "done"}
def build_prompt(state): # π: state -> prompt
return f"Task: {state['task']}\nHistory: {state['history'][-3:]}"
def parse(text): # Y, or ⊥Y on a parse failure
try:
return json.loads(text)
except json.JSONDecodeError:
return None
def gate(action, workdir): # γ: deterministic, no model involved
if action.get("tool") not in ALLOWED:
return f"tool {action.get('tool')!r} not allowed"
p = action.get("path")
if p and not (workdir / p).resolve().is_relative_to(workdir):
return f"path {p!r} outside the workspace"
return None
def tests_pass(workdir):
r = subprocess.run(["python", "-B", "-c", "from add import add; assert add(2, 3) == 5"],
cwd=workdir, capture_output=True)
return r.returncode == 0
def execute(action, workdir): # environment: touches the real files, returns a receipt o
f = workdir / action.get("path", "")
match action["tool"]:
case "read": return f.read_text()
case "write": f.write_text(action["text"]); return f"wrote {len(action['text'])} bytes"
case "run_tests": return "pass" if tests_pass(workdir) else "fail"
def run(model, verify, max_steps=8):
workdir = pathlib.Path(tempfile.mkdtemp()).resolve()
(workdir / "add.py").write_text("def add(a, b):\n return a - b\n") # the bug
state = {"task": "make add(2, 3) == 5", "history": [], "steps": 0}
while state["steps"] < max_steps: # H includes the step budget
state["steps"] += 1
action = parse(model(build_prompt(state)))
if action is None:
obs = "unparseable reply, answer with one JSON object"
elif (why := gate(action, workdir)):
obs = f"rejected: {why}"
elif action["tool"] == "done":
if not verify or tests_pass(workdir): # ρ: check before accepting
return f"halted at step {state['steps']}, accepted; tests really pass: {tests_pass(workdir)}"
obs = "done refused: tests fail"
else:
obs = execute(action, workdir)
state["history"].append(obs) # ρ: fold the receipt back
print(f" {state['steps']}: {obs.strip()[:60]!r}")
return f"stopped: step budget of {max_steps} used up"
FIX = json.dumps({"tool": "write", "path": "add.py", "text": "def add(a, b):\n return a + b\n"})
script = ['{"tool": "read", "path": "add.py"}',
'Sure! I will fix the bug now.',
'{"tool": "shell", "cmd": "rm -rf ~"}',
'{"tool": "read", "path": "../../etc/passwd"}',
'{"tool": "done"}',
FIX,
'{"tool": "run_tests"}',
'{"tool": "done"}']
print("trusting harness:"); print(" ", run(scripted_model(script), verify=False))
print("verifying harness:"); print(" ", run(scripted_model(script), verify=True))
Output, on Python 3.12.13:
trusting harness:
1: 'def add(a, b):\n return a - b'
2: 'unparseable reply, answer with one JSON object'
3: "rejected: tool 'shell' not allowed"
4: "rejected: path '../../etc/passwd' outside the workspace"
halted at step 5, accepted; tests really pass: False
verifying harness:
1: 'def add(a, b):\n return a - b'
2: 'unparseable reply, answer with one JSON object'
3: "rejected: tool 'shell' not allowed"
4: "rejected: path '../../etc/passwd' outside the workspace"
5: 'done refused: tests fail'
6: 'wrote 32 bytes'
7: 'pass'
halted at step 8, accepted; tests really pass: True
The same eight replies as a picture, side by side:
Every part of the loop shows up in eight steps:
- Step 2 is a parse failure (⊥Y). The harness doesn’t crash or guess; it tells the model what shape of answer it expects.
- Steps 3 and 4 are gate refusals (⊥A): a tool that isn’t on the list, and a path that escapes the workspace once
..is resolved. Neither touches the disk. - Step 5 is where the two harnesses part. The model claims it’s done without having changed anything. The trusting harness accepts: a halt in Hacc that isn’t in G, reported as a success. The verifying harness runs the test itself, refuses, and folds “tests fail” back into the history.
- Steps 6 to 8: the fix is written, the test is run, and the second “done” passes the check.
The verifying harness took three more steps and got the right answer. That is the usual trade: verification costs steps and time, and buys accepted halts you can believe.
Design rules that follow
- Put the guarantees in code, not in the prompt. “Never run
rm” in a system prompt is a request. A deny rule in the gate is a guarantee. Use the prompt to make good actions likely and the gate to make bad ones impossible. - Check the world, not the model’s report of it. Before accepting “done”, re-run the test, re-read the file, query the endpoint. A receipt from the environment beats a claim from the model; a fresh check beats an old receipt.
- Fold back everything the next step needs, including failures. Refusals, parse errors and non-zero exit codes are information. A harness that hides them gets the same wrong action again.
- Bound the loop. A step budget, a token budget and a wall-clock limit are all part of H. Without them a confused model loops until something external kills it.
- Keep the visible state small and honest. What π puts in the prompt is what the model believes. Stale tool results, truncated output with no marker that it was truncated, and summaries that drop the error all make q drift away from w.
- Make the dangerous actions reversible, or ask a human. The gate can’t foresee everything. Working on a copy, in a container, or behind a confirmation prompt shrinks B to what a human approved.
Things that go wrong
- The verifier reads a cache. While the example above was being written, the test first ran without
-B. Python cached the compiled buggyadd.py, the fix had the same byte length and was written within the same second, so Python’s freshness check for the cached.pyc(source modification time in whole seconds, and size) saw no change and the test kept loading the old code: the harness reported “fail” for code that was correct. A check that reads a copy of the world is not a check of the world. - The gate checks the string, not the effect. Blocking
rm -rfdoesn’t blockfind . -delete, a Python one-liner, or a script the model wrote a step earlier. Gates that allow a general-purpose shell can only filter the obvious; the stronger move is fewer, narrower tools, or a sandbox where the worst case is cheap. - Path checks before resolving.
../and symlinks turn an allowed path into a forbidden one. Resolve first, then compare, as the example does. - Errors that vanish. A tool that returns an empty string on failure, or a harness that truncates long output from the end, hides exactly the line the model needed.
- “Done” as the only stop. If the only way out is the model saying so, a model that never says so runs forever, and one that says so too early is believed.
- Unseen side effects. A deploy, an email, a database write or a call to someone else’s API changes w in ways no later receipt may reflect. These belong behind the strictest gate, because ρ can’t undo them.
Related
- LangGraph, the basics: a library for building the loop, with a checkpoint after every step so a run can pause for a human and resume.
- Native Claude web search vs self-hosted: one tool seen from both sides of the gate.