July 13, 2026 · Building

The Harness Is a While-Loop

What's actually inside an AI agent, in three small files you can read

These posts are live but still drafts — I'm figuring out how to write well with AI, and the tone and structure may shift as I go.

An AI agent is a program that uses an AI model to do real work on a computer. It reads files, runs tests, fixes a bug, and keeps going without you sitting there watching it.

I wanted to know what’s actually inside one. Not the diagram from the launch talk, the code. So I opened mini-swe-agent, which is about the smallest working example there is, and read all of it.

Last time I opened up GitHub’s app modernization agent and found that almost all of it was a folder of markdown files. That post was about the instructions somebody wrote. This one’s about the machine that reads them.

It comes down to four moving parts and three small files: 188 lines of Python for the loop, 92 lines for the bit that runs commands, and a 171-line system prompt written in plain English. That last number is the one I keep thinking about. The instructions are almost as long as the program.

To put that in perspective: the Windows Calculator that ships with Windows is around 35,000 lines. A browser like Chrome runs into the tens of millions. The thing you’re about to read, which fixes real bugs on its own, is a few hundred.

Those are the three files that matter, by the way, not the whole project. There’s a command line interface in there too, and plumbing for the different AI providers. But this is the spine, and the spine is what I wanted to see.

One promise before we start: you don’t need to read the Python. Just watch the left side for what’s happening, and the right side for how few lines it takes.

what’s bash?

Skip this if you already know.

Bash is the text-only way to drive a computer. Instead of clicking icons, you type a line, press enter, and the computer prints its answer back as text.

  • ls means “list the files here”
  • cat notes.txt means “print out this file”
  • pytest means “run the tests”

It’s the black window you’ve seen programmers staring at. Text goes in, text comes out.

And that’s why any of this works. Text in, text out is the shape of an AI model too, so nobody had to build the AI a special toolbox. They just handed it the same interface programmers have been using for decades, and it fit.

the four parts

Here’s the shape of the thing before we look at any code.

The four parts of an agent The system prompt, the rules written in English, is loaded once as the first message into the loop. The loop asks the model what to do and gets back a command. The loop hands that command to the environment, which runs it and returns the text it printed. THE SYSTEM PROMPT the rules, in plain English default.yaml loaded once, as the very first message THE LOOP + THE SEATBELTS ask, then act, then repeat with limits on money, turns, and time default.py THE MODEL the AI you rent, by the word someone else's server THE ENVIRONMENT a computer that runs bash commands local.py ask a command act what it printed Everything that comes back gets written into the history, and the history is what gets sent on the next turn.
The four parts. Three of them are separate files on purpose, so they can be swapped.

Three of those are files we’re about to open. The fourth is the model itself, which you rent from someone else, so there’s nothing to read.

They’re separate on purpose. The loop doesn’t know which AI it’s talking to, and it doesn’t know whether commands run on your laptop or on a machine in a data center. It just calls whatever got plugged in. Swap the environment out and the loop doesn’t change by a single character.

One more thing before the code: let’s give the agent a job and keep it in hand the whole way through. Fix the failing login test. Five words. Everything below exists to turn them into a passing test.

188 lines · github @ 6e0413c

what it plugs into

This file doesn't contain the AI, and it doesn't contain the computer. It imports them. A Model is the AI you rent. An Environment is a computer where commands actually run. Everything here is the glue between those two.

"""Basic agent class. See https://mini-swe-agent.com/latest/advanced/control_flow/ for visual explanation
or https://minimal-agent.com for a tutorial on the basic building principles.
"""

import json
import logging
import time
import traceback
from pathlib import Path

from jinja2 import StrictUndefined, Template
from pydantic import BaseModel

from minisweagent import Environment, Model, __version__
from minisweagent.exceptions import FormatError, InterruptAgentFlow, LimitsExceeded, TimeExceeded
from minisweagent.utils.serialize import recursive_merge


class AgentConfig(BaseModel):
    """Check the config files in minisweagent/config for example settings."""

    system_template: str
    """Template for the system message (the first message)."""
    instance_template: str
    """Template for the first user message specifying the task (the second message overall)."""
    step_limit: int = 0
    """Maximum number of steps the agent can take."""
    cost_limit: float = 3.0
    """Stop agent after exceeding (!) this cost."""
    wall_time_limit_seconds: int = 0
    """Stop agent after this many seconds of wall-clock time. 0 means no limit."""
    max_consecutive_format_errors: int = 3
    """Exit after this many format errors in a row (0 = no limit)."""
    output_path: Path | None = None
    """Save the trajectory to this path."""


class DefaultAgent:
    def __init__(self, model: Model, env: Environment, *, config_class: type = AgentConfig, **kwargs):
        """See the `AgentConfig` class for permitted keyword arguments."""
        self.config = config_class(**kwargs)
        self.messages: list[dict] = []
        self.model = model
        self.env = env
        self.extra_template_vars = {}
        self.logger = logging.getLogger("agent")
        self.cost = 0.0
        self.n_calls = 0
        self.n_consecutive_format_errors = 0
        self._start_time = time.time()

    def get_template_vars(self, **kwargs) -> dict:
        return recursive_merge(
            self.config.model_dump(),
            self.env.get_template_vars(),
            self.model.get_template_vars(),
            {
                "n_model_calls": self.n_calls,
                "model_cost": self.cost,
                "elapsed_seconds": int(time.time() - self._start_time),
            },
            self.extra_template_vars,
            kwargs,
        )

    def _render_template(self, template: str) -> str:
        return Template(template, undefined=StrictUndefined).render(**self.get_template_vars())

    def add_messages(self, *messages: dict) -> list[dict]:
        self.logger.debug(messages)  # set log level to debug to see
        self.messages.extend(messages)
        return list(messages)

    def handle_uncaught_exception(self, e: Exception) -> list[dict]:
        return self.add_messages(
            self.model.format_message(
                role="exit",
                content=str(e),
                extra={
                    "exit_status": type(e).__name__,
                    "submission": "",
                    "exception_str": str(e),
                    "traceback": traceback.format_exc(),
                },
            )
        )

    def run(self, task: str = "", **kwargs) -> dict:
        """Run step() until agent is finished. Returns dictionary with exit_status, submission keys."""
        self.extra_template_vars |= {"task": task, **kwargs}
        self.messages = []
        self.add_messages(
            self.model.format_message(role="system", content=self._render_template(self.config.system_template)),
            self.model.format_message(role="user", content=self._render_template(self.config.instance_template)),
        )
        while True:
            try:
                self.step()
                self.n_consecutive_format_errors = 0  # reset on any clean step
            except FormatError as e:
                self.n_consecutive_format_errors += 1
                if 0 < self.config.max_consecutive_format_errors <= self.n_consecutive_format_errors:
                    self.add_messages(
                        *e.messages,
                        {
                            "role": "exit",
                            "content": "RepeatedFormatError",
                            "extra": {"exit_status": "RepeatedFormatError", "submission": ""},
                        },
                    )
                else:
                    self.add_messages(*e.messages)
            except InterruptAgentFlow as e:
                self.add_messages(*e.messages)
            except Exception as e:
                self.handle_uncaught_exception(e)
                raise
            finally:
                self.save(self.config.output_path)
            if self.messages[-1].get("role") == "exit":
                break
        return self.messages[-1].get("extra", {})

    def step(self) -> list[dict]:
        """Query the LM, execute actions."""
        return self.execute_actions(self.query())

    def query(self) -> dict:
        """Query the model and return model messages. Override to add hooks."""
        if 0 < self.config.step_limit <= self.n_calls or 0 < self.config.cost_limit <= self.cost:
            raise LimitsExceeded(
                {
                    "role": "exit",
                    "content": "LimitsExceeded",
                    "extra": {"exit_status": "LimitsExceeded", "submission": ""},
                }
            )
        if 0 < self.config.wall_time_limit_seconds <= int(time.time() - self._start_time):
            raise TimeExceeded(
                {
                    "role": "exit",
                    "content": "TimeExceeded",
                    "extra": {"exit_status": "TimeExceeded", "submission": ""},
                }
            )
        self.n_calls += 1
        message = self.model.query(self.messages)
        self.cost += message.get("extra", {}).get("cost", 0.0)
        self.add_messages(message)
        return message

    def execute_actions(self, message: dict) -> list[dict]:
        """Execute actions in message, add observation messages, return them."""
        outputs = [self.env.execute(action) for action in message.get("extra", {}).get("actions", [])]
        return self.add_messages(*self.model.format_observation_messages(message, outputs, self.get_template_vars()))

    def serialize(self, *extra_dicts) -> dict:
        """Serialize agent state to a json-compatible nested dictionary for saving."""
        last_message = self.messages[-1] if self.messages else {}
        last_extra = last_message.get("extra", {})
        agent_data = {
            "info": {
                "model_stats": {
                    "instance_cost": self.cost,
                    "api_calls": self.n_calls,
                },
                "config": {
                    "agent": self.config.model_dump(mode="json"),
                    "agent_type": f"{self.__class__.__module__}.{self.__class__.__name__}",
                },
                "mini_version": __version__,
                "exit_status": last_extra.get("exit_status", ""),
                "submission": last_extra.get("submission", ""),
            },
            "messages": self.messages,
            "trajectory_format": "mini-swe-agent-1.1",
        }
        return recursive_merge(agent_data, self.model.serialize(), self.env.serialize(), *extra_dicts)

    def save(self, path: Path | None, *extra_dicts) -> dict:
        """Save the trajectory of the agent to a file if path is given. Returns full serialized data.
        You can pass additional dictionaries with extra data to be (recursively) merged into the output data.
        """
        data = self.serialize(*extra_dicts)
        if path:
            path.parent.mkdir(parents=True, exist_ok=True)
            path.write_text(json.dumps(data, indent=2))
        return data

one run, start to finish

Put the pieces together and watch the login job actually happen.

Turn one: the history holds two messages, the system prompt and fix the failing login test. The harness sends both to the model and waits. (When a tool shows you a spinner that says “Thinking”, this wait is usually all that’s happening: your computer idle, a reply on its way back over the internet.) The model answers with a sentence of reasoning, the THOUGHT the rules asked it to show, and one command: run the failing test. The harness runs it, and the error that prints gets added to the history.

Turn two: the harness sends the whole history again, from the top. The model doesn’t remember turn one. It re-reads the story so far and does what anyone who just saw that error would do: asks to open the login code. The file’s contents come back and join the history too.

And around it goes. Edit the file, run the test again, check the edge cases, one command per turn, every result stapled to the history. Some turns later the test passes, the model prints the magic phrase, and the run ends.

Notice what the “memory” is: nothing but that list. The model gets the entire conversation again every single turn, reads the lot, writes one reply, and forgets you. The continuity you feel, the sense of something working away at your problem, is a list being re-read from the top a few dozen times. Claude Code and the other tools you’ve used run a conversation on top of this same loop; you can type between turns, but while it’s working, this is the picture.

what a full harness has

This is the honest minimum. The agents you’ve actually used, like Claude Code or Cursor, do more, and LangChain’s write-up lays out the full menu of parts a harness can have. Here’s the whole list, and where this tiny one lands.

what a harness can have this one
  • the system prompt the written rules for how the model behaves. That's default.yaml. has it
  • a way to act one tool, bash, for touching the computer. Bigger harnesses add more here; this one stops at bash. has it
  • a sandbox a sealed, throwaway computer, so a bad command can't touch your real files. This one runs on your own machine. skips it
  • memory and search remembering your project between sessions, or looking things up online. skips it
  • context management trimming the conversation once it grows too long to send. Here it just keeps growing. skips it
  • orchestration spawning helper agents and routing work between them. skips it
  • hooks and middleware extra scaffolding around the model to keep it predictable. skips it
  • planning and self-verification the agent makes a plan, then checks its own work. as English

That last row deserves a second look. Planning and self-verification is the kind of thing you’d expect to be a system, some module buried in the code. Here it isn’t. It’s the numbered list you saw in default.yaml, the one telling the model to reproduce the bug first and check the fix afterwards. Something you’d expect to find in the architecture turns out to be a paragraph somebody wrote in English.

so what’s an agent?

A loop that asks a model what to do and then does it. A short list of limits, which somebody still has to switch on. A way to run commands. And a page of English telling it how to behave.

This is the simplest harness that actually works, and here’s why that’s worth taking seriously. There’s a standard test called SWE-bench Verified: 500 real bugs from real open-source projects, the kind a developer gets handed on a Monday. Fixing one means opening the repo, finding the broken code, running the tests, and trying again when you got it wrong.

Hand those same bugs to a model in a chat window, with no way to open the project or run anything, and it solves almost none of them. Not because it isn’t smart enough. Because it never gets to touch the code. Wrap that exact same model in the loop you just read, and it resolves more than 74%, roughly three out of four.

That’s the whole point of a harness. The intelligence was already there, sitting in the chat window. What it was missing was hands: a way to open the project, run the tests, and read what came back. A few hundred lines of Python hand it the keyboard. The model didn’t get smarter; it finally got to touch the code.

Source: mini-swe-agent, read at commit 6e0413c (June 2026). Every line count here comes from the exact files shown above. The project’s still being worked on, so if you open it today the numbers may have moved.