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.
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.
Here’s the shape of the thing before we look at any code.
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.
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
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.
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.
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.
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.