Guide contents

Lesson 72 of 73

Reading Coding Harness Codebases

A coding agent can seem mysterious from the outside: you type a request, and files change. Inside, it is ordinary code. This lesson opens the source of five real coding agents and shows where each one keeps the same parts that the loop agent of this course has: the loop, the tools, the prompts, the model connection, the permission checks, and the saved state.

A harness is the program that wraps a language model and turns its replies into actions. It sends the conversation to the model, runs the tool calls the model asks for (for example "read this file" or "run this command"), and feeds the results back to the model. The model only produces text. The harness is what makes that text do something.

Reading other people's code is the fastest way to learn which design choices exist and what each one costs. After this lesson you can open an unfamiliar harness, find each of its parts in a few minutes, and explain how it differs from the others. The five projects are:

Project Version read here Language Shape
mini-swe-agent 2.4.6 Python One agent class, one bash tool, a YAML prompt
Pi (@mariozechner/pi-coding-agent) 0.73.1 TypeScript, read as compiled JavaScript Session layer over a separate agent-core package, seven tools, extensions
Aider 0.86.2 Python Coder classes per edit format, a repository map, git commits
OpenHands SDK 1.51.0 Python Library: agent, conversation, tools, condensers, security policy
Crush v0.97.1 Go Terminal app: session agent, many tools, permission service, SQLite

You do not need to know Python, TypeScript, or Go. The code excerpts are short and each one is explained in plain words. The Java programs are the part to run and study.

Every fact about a project in this lesson comes from its source at the version in the table. Paths are relative to each project's root: minisweagent/ for mini-swe-agent, the npm package root for Pi (dist/, docs/), aider/ for Aider, openhands/sdk/ for the OpenHands SDK, and the module root for Crush (internal/). Pi is published as compiled JavaScript with source maps (files that map the compiled code back to the original TypeScript). Its agent loop lives in a separate package (@mariozechner/pi-agent-core) whose code is not in the published coding-agent package, so the readings say where that limits what can be shown. Codex CLI and OpenCode are other coding harnesses. Their npm packages contain launchers for prebuilt binaries rather than source, so this lesson does not read them.

The Java programs reproduce one mechanism each, on small inputs, so you can watch the behavior run. They are written for this lesson and are shorter than the originals. Each lead-in names the function it follows, so you can open the original and compare.

Example 1: a method for reading a harness

A large codebase is hard to read from the first file to the last. A better plan is to know in advance what you are looking for. Every harness has the same six parts, and reading goes faster when you find them in a fixed order.

Part Question to answer Typical file names
Entry point Which command starts it, and what does it build? main, cli, cmd/, run/
Loop Where is the model called, and when does the loop stop? agent, coder, conversation, session
Tools Which actions exist, and how is a call turned into an action? tools/, environments/, the edit format
Prompts What does the model see before the first user message? prompts, templates/, YAML configs
Providers How does the request reach a model API? models, llm, provider
State What is saved, where, and how is a session resumed? session, history, db/

A provider here means the service or server that hosts the model, such as OpenAI's API or a local server on your machine. The provider layer is the code that sends requests to it.

The first step is to measure. Line counts show which files are worth opening first and how much code each part takes. The command below uses wc -l (word count, lines only), which prints the number of lines in each file. It runs from a directory that holds the five source trees.

Terminal
wc -l mini-swe-agent/minisweagent/agents/default.py mini-swe-agent/minisweagent/environments/local.py \
      aider/aider/coders/base_coder.py aider/aider/coders/editblock_coder.py aider/aider/repomap.py \
      openhands-sdk/openhands/sdk/agent/agent.py openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py \
      crush/internal/agent/agent.go crush/internal/permission/permission.go \
      pi/dist/core/agent-session.js pi/dist/core/system-prompt.js

The output below comes from the reference source trees. The paths are shortened to the project and the path inside it.

Output
   190 minisweagent/agents/default.py
    92 minisweagent/environments/local.py
  2485 aider/coders/base_coder.py
   657 aider/coders/editblock_coder.py
   867 aider/repomap.py
  1622 openhands/sdk/agent/agent.py
  3172 openhands/sdk/conversation/impl/local_conversation.py
  2428 crush/internal/agent/agent.go
   310 crush/internal/permission/permission.go
  2520 pi/dist/core/agent-session.js
   119 pi/dist/core/system-prompt.js
 14462 total

Look at the first two lines. The whole mini-swe-agent loop is 190 lines, and its only tool (running a shell command) is 92 lines. The loops of Aider, OpenHands, Crush, and Pi are each over 1,600 lines. The difference does not come from a more complicated loop. It comes from the features built around the loop: streaming replies, retries, compaction (shrinking a long chat), permissions, and user interfaces. Keep this in mind while reading the later examples. The loop is small everywhere, and most of the code is what surrounds it.

The second step is to sort file names into the six parts. The program below does this with one regular expression (a text pattern, here for file paths) per part, on real paths from three of the projects. Each path is tested against the patterns in TEST_ORDER, and the first part whose pattern matches wins. Paths that match nothing go to "unmatched". Look at which files land where, and at the rows that show a dash.

Java
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

public class HarnessMap {
    // The six parts of a harness, each with a path pattern that usually finds it.
    static final Map<String, Pattern> PARTS = new LinkedHashMap<>();
    static {
        PARTS.put("entry", Pattern.compile("(main|cli|__main__)\\.(py|js|go)$|/run/|/cmd/"));
        PARTS.put("loop", Pattern.compile("agents?/[a-z_]+\\.(py|go)$|base_coder|agent-session"));
        PARTS.put("tools", Pattern.compile("/tools/|environments/|editblock_coder"));
        PARTS.put("prompts", Pattern.compile("prompt|templates/|config/.*\\.yaml$"));
        PARTS.put("providers", Pattern.compile("models?(\\.py|/)|coordinator"));
        PARTS.put("state", Pattern.compile("session|history|/db/"));
    }
    static final List<String> TEST_ORDER = List.of("prompts", "tools", "entry", "loop", "providers", "state");

    static Map<String, List<String>> classify(List<String> paths) {
        Map<String, List<String>> out = new LinkedHashMap<>();
        for (String part : PARTS.keySet()) out.put(part, new ArrayList<>());
        out.put("unmatched", new ArrayList<>());
        for (String p : paths) {
            String part = TEST_ORDER.stream().filter(k -> PARTS.get(k).matcher(p).find())
                    .findFirst().orElse("unmatched");
            out.get(part).add(p);
        }
        return out;
    }

    public static void main(String[] args) {
        Map<String, List<String>> projects = new LinkedHashMap<>();
        projects.put("mini-swe-agent 2.4.6", List.of("minisweagent/run/mini.py",
                "minisweagent/agents/default.py", "minisweagent/environments/local.py",
                "minisweagent/config/mini.yaml", "minisweagent/models/litellm_model.py"));
        projects.put("aider 0.86.2", List.of("aider/main.py", "aider/coders/base_coder.py",
                "aider/coders/editblock_coder.py", "aider/coders/editblock_prompts.py",
                "aider/models.py", "aider/history.py", "aider/repomap.py"));
        projects.put("crush v0.97.1", List.of("internal/cmd/root.go", "internal/agent/agent.go",
                "internal/agent/tools/bash.go", "internal/agent/templates/coder.md.tpl",
                "internal/agent/coordinator.go", "internal/db/sessions.sql.go"));
        for (var project : projects.entrySet()) {
            System.out.println(project.getKey());
            classify(project.getValue()).forEach((part, files) ->
                    System.out.printf("  %-9s %s%n", part, files.isEmpty() ? "-" : String.join(", ", files)));
        }
    }
}
Output
mini-swe-agent 2.4.6
  entry     minisweagent/run/mini.py
  loop      minisweagent/agents/default.py
  tools     minisweagent/environments/local.py
  prompts   minisweagent/config/mini.yaml
  providers minisweagent/models/litellm_model.py
  state     -
  unmatched -
aider 0.86.2
  entry     aider/main.py
  loop      aider/coders/base_coder.py
  tools     aider/coders/editblock_coder.py
  prompts   aider/coders/editblock_prompts.py
  providers aider/models.py
  state     aider/history.py
  unmatched aider/repomap.py
crush v0.97.1
  entry     internal/cmd/root.go
  loop      internal/agent/agent.go, internal/agent/coordinator.go
  tools     internal/agent/tools/bash.go
  prompts   internal/agent/templates/coder.md.tpl
  providers -
  state     internal/db/sessions.sql.go
  unmatched -

Most files land in the right part from their names alone. Three results show why the name is only a starting point.

  • mini-swe-agent has no file for state. The trajectory (the saved record of the run) is written by save() inside agents/default.py, the same file as the loop.
  • Crush's internal/agent/coordinator.go lands in "loop" because of its directory, but it is also where Crush builds providers (Example 14).
  • Aider's history.py lands in "state", yet it holds ChatSummary, which summarizes old messages (Example 15). Aider's chat history file is written by io.py.

And repomap.py matches no part at all. The repository map is a context feature that the six-part list does not name. The method is: sort by name, then open each candidate and confirm what it does.

Example 2: mini-swe-agent, the smallest complete loop

The first question is what the smallest working coding agent contains. mini-swe-agent answers it with very little code, so it is the best place to start. Open minisweagent/agents/default.py (mini-swe-agent 2.4.6). The whole run is one method. It renders the system message and the first user message from templates, then repeats step() forever (the while True) until something stops it:

Textminisweagent/agents/default.py
    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()

step() is one line, return self.execute_actions(self.query()). query() checks the limits, calls the model, and appends its reply. execute_actions() runs each action in the environment and appends the outputs as new messages. An action is a command the model asked to run. The environment is the object that actually runs it.

Textminisweagent/agents/default.py
    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()))

An observation is the message that carries a tool's output back to the model. So one pass of the loop is: ask the model, run what it asked for, show it the result.

Text
messages -> query() -> model reply (with actions)
                          |
                          v
        execute_actions() -> env.execute(action) -> output
                          |
                          v
          observation message appended -> next step()

The loop has no if for "the task is done". Stopping is done with exceptions. An exception is a Java-like error object that jumps out of the normal flow. minisweagent/exceptions.py defines InterruptAgentFlow with subclasses Submitted, LimitsExceeded, TimeExceeded, UserInterruption, and FormatError. Each exception carries the messages to append. run() catches it, appends the messages, and leaves the loop when the last message has the role exit. Using exceptions means that any code deep inside a step can end the run or add a message without passing a status value back up through every method.

The environment raises Submitted. minisweagent/environments/local.py runs every command with subprocess.Popen(command, shell=True, ...) in a new process group, with stderr merged into stdout and a default timeout of 30 seconds. A process group is a set of processes that can be signalled together, which matters when a command has to be killed along with its children. After each command it checks the first output line:

Textminisweagent/environments/local.py
        lines = output.get("output", "").lstrip().splitlines(keepends=True)
        if lines and lines[0].strip() == "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT" and output["returncode"] == 0:
            submission = "".join(lines[1:])

So the model finishes by running echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT, and the system prompt in config/mini.yaml tells it so. The model "submits" by printing an agreed keyword through the only tool it has, and the environment recognizes that keyword. The lines after the first one become the submission.

Three more facts complete the picture.

  • Every command runs in a new subshell, so cd does not persist from one command to the next. The prompt says this and shows cd /path && ... as the way around it.
  • Limits live in AgentConfig: step_limit (0 means none), cost_limit (default 3.0), wall_time_limit_seconds, and max_consecutive_format_errors (default 3).
  • run() calls self.save(self.config.output_path) in a finally block, so the trajectory file is rewritten after every step, even when a step fails.

Example 3: mini-swe-agent's loop in Java

This example checks two behaviors of the exception-driven loop: what happens when the model makes a format mistake, and what happens when it hits the step limit. The program follows DefaultAgent.run, query, and execute_actions with a scripted model, which means a list of canned replies instead of a real LLM, so the output is the same every time.

It uses the text-based action format from models/utils/actions_text.py. In this format the model writes its command inside a fenced block tagged mswea_bash_command, and the parser requires exactly one such block per reply. The exit message in the Java program stores the exit status in its content. In mini-swe-agent the status is in extra.exit_status and the content holds the submission. The program is simplified in that one place.

Read run and step first. step throws an exception for the three special cases (limit reached, wrong number of actions, submission), and run catches it, appends its message, and checks whether the last message is the exit message.

Java
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class MiniLoop {
    // Control flow by exception, as in minisweagent/exceptions.py: each one carries the message to append.
    static class InterruptAgentFlow extends RuntimeException {
        final Map<String, String> message;
        InterruptAgentFlow(Map<String, String> m) { super(null, null, false, false); message = m; }
    }
    static class Submitted extends InterruptAgentFlow { Submitted(Map<String, String> m) { super(m); } }
    static class LimitsExceeded extends InterruptAgentFlow { LimitsExceeded(Map<String, String> m) { super(m); } }
    static class FormatError extends InterruptAgentFlow { FormatError(Map<String, String> m) { super(m); } }

    static final Pattern ACTION = Pattern.compile("```mswea_bash_command\\s*\\n(.*?)\\n```", Pattern.DOTALL);
    final List<Map<String, String>> messages = new ArrayList<>();
    final Iterator<String> model;   // scripted replies instead of an LLM
    final int stepLimit;
    int nCalls;

    MiniLoop(List<String> replies, int stepLimit) { model = replies.iterator(); this.stepLimit = stepLimit; }

    String run(String task) {
        messages.add(Map.of("role", "system", "content", "You can run bash."));
        messages.add(Map.of("role", "user", "content", "Please solve this issue: " + task));
        while (true) {
            try { step(); } catch (InterruptAgentFlow e) { messages.add(e.message); }
            if (messages.getLast().get("role").equals("exit")) return messages.getLast().get("content");
        }
    }

    void step() {
        if (0 < stepLimit && stepLimit <= nCalls)
            throw new LimitsExceeded(Map.of("role", "exit", "content", "LimitsExceeded"));
        nCalls++;
        String reply = model.next();
        Matcher m = ACTION.matcher(reply);       // parsing happens inside model.query()
        List<String> actions = new ArrayList<>();
        while (m.find()) actions.add(m.group(1).strip());
        if (actions.size() != 1) throw new FormatError(Map.of("role", "user", "content",
                "Please always provide EXACTLY ONE action in triple backticks, found " + actions.size() + " actions."));
        messages.add(Map.of("role", "assistant", "content", reply));
        String output = execute(actions.get(0));
        if (output.startsWith("COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT"))
            throw new Submitted(Map.of("role", "exit", "content", "Submitted"));
        messages.add(Map.of("role", "user", "content", "<returncode>0</returncode>\n<output>\n" + output + "</output>"));
    }

    static String execute(String command) {   // stands in for LocalEnvironment.execute
        return switch (command) {
            case "ls" -> "app.py\ntest_app.py\n";
            case "echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT" -> "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT\n";
            default -> "";
        };
    }

    static String bash(String cmd) { return "```mswea_bash_command\n" + cmd + "\n```"; }

    public static void main(String[] args) {
        MiniLoop ok = new MiniLoop(List.of("Look first.\n" + bash("ls"), "The fix is obvious.",
                "Done.\n" + bash("echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT")), 0);
        System.out.println("exit: " + ok.run("fix the bug") + ", calls: " + ok.nCalls);
        for (var msg : ok.messages)
            System.out.printf("  %-9s %s%n", msg.get("role"), msg.get("content").lines().findFirst().orElse(""));
        MiniLoop capped = new MiniLoop(List.of(bash("ls"), bash("ls"), bash("ls")), 2);
        System.out.println("exit: " + capped.run("fix the bug") + ", calls: " + capped.nCalls);
    }
}
Output
exit: Submitted, calls: 3
  system    You can run bash.
  user      Please solve this issue: fix the bug
  assistant Look first.
  user      <returncode>0</returncode>
  user      Please always provide EXACTLY ONE action in triple backticks, found 0 actions.
  assistant Done.
  exit      Submitted
exit: LimitsExceeded, calls: 2

Trace the first run, which makes three model calls.

  1. The first reply has one action, ls. An assistant message and an observation (<returncode>0</returncode> and the output) are appended.
  2. The second reply has no action block. The parser raises FormatError before the reply is appended, so the history gets only the user message with the error text.
  3. The third reply runs the completion command. The environment raises Submitted, and the loop appends the exit message and returns.

The order in step 2 matches the real code. LitellmModel.query parses the actions before returning the message. On failure it stores the raw reply in the error message's extra field, which is removed before messages are sent to the model. The model therefore sees its instruction to fix the format, not its own malformed reply. This avoids showing the model a wrong example of its own to copy.

The second run has stepLimit 2. Two calls happen. Before the third, query raises LimitsExceeded, which ends the run with exit status LimitsExceeded. The check runs before the call is made, so a limit of 2 means at most two model calls.

Example 4: Pi, the agent loop and its tools

The question now is where Pi's loop is and which tools it gives the model. Open dist/core/agent-session.js (Pi 0.73.1). Its header comment says what the class does:

Textdist/core/agent-session.js
 * AgentSession - Core abstraction for agent lifecycle and session management.
 *
 * This class is shared between all run modes (interactive, print, rpc).

AgentSession does not call the model itself. It wraps an Agent object from @mariozechner/pi-agent-core. The file docs/sdk.md states that "The Agent class (from @mariozechner/pi-agent-core) handles the core LLM interaction". That package is not part of the published coding-agent package, so the request loop itself cannot be read here. What AgentSession adds can be read. It subscribes to the agent's events, saves each finished message to the session file, retries on provider errors, and checks for compaction after each run. It also installs two hooks on the agent. A hook is a function the framework calls at a fixed moment, so that other code can react. Here is the first one:

Textdist/core/agent-session.js
        this.agent.beforeToolCall = async ({ toolCall, args }) => {
            const runner = this._extensionRunner;
            if (!runner.hasHandlers("tool_call")) {
                return undefined;
            }

beforeToolCall passes each call to extensions, which may block it. afterToolCall passes each result to extensions, which may replace it. An extension is a module that users add to Pi to change its behavior (more on extensions in Example 6).

The order of events in one prompt is documented in docs/extensions.md: agent_start, then turns that repeat while the model calls tools, then agent_end. Inside each turn the events are turn_start, context, before_provider_request, the model reply, tool_call, tool_result, and turn_end. A turn here is one model reply plus the tool calls it makes.

The tools are in dist/core/tools/. index.js lists them:

Textdist/core/tools/index.js
export const allToolNames = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);

createCodingTools returns four of them (read, bash, edit, write), which is the default set. createReadOnlyTools returns read, grep, find, and ls, a set that cannot change anything. Tool output is cut by truncate.js at DEFAULT_MAX_LINES = 2000 or DEFAULT_MAX_BYTES = 50 * 1024, whichever comes first. Cutting keeps one huge output (a large log, for example) from filling the model's context.

docs/extensions.md states that tool calls from one assistant message run in parallel by default: they are checked one after another, then executed concurrently. Parallel edits create a danger. Two edits to the same file at the same moment could overwrite each other. So edit and write go through a per-file queue (file-mutation-queue.js), which makes writes to one file wait for each other.

The edit tool takes a path and a list of edits, each with oldText and newText. Its schema description in edit.js states the rules the model must follow: every oldText must match a unique region of the original file, and edits must not overlap. Example 5 shows why these two rules exist.

Example 5: Pi's edit tool in Java

An edit tool must change a file safely when the model describes the change as text. This example shows what Pi's edit tool does with several edits in one call. The program follows applyEditsToNormalizedContent in dist/core/tools/edit-diff.js for exact matches. Each oldText is found in the original content and counted. Then the edits are checked for overlap with each other, and then all replacements are applied from the last to the first.

The program runs three calls on the same small file. The first is valid. The second uses an oldText that is not unique. The third has two edits that overlap. Watch which error message each one gives.

Java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public class PiEdits {
    record Edit(String oldText, String newText) {}
    record Match(int editIndex, int at, int length, String newText) {}

    static int count(String text, String needle) {
        int n = 0;
        for (int i = text.indexOf(needle); i >= 0; i = text.indexOf(needle, i + needle.length())) n++;
        return n;
    }

    // Mirrors applyEditsToNormalizedContent in dist/core/tools/edit-diff.js (exact matching only).
    static String apply(String content, List<Edit> edits, String path) {
        List<Match> matches = new ArrayList<>();
        for (int i = 0; i < edits.size(); i++) {
            Edit e = edits.get(i);
            int at = content.indexOf(e.oldText());
            if (at < 0) throw new IllegalArgumentException("Could not find edits[" + i + "] in " + path);
            int n = count(content, e.oldText());
            if (n > 1 && edits.size() == 1) throw new IllegalArgumentException("Found " + n
                    + " occurrences of the text in " + path + ". The text must be unique.");
            if (n > 1) throw new IllegalArgumentException("Found " + n + " occurrences of edits[" + i + "] in "
                    + path + ". Each oldText must be unique.");
            matches.add(new Match(i, at, e.oldText().length(), e.newText()));
        }
        matches.sort(Comparator.comparingInt(Match::at));
        for (int i = 1; i < matches.size(); i++) {
            Match prev = matches.get(i - 1), cur = matches.get(i);
            if (prev.at() + prev.length() > cur.at())
                throw new IllegalArgumentException("edits[" + prev.editIndex() + "] and edits[" + cur.editIndex()
                        + "] overlap in " + path + ". Merge them into one edit or target disjoint regions.");
        }
        String out = content;
        for (int i = matches.size() - 1; i >= 0; i--) {       // last first, so earlier offsets stay valid
            Match m = matches.get(i);
            out = out.substring(0, m.at()) + m.newText() + out.substring(m.at() + m.length());
        }
        return out;
    }

    static void attempt(String content, List<Edit> edits) {
        try { System.out.print(apply(content, edits, "Cart.java")); }
        catch (IllegalArgumentException e) { System.out.println("error: " + e.getMessage()); }
    }

    public static void main(String[] args) {
        String file = "int total = 0;\nfor (Item i : items) total += i.price;\nreturn total;\n";
        attempt(file, List.of(new Edit("int total = 0;", "long total = 0;"),
                              new Edit("return total;", "return Math.toIntExact(total);")));
        attempt(file, List.of(new Edit("total", "sum")));
        attempt(file, List.of(new Edit("int total = 0;\nfor", "long total = 0;\nfor"),
                              new Edit("0;\nfor (Item i", "0;\nfor (var i")));
    }
}
Output
long total = 0;
for (Item i : items) total += i.price;
return Math.toIntExact(total);
error: Found 3 occurrences of the text in Cart.java. The text must be unique.
error: edits[0] and edits[1] overlap in Cart.java. Merge them into one edit or target disjoint regions.

The first call has two edits on different lines. Both are found once, they do not overlap, and the result has both changes. The replacements are applied at the higher offset first. A character offset is the position of a piece of text inside the file. If the earlier edit were applied first and changed the text length, the later edit's offset would point to the wrong place. Working from the end keeps the earlier offsets valid.

Text
file:   int total = 0;\nfor ...\nreturn total;\n
edit 0: at offset 0  (length 14)   <- applied second
edit 1: at offset 54 (length 13)   <- applied first

The second call has one edit whose oldText is total, which occurs three times. Pi refuses it with a message that names the count. With a single edit, the real message uses the words "the text"; with several edits it names edits[i]. Both continue with "Please provide more context to make it unique." The model's next step is to resend a longer oldText, for example a whole line.

The third call has two edits that both cover 0;\nfor. They are matched against the same original content, so the second edit's position falls inside the first one's region, and Pi rejects the call with the overlap message. Pi does not apply some edits and skip others. One failing edit rejects the whole call, and the file is unchanged. This all-or-nothing rule means the model never has to guess which part of a call took effect.

The real function has one more step that this program leaves out. When an exact match fails, fuzzyFindText retries after normalizing both texts. Normalizing means rewriting both texts into a standard form before comparing. The steps are:

  • NFKC normalization (a Unicode standard form that merges look-alike characters).
  • Trailing spaces removed from each line.
  • Smart quotes turned into ASCII quotes.
  • Unicode dashes turned into -.
  • Special spaces turned into a plain space.

If any edit needs this fuzzy match, all edits run on the normalized content, so the saved file then has normalized whitespace and quotes.

Example 6: Pi, system prompt, sessions, and extensions

This example covers what Pi sends before the first user message and how a session is stored. The system prompt is the first message of a conversation. It tells the model who it is, which tools it has, and how to behave.

Open dist/core/system-prompt.js. buildSystemPrompt has two branches. With a custom prompt, it uses that prompt. Without one, it writes its own:

Textdist/core/system-prompt.js
    let prompt = `You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.

Available tools:
${toolsList}

The tool list contains only tools that have a one-line snippet. Guidelines follow, chosen by which tools are active. With bash but no grep, find, or ls, the prompt says "Use bash for file operations like ls, rg, find". With both, it says "Prefer grep/find/ls tools over bash for file exploration". Two guidelines are always present ("Be concise in your responses", "Show file paths clearly when working with files"). Then come pointers to Pi's own documentation, the project context files, the skills list, and two final lines: Current date: and Current working directory:.

Project context files are files in your repository that hold instructions for the agent, such as a list of coding rules. They come from dist/core/resource-loader.js. In each directory it looks for AGENTS.md, AGENTS.MD, CLAUDE.md, or CLAUDE.MD and takes the first that exists. It reads the global one in Pi's agent directory first, then walks from the working directory up to the file-system root, and orders the found files from the root down. So a repository-wide AGENTS.md appears before a more specific one in a subdirectory, and each appears under a ## <path> heading in a # Project Context section. The more specific instructions come later, nearer to the user's request.

Sessions are described in docs/session-format.md. A session is a JSONL file, which means a file with one JSON object per line. It sits under ~/.pi/agent/sessions/--<path>--/<timestamp>_<uuid>.jsonl. The first line is a header with type: "session", a version (3), and the working directory. Every later line is an entry with an 8-character hex id and a parentId. These two fields turn the file into a tree, as the format document says:

Textdocs/session-format.md
Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field. Session entries form a tree structure via `id`/`parentId` fields, enabling in-place branching without creating new files.

Entry types include:

  • message: user, assistant, and tool result messages.
  • model_change and thinking_level_change: records of settings changes.
  • compaction and branch_summary: summaries of earlier parts.
  • custom: state saved by extensions, never sent to the model.

session-manager.js writes each entry with appendFileSync, which adds one line at the end of the file, so a crash loses at most the entry being written.

Extensions are TypeScript modules loaded from ~/.pi/agent/extensions/ or .pi/extensions/. docs/extensions.md lists what they can do: register tools with pi.registerTool(), register commands, and handle events. A tool_call handler that returns { block: true, reason } stops a call. Pi has no built-in approval prompts. Its README says "No permission popups" and points to containers or extensions for confirmation flows. Pi also has no built-in MCP client (MCP, the Model Context Protocol, is a standard way to plug tools into agents); the README states that MCP support can be added as an extension.

Example 7: Pi's session tree in Java

Imagine you ask an agent for a change, dislike the result, go back two steps, and ask for something different. A flat list of messages cannot hold both paths. This example shows how one JSONL file holds two branches of a conversation and which messages the model sees on each branch.

The program parses a small session in the format of docs/session-format.md (timestamps left out) with Jackson, a common Java JSON library. Then it walks from each branch tip to the root by parentId. A leaf is an entry that no other entry names as its parent, so it is the tip of a branch. Before reading the output, find the entries a5 and b5 in the data: both have a4 as their parent.

Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class PiSessionTree {
    // A session file in the shape of docs/session-format.md (version 3): a header, then entries with id/parentId.
    static final String JSONL = """
        {"type":"session","version":3,"id":"s-1","timestamp":"2026-10-03T09:00:00.000Z","cwd":"/home/dev/shop"}
        {"type":"message","id":"a1","parentId":null,"message":{"role":"user","content":"Add a discount field"}}
        {"type":"message","id":"a2","parentId":"a1","message":{"role":"assistant","content":"[toolCall edit Cart.java]"}}
        {"type":"message","id":"a3","parentId":"a2","message":{"role":"toolResult","content":"edit applied"}}
        {"type":"message","id":"a4","parentId":"a3","message":{"role":"assistant","content":"Added int discount"}}
        {"type":"message","id":"a5","parentId":"a4","message":{"role":"user","content":"Now add tests"}}
        {"type":"message","id":"b5","parentId":"a4","message":{"role":"user","content":"Use BigDecimal instead"}}
        {"type":"model_change","id":"b6","parentId":"b5","provider":"local","modelId":"qwen2.5-coder-7b"}
        {"type":"message","id":"b7","parentId":"b6","message":{"role":"assistant","content":"[toolCall edit Cart.java]"}}
        """;

    public static void main(String[] args) throws Exception {
        ObjectMapper json = new ObjectMapper();
        Map<String, JsonNode> byId = new HashMap<>();
        List<String> order = new ArrayList<>();
        for (String line : JSONL.strip().split("\n")) {
            JsonNode entry = json.readTree(line);
            if (entry.get("type").asText().equals("session")) {
                System.out.println("session " + entry.get("id").asText() + " in " + entry.get("cwd").asText());
                continue;
            }
            byId.put(entry.get("id").asText(), entry);
            order.add(entry.get("id").asText());
        }
        for (String leaf : List.of("a5", "b7")) {             // the two branch tips
            List<String> path = new ArrayList<>();
            for (JsonNode e = byId.get(leaf); e != null; e = byId.get(e.path("parentId").asText(null)))
                path.add(e.get("id").asText());
            Collections.reverse(path);
            System.out.println("leaf " + leaf + " -> path " + path);
            for (String id : path) {
                JsonNode e = byId.get(id);
                if (e.get("type").asText().equals("message"))
                    System.out.printf("    %-10s %s%n", e.at("/message/role").asText(), e.at("/message/content").asText());
                else
                    System.out.printf("    (%s: %s, not sent to the model)%n", e.get("type").asText(), e.get("modelId").asText());
            }
        }
        List<String> leaves = new ArrayList<>(order);
        byId.values().forEach(e -> leaves.remove(e.path("parentId").asText("")));
        System.out.println("entries: " + order.size() + ", leaves: " + leaves);
    }
}
Output
session s-1 in /home/dev/shop
leaf a5 -> path [a1, a2, a3, a4, a5]
    user       Add a discount field
    assistant  [toolCall edit Cart.java]
    toolResult edit applied
    assistant  Added int discount
    user       Now add tests
leaf b7 -> path [a1, a2, a3, a4, b5, b6, b7]
    user       Add a discount field
    assistant  [toolCall edit Cart.java]
    toolResult edit applied
    assistant  Added int discount
    user       Use BigDecimal instead
    (model_change: qwen2.5-coder-7b, not sent to the model)
    assistant  [toolCall edit Cart.java]
entries: 8, leaves: [a5, b7]

The file has eight entries after the header. Entries a5 and b5 both have a4 as parent. The user went back to a4 (Pi's /tree command does this) and asked something else.

Text
a1 -> a2 -> a3 -> a4 -+-> a5                  (branch 1)
                      |
                      +-> b5 -> b6 -> b7      (branch 2)

Walking from a5 to the root gives five entries, in order, and those are the messages of the first branch. Walking from b7 gives seven entries that share the first four with the other branch. The model_change entry lies on the second path but is not a message, so the program does not print it as one. Entries like this record events for the session, and only message entries (and summaries) become model input. The last line finds the leaves by removing every id that appears as a parent. The two leaves are the two branch tips.

The design has a cost and a benefit. Reading a session means loading every entry and rebuilding the path, but writing never rewrites earlier lines. A harness that stores a flat list of messages must either copy the history to a new file for each branch or keep only one branch.

Example 8: Aider, coders and edit formats

Aider takes a different approach from the two harnesses above. This example asks how Aider turns a model reply into file changes. Aider has no tool calls for editing. The model writes edits as plain text in a fixed edit format, and a coder class parses that text. A parser is code that reads text and extracts structure from it, here the file name, the old lines, and the new lines.

Open aider/coders/. Each format has a coder and a prompts file (editblock_coder.py and editblock_prompts.py, wholefile_coder.py, udiff_coder.py, patch_coder.py, and others). The prompts file teaches the model the format, and the coder reads what the model writes. Each coder sets a class attribute that names its format:

Textaider/coders/editblock_coder.py
class EditBlockCoder(Coder):
    """A coder that uses search/replace blocks for code modifications."""

    edit_format = "diff"
    gpt_prompts = EditBlockPrompts()

Coder.create in base_coder.py picks the class. It takes the requested edit format (or the model's default edit_format from the model settings), then loops over coders.__all__ and returns the first coder whose edit_format matches. The formats in 0.86.2 include diff, diff-fenced, whole, udiff, udiff-simple, patch, architect, ask, context, and help. When the user switches format, create summarizes the old assistant messages first, because, as the comment there says, the old edit format "will confused the new LLM". The old messages show the old format, and the model might copy it.

The loop is in Coder.run_one. It sends a message and repeats only when there is a reflected message, a message Aider sends back to the model on its own:

Textaider/coders/base_coder.py
        while message:
            self.reflected_message = None
            list(self.send_message(message))

            if not self.reflected_message:
                break

            if self.num_reflections >= self.max_reflections:
                self.io.tool_warning(f"Only {self.max_reflections} reflections allowed, stopping.")
                return

max_reflections is 3. Three things set reflected_message:

  • An edit that fails to parse or apply (apply_updates catches the ValueError and stores its text).
  • Lint errors after an edit, when the user agrees to fix them.
  • Test failures, when auto_test is on.

So Aider's loop is different from mini-swe-agent's. The model does not choose actions in a loop. It writes edits once. Aider applies them, commits them to git, runs the linter and tests, and loops back only to repair failures. Shell commands also come from the reply: find_original_update_blocks returns fenced bash and sh blocks as commands, and Aider asks before running them.

Example 9: Aider's SEARCH/REPLACE parser in Java

The diff format asks the model to write a block with the old lines (SEARCH) and the new lines (REPLACE). This example shows how that block is parsed and what the model sees when a block does not match. The markers are three regular expressions in editblock_coder.py. They accept 5 to 9 marker characters, which tolerates a model that writes a few too many or too few:

Textaider/coders/editblock_coder.py
HEAD = r"^<{5,9} SEARCH>?\s*$"
DIVIDER = r"^={5,9}\s*$"
UPDATED = r"^>{5,9} REPLACE\s*$"

The program uses the same patterns. The file name comes from the line before the block, skipping a fence line, as find_filename does. The real function looks back up to three lines and also matches the name against files in the chat. Applying a block follows perfect_replace: the SEARCH lines must equal a run of whole lines in the file.

The sample reply has two blocks. The first names its file and matches. The second has no file name and asks to replace a line that does not exist. Look for what the program prints for each.

Java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Pattern;

public class AiderBlocks {
    // The three marker patterns from aider/coders/editblock_coder.py.
    static final Pattern HEAD = Pattern.compile("^<{5,9} SEARCH>?\\s*$");
    static final Pattern DIVIDER = Pattern.compile("^={5,9}\\s*$");
    static final Pattern UPDATED = Pattern.compile("^>{5,9} REPLACE\\s*$");
    record Block(String file, String search, String replace) {}

    static List<Block> parse(String reply) {
        List<String> lines = reply.lines().toList();
        List<Block> blocks = new ArrayList<>();
        String current = null;
        for (int i = 0; i < lines.size(); i++) {
            if (!HEAD.matcher(lines.get(i).strip()).matches()) continue;
            String before = i > 0 ? lines.get(i - 1).strip() : "";
            if (before.startsWith("```")) before = i > 1 ? lines.get(i - 2).strip() : "";
            if (!before.isEmpty() && !before.startsWith("```")) current = before.replaceAll("^[#`*]+|[:`*]+$", "");
            if (current == null) throw new IllegalArgumentException("Bad/missing filename.");
            StringBuilder search = new StringBuilder(), replace = new StringBuilder();
            for (i++; i < lines.size() && !DIVIDER.matcher(lines.get(i).strip()).matches(); i++)
                search.append(lines.get(i)).append('\n');
            if (i >= lines.size()) throw new IllegalArgumentException("Expected `=======`");
            for (i++; i < lines.size() && !UPDATED.matcher(lines.get(i).strip()).matches(); i++)
                replace.append(lines.get(i)).append('\n');
            if (i >= lines.size()) throw new IllegalArgumentException("Expected `>>>>>>> REPLACE` or `=======`");
            blocks.add(new Block(current, search.toString(), replace.toString()));
        }
        return blocks;
    }

    // perfect_replace: the SEARCH lines must equal a run of whole lines (line endings kept, as in prep()).
    static String perfectReplace(String whole, String part, String replace) {
        List<String> w = List.of(whole.split("(?<=\n)")), p = List.of(part.split("(?<=\n)"));
        for (int i = 0; i + p.size() <= w.size(); i++)
            if (w.subList(i, i + p.size()).equals(p))
                return String.join("", w.subList(0, i)) + replace + String.join("", w.subList(i + p.size(), w.size()));
        return null;
    }

    public static void main(String[] args) {
        Map<String, String> files = new TreeMap<>(Map.of("src/Greeter.java",
                "class Greeter {\n    String hello() { return \"hi\"; }\n}\n"));
        String reply = """
            Change the greeting.

            src/Greeter.java
            ```java
            <<<<<<< SEARCH
                String hello() { return "hi"; }
            =======
                String hello() { return "hello"; }
            >>>>>>> REPLACE
            ```

            ```java
            <<<<<<< SEARCH
                String bye() { return "bye"; }
            =======
                String bye() { return "goodbye"; }
            >>>>>>> REPLACE
            ```
            """;
        for (Block b : parse(reply)) {
            String updated = perfectReplace(files.get(b.file()), b.search(), b.replace());
            if (updated == null) System.out.print("SearchReplaceNoExactMatch: This SEARCH block failed to exactly match lines in "
                    + b.file() + "\n" + b.search());
            else { files.put(b.file(), updated); System.out.println("Applied edit to " + b.file()); }
        }
        System.out.print(files.get("src/Greeter.java"));
    }
}
Output
Applied edit to src/Greeter.java
SearchReplaceNoExactMatch: This SEARCH block failed to exactly match lines in src/Greeter.java
    String bye() { return "bye"; }
class Greeter {
    String hello() { return "hello"; }
}

The first block names src/Greeter.java on the line before its fence. Its SEARCH text matches line 2 exactly, so the line is replaced and the program prints Applied edit to src/Greeter.java, the same words Aider prints. The second block has no file name before its fence, so the parser reuses the previous one, as Aider's current_filename does. Its SEARCH line does not exist in the file, so the program prints the start of Aider's failure text. The final lines show the file: only the first edit was applied.

In Aider, that failure becomes the reflected message from Example 8. apply_edits collects every failed block into one report:

  • A heading # 1 SEARCH/REPLACE block failed to match!.
  • The block itself.
  • A "Did you mean to match some of these actual lines" section with similar lines from the file (find_similar_lines, similarity threshold 0.6).
  • A note when the REPLACE text is already in the file.
  • When other blocks in the same reply succeeded, a closing note saying they were applied and must not be sent again.

The model gets one retry message that covers all failures. Showing the real lines of the file gives the model what it needs to correct its SEARCH text.

Before giving up, replace_most_similar_chunk tries a few repairs in order:

  1. An exact match.
  2. A match that ignores a uniform difference in leading whitespace.
  3. The same two after dropping a spurious blank first line.
  4. A match where the model wrote ... for unchanged code.

The function then has a bare return before a call to replace_closest_edit_distance, so in 0.86.2 the edit-distance fallback below it never runs.

Example 10: Aider, the repository map

A model that edits a project needs to know what else is in the project. It cannot read every file, because the context window (the amount of text the model can read at once) is limited. This example asks how Aider tells the model about files that are not in the chat. Open aider/repomap.py. The repository map is a compact listing of files with their most relevant definitions (classes, functions, methods), sized to a token budget. A token is the small unit of text, often a word piece, that a model reads and counts.

Definitions and references come from tree-sitter queries in aider/queries/. Tree-sitter is a parser library that turns source code into a syntax tree, so a query can ask for "all class names". A definition is where a name is created (class Cart). A reference is a place that uses it (new Cart()). The Java query (tree-sitter-language-pack/java-tags.scm) captures class, interface, and method declarations as definitions, and method calls, new expressions, and superclasses as references. Here are its definition rules for classes and methods:

Textaider/queries/tree-sitter-language-pack/java-tags.scm
(class_declaration
  name: (identifier) @name.definition.class) @definition.class

(method_declaration
  name: (identifier) @name.definition.method) @definition.method

get_ranked_tags builds a graph with one node per file. For every identifier that is both defined and referenced, it adds an edge from each referencing file to each defining file. So if Main.java calls something defined in Cart.java, there is an edge from Main.java to Cart.java. The edge weight starts at 1.0 and is multiplied by factors that favor names likely to matter:

Textaider/repomap.py
            if ident in mentioned_idents:
                mul *= 10
            if (is_snake or is_kebab or is_camel) and len(ident) >= 8:
                mul *= 10
            if ident.startswith("_"):
                mul *= 0.1
            if len(defines[ident]) > 5:
                mul *= 0.1

In words, these four rules do the following.

  • A name that the user mentioned counts 10 times more.
  • A long, specific name (snake_case, kebab-case, or camelCase, 8 or more characters) counts 10 times more, because short names like get appear everywhere and say little.
  • A name that starts with an underscore counts one tenth, because it is private.
  • A name defined in more than 5 files counts one tenth, because it is too common to be informative.

References from files in the chat count 50 times more. The number of references enters as its square root, so one file that calls a function 100 times does not outweigh ten files that call it once (the square root of 100 is 10, and ten files add 10 as well).

Then Aider runs PageRank, the algorithm that ranks nodes of a graph by how many important nodes point at them. Imagine a reader who follows edges at random and now and then jumps to a random node. Nodes that the reader visits often rank high. With personalization, the random jumps do not go to any node at all. They go only to chat files, files named in the user's message, and files whose path contains a mentioned identifier. So the ranking measures closeness to what the user is working on.

Finally, each file's rank is split across its outgoing edges, and each (file, identifier) pair gets the share of the edges that point to it. Definitions inside chat files are skipped, since those files are already in full in the prompt.

The budget comes from get_repo_map_tokens in models.py: the model's input limit divided by 8, kept between 1,024 and 4,096 tokens. With no files in the chat, the budget is multiplied by --map-multiplier-no-files (default 2). To fit the budget, get_ranked_tags_map_uncached does a binary search over how many ranked tags to include. It renders the tree, counts tokens, and stops when the result is within 15% of the budget.

Example 11: Aider's ranking in Java

This example answers which definitions reach the top of the map for a small repository. The program applies Aider's edge weights (the mentioned-identifier rule is left out) and a personalized PageRank with damping 0.85 and 100 iterations. Damping 0.85 means that at each step the walker follows an edge with probability 0.85 and jumps back with probability 0.15. Aider calls networkx.pagerank with its default settings. Main.java is the chat file.

The data lists which file defines and which files reference each name. Read it first: Main.java and CartTest.java use ShoppingCart and totalPrice, and Cart.java uses Discount and apply. Nothing references Logger.

Java
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;

public class AiderRank {
    record Edge(String from, String to, String ident, double weight) {}

    public static void main(String[] args) {
        // Tags as tree-sitter would report them: definitions and references per file.
        Map<String, Set<String>> defines = new TreeMap<>(Map.of(
                "ShoppingCart", Set.of("Cart.java"), "totalPrice", Set.of("Cart.java"),
                "Discount", Set.of("Discount.java"), "apply", Set.of("Discount.java"),
                "Logger", Set.of("Log.java")));
        Map<String, List<String>> references = new TreeMap<>(Map.of(
                "ShoppingCart", List.of("Main.java", "CartTest.java", "CartTest.java"),
                "totalPrice", List.of("Main.java", "CartTest.java"),
                "Discount", List.of("Cart.java"), "apply", List.of("Cart.java")));
        Set<String> chat = Set.of("Main.java");
        Set<String> files = new TreeSet<>(List.of("Cart.java", "CartTest.java", "Discount.java", "Log.java", "Main.java"));

        List<Edge> edges = new ArrayList<>();
        defines.forEach((ident, definers) -> {
            List<String> refs = references.get(ident);
            if (refs == null) { definers.forEach(d -> edges.add(new Edge(d, d, ident, 0.1))); return; }
            double mul = 1.0;
            boolean camel = !ident.equals(ident.toLowerCase()) && !ident.equals(ident.toUpperCase());
            if (camel && ident.length() >= 8) mul *= 10;              // long, specific names count more
            for (String referencer : new TreeSet<>(refs)) {
                long n = refs.stream().filter(referencer::equals).count();
                double use = chat.contains(referencer) ? mul * 50 : mul;   // references from chat files count 50x
                for (String d : definers) edges.add(new Edge(referencer, d, ident, use * Math.sqrt(n)));
            }
        });

        // Personalized PageRank: restarts land on the chat file only.
        Map<String, Double> rank = new TreeMap<>(), pers = new TreeMap<>();
        for (String f : files) { rank.put(f, 1.0 / files.size()); pers.put(f, chat.contains(f) ? 1.0 : 0.0); }
        for (int it = 0; it < 100; it++) {
            Map<String, Double> next = new TreeMap<>();
            double dangling = 0;
            for (String f : files) {
                double out = edges.stream().filter(e -> e.from().equals(f)).mapToDouble(Edge::weight).sum();
                if (out == 0) dangling += rank.get(f);
                for (Edge e : edges) if (e.from().equals(f))
                    next.merge(e.to(), 0.85 * rank.get(f) * e.weight() / out, Double::sum);
            }
            for (String f : files) next.merge(f, (0.85 * dangling + 0.15) * pers.get(f), Double::sum);
            rank = next;
        }
        // Spread each file's rank over its out-edges to rank (file, identifier) definitions.
        Map<String, Double> defRank = new TreeMap<>();
        for (String f : files) {
            double out = edges.stream().filter(e -> e.from().equals(f)).mapToDouble(Edge::weight).sum();
            for (Edge e : edges) if (e.from().equals(f))
                defRank.merge(e.to() + " " + e.ident(), rank.get(f) * e.weight() / out, Double::sum);
        }
        defRank.entrySet().stream().sorted(Map.Entry.<String, Double>comparingByValue().reversed())
                .forEach(e -> System.out.printf(Locale.ROOT, "%.4f  %s%n", e.getValue(), e.getKey()));
    }
}
Output
0.3004  Discount.java Discount
0.1944  Cart.java ShoppingCart
0.1944  Cart.java totalPrice
0.0300  Discount.java apply
0.0000  Log.java Logger

Start at the chat file. Main.java references ShoppingCart and totalPrice, both defined in Cart.java. Both names are camel case and at least 8 characters, so each edge gets weight 10, and since Main.java is in the chat, 500. All random jumps return to Main.java, so it has the highest rank, and it sends that rank to Cart.java. The two Cart.java definitions get 0.1944 each: Main.java's rank split evenly over two edges of equal weight.

The top entry is Discount in Discount.java, which the chat file never mentions. Cart.java references Discount (8 characters, camel case: weight 10) and apply (short: weight 1), and Cart.java holds a large rank because the chat file points at it. Almost all of Cart.java's rank flows to Discount, and the weight-1 edge gives apply 0.0300. This is the point of the algorithm: a file that the user never named can still rank first because a file the user is working on depends on it.

CartTest.java also references Cart.java, but with no jumps landing on it and no edges into it, its rank is near zero, so its references add little. Logger in Log.java gets 0.0000: nothing references it, so it only has the small self-edge Aider adds for unreferenced definitions, and no rank reaches it.

Example 12: OpenHands SDK, agent, conversation, tools, condenser

OpenHands splits the work among several classes instead of one loop. This example asks how the OpenHands SDK divides the loop between them. Open openhands/sdk/conversation/impl/local_conversation.py (OpenHands SDK 1.51.0). LocalConversation.run() holds the outer loop, and each pass calls the agent once:

Textopenhands/sdk/conversation/impl/local_conversation.py
                    self._step_holds_state_lock = True
                    try:
                        self.agent.step(
                            self, on_event=self._on_event, on_token=self._on_token
                        )
                    finally:
                        self._step_holds_state_lock = False
                    iteration += 1

The loop stops on an execution status, not on a missing tool call. The statuses include RUNNING, PAUSED, STUCK, WAITING_FOR_CONFIRMATION, FINISHED, and ERROR. A run ends when:

  • The status is FINISHED (and no stop hook objects).
  • The status is PAUSED or STUCK.
  • It is waiting for confirmation.
  • The budget is exceeded.
  • max_iteration_per_run (default 500) is reached, which records a MaxIterationsReached error event.

Before each step, a stuck detector (conversation/stuck_detector.py) looks for repeated patterns. A model that keeps trying the same failing command wastes money, so the detector stops it. Its thresholds in conversation/types.py are:

  • 4 repeated action-observation pairs.
  • 3 repeated action-error pairs.
  • 3 agent messages in a row without user input.
  • 6 repetitions of an alternating pattern.

Agent._step in agent/agent.py does one model call. It first executes pending actions that were waiting for confirmation, then prepares messages through the condenser, calls self.llm.generate(...) with the tools, and turns each tool call into an ActionEvent. When the reply calls the finish tool (tool/builtins/finish.py), the step ends by setting the status to FINISHED. Tools are ToolDefinition objects with an executor (tool/tool.py). The SDK package holds this framework and a few built-in tools (finish, think, invoke_skill, vision_inspect, and model-switching tools). It contains no terminal or file-editor tool, which are supplied to the agent from outside the package.

State is an event log. Every message, action, observation, and condensation is an event, and the agent builds its prompt from a View over the events. A condenser decides when the view is too big and replaces old events with a summary event. LLMSummarizingCondenser in context/condenser/llm_summarizing_condenser.py has class defaults max_size = 240 events and keep_first = 2. The default_condenser() helper in the same file uses 80 and 4.

The program below reproduces its rules: the reasons to condense, and how hard each one is. It checks four situations against two configurations. Read the output columns as: which reasons apply, whether condensing is NONE, SOFT (optional), or HARD (required), and how many recent events survive.

Java
import java.util.EnumSet;
import java.util.Set;

public class CondenserRules {
    enum Reason { REQUEST, TOKENS, EVENTS }
    enum Requirement { NONE, SOFT, HARD }

    // max_size and keep_first: class defaults 240 and 2; default_condenser() uses 80 and 4.
    record Condenser(int maxSize, int keepFirst) {
        Set<Reason> reasons(boolean requested, Integer maxTokens, int totalTokens, int events) {
            Set<Reason> r = EnumSet.noneOf(Reason.class);
            if (requested) r.add(Reason.REQUEST);
            if (maxTokens != null && totalTokens > maxTokens) r.add(Reason.TOKENS);
            if (events > maxSize) r.add(Reason.EVENTS);
            return r;
        }
        // Events kept at the end of the view; the TOKENS case depends on token counts and is left out here.
        int tailToKeep(Set<Reason> r, int events) {
            int tail = Integer.MAX_VALUE;
            if (r.contains(Reason.REQUEST)) tail = Math.min(tail, events / 2 - keepFirst - 1);
            if (r.contains(Reason.EVENTS)) tail = Math.min(tail, maxSize / 2 - keepFirst - 1);
            return tail;
        }
    }

    static Requirement requirement(Set<Reason> r) {
        if (r.isEmpty()) return Requirement.NONE;
        if (r.contains(Reason.TOKENS)) return Requirement.HARD;
        if (r.equals(EnumSet.of(Reason.EVENTS))) return Requirement.SOFT;
        return Requirement.HARD;                          // REQUEST, alone or with EVENTS
    }

    public static void main(String[] args) {
        int window = 32_768;                              // the agent LLM's input limit
        Object[][] cases = { {false, 12_000, 60}, {false, 12_000, 81}, {false, 33_000, 50}, {true, 5_000, 30} };
        for (Condenser c : new Condenser[] {new Condenser(240, 2), new Condenser(80, 4)}) {
            System.out.println(c);
            for (Object[] k : cases) {
                int events = (Integer) k[2];
                Set<Reason> r = c.reasons((Boolean) k[0], window, (Integer) k[1], events);
                int tail = c.tailToKeep(r, events);
                System.out.printf("  requested=%-5s tokens=%-6d events=%-3d -> %-9s %-4s keep tail: %s%n", k[0], k[1],
                        events, r, requirement(r), tail == Integer.MAX_VALUE ? "-" : tail);
            }
        }
    }
}
Output
Condenser[maxSize=240, keepFirst=2]
  requested=false tokens=12000  events=60  -> []        NONE keep tail: -
  requested=false tokens=12000  events=81  -> []        NONE keep tail: -
  requested=false tokens=33000  events=50  -> [TOKENS]  HARD keep tail: -
  requested=true  tokens=5000   events=30  -> [REQUEST] HARD keep tail: 12
Condenser[maxSize=80, keepFirst=4]
  requested=false tokens=12000  events=60  -> []        NONE keep tail: -
  requested=false tokens=12000  events=81  -> [EVENTS]  SOFT keep tail: 35
  requested=false tokens=33000  events=50  -> [TOKENS]  HARD keep tail: -
  requested=true  tokens=5000   events=30  -> [REQUEST] HARD keep tail: 10

There are three reasons.

  • REQUEST means someone asked for condensation: the user, or the agent itself after the provider reported a context-window error (_step emits a CondensationRequest in that case).
  • TOKENS means the view's token count is above the smaller of the condenser's max_tokens and the model's input limit. Here the limit is 32,768 and the third row has 33,000 tokens.
  • EVENTS means more than max_size events.

The requirement column shows how the reasons combine. Token pressure is always HARD, because the next request would fail. Event count alone is SOFT: the comment in the source calls it "only a history-management heuristic", so the agent condenses when it can but may continue. A request is HARD. The second row shows the difference between the two configurations: 81 events is under the class default of 240 and over the helper's 80.

The last column is how many recent events survive.

  • For EVENTS it is max_size // 2 - keep_first - 1. For the 80/4 configuration that is 80 / 2 - 4 - 1 = 35.
  • For REQUEST it is half the current view minus the same terms. 30 events give 30 / 2 - 2 - 1 = 12 with keep_first 2 and 30 / 2 - 4 - 1 = 10 with keep_first 4.
  • For TOKENS the source computes how many tail events fit after removing enough tokens to get under half the limit. This depends on real token counts, so the program prints -.

The first keep_first events are never summarized, and the leading system prompt event is protected even when keep_first is 0.

Example 13: Crush, architecture, tools, permissions

Crush is a terminal application written in Go. This example asks how it is laid out and when it asks the user before running a command. Crush's own AGENTS.md (v0.97.1) has an architecture map. The parts that matter here:

TextAGENTS.md
    agent.go                       SessionAgent: runs LLM conversations per session
    coordinator.go                 Coordinator: manages named agents ("coder", "task")
    hooked_tool.go                 Decorator that runs PreToolUse hooks before tool execution
    prompts.go                     Loads Go-template system prompts
    templates/                     System prompt templates (coder.md.tpl, task.md.tpl, etc.)
    tools/                         All built-in tools (bash, edit, view, grep, glob, etc.)

The loop itself is not in Crush. sessionAgent.Run in internal/agent/agent.go builds a fantasy.NewAgent with the system prompt and tools and calls agent.Stream(...) with callbacks (OnToolCall, OnToolResult, OnStepFinish) and a list of stop conditions. charm.land/fantasy is Charm's provider and agent library. Crush adds two stop conditions:

  • The first triggers summarization when the context is nearly full (Example 15).
  • The second is loop detection in loop_detection.go. Over the last 10 steps, if any tool-call signature (a SHA-256 hash of tool name, input, and output) appears more than 5 times, the run stops.

Each tool is a .go file and a .md (or .md.tpl) file with its description, in internal/agent/tools/. The tools are bash, edit, multiedit, view, write, grep, glob, ls, fetch, download, todos, LSP tools such as lsp_definition and lsp_rename (LSP, the Language Server Protocol, lets an editor tool ask about definitions and renames), background job tools, and MCP resource tools. The bash tool does not start bash. internal/shell/shell.go interprets commands with mvdan.cc/sh/v3, a POSIX shell written in Go, so the same tool works on Windows.

Permissions are in internal/permission/permission.go. A permission check answers "may this action run without asking the user?". Request decides in this order, and the first rule that applies wins:

  1. If requests are skipped (the --yolo flag sets this), allow.
  2. If the tool name, or tool:action, is in the configured allow list, allow.
  3. If a PreToolUse hook approved this tool call id, allow.
  4. If the session has auto-approve on, allow.
  5. If the user already granted the same session, tool, action, and path for the session, allow.
  6. Otherwise publish a request to the UI and wait.

The bash tool adds one more rule before calling Request at all: commands that look read-only skip the permission check. The program reproduces that check from bash.go and safe.go, with part of the 46-entry safeCommands list (Windows adds six more). A command is safe when it starts with a name on the list and contains none of the chaining characters (characters that let a shell run a second command). Predict the result for each command before you read the output.

Java
import java.util.List;
import java.util.Locale;

public class CrushSafe {
    // A part of safeCommands and all of chainingMetacharacters from internal/agent/tools/safe.go.
    static final List<String> SAFE = List.of("date", "echo", "env", "kill", "ls", "pwd", "which",
            "git diff", "git log", "git show", "git status");
    static final List<String> CHAINING = List.of(";", "|", "&&", "$(", "`");

    // The check at the top of the bash tool in internal/agent/tools/bash.go.
    static boolean isSafeReadOnly(String command) {
        if (CHAINING.stream().anyMatch(command::contains)) return false;
        String lower = command.toLowerCase(Locale.ROOT);
        for (String safe : SAFE)
            if (lower.startsWith(safe) && (lower.length() == safe.length()
                    || lower.charAt(safe.length()) == ' ' || lower.charAt(safe.length()) == '-'))
                return true;
        return false;
    }

    public static void main(String[] args) {
        for (String cmd : List.of("ls -la", "lsof -i", "git status", "git push", "git log --oneline | head",
                "echo $(cat ~/.ssh/id_rsa)", "ls && rm -rf build", "echo done > notes.txt", "kill -9 4242"))
            System.out.printf("%-28s %s%n", cmd, isSafeReadOnly(cmd) ? "runs without a prompt" : "asks permission");
    }
}
Output
ls -la                       runs without a prompt
lsof -i                      asks permission
git status                   runs without a prompt
git push                     asks permission
git log --oneline | head     asks permission
echo $(cat ~/.ssh/id_rsa)    asks permission
ls && rm -rf build           asks permission
echo done > notes.txt        runs without a prompt
kill -9 4242                 runs without a prompt

ls -la matches the safe prefix ls followed by a space. lsof -i starts with ls too, but the next character is o, and a match must be followed by the end of the string, a space, or a -, so it asks. git status is on the list and git push is not. The next three commands contain a chaining character (|, $(, &&), and any command with one of ;, |, &&, $(, or a backtick is never treated as read-only.

The last two rows follow from the same rules, and they show the limits of a text check. echo done > notes.txt writes a file, yet it runs without a prompt: > is not in the chaining list, and echo is a safe prefix. kill -9 4242 runs without a prompt because kill is on the safe list. The check is a prefix test on the command text, so its result depends on which characters the list covers.

A separate list, bannedCommands in bash.go, blocks commands such as curl, wget, ssh, su, and doas inside the shell interpreter whether or not permission was granted.

Example 14: provider abstractions compared

Every harness must send requests to a model API, and the APIs differ between companies. Each project therefore puts one layer between the loop and the HTTP API, so the loop does not care which model it talks to. This example asks how each harness does that, and what a local server needs to work with it.

Harness Layer Where Local servers
mini-swe-agent LiteLLM (litellm.completion) minisweagent/models/litellm_model.py and variants Through LiteLLM provider prefixes; a model registry file supplies cost data
Pi @mariozechner/pi-ai, with API kinds openai-completions, openai-responses, anthropic-messages, google-generative-ai docs/models.md, dist/core/model-registry.js ~/.pi/agent/models.json with baseUrl, api: "openai-completions", and compat flags such as supportsDeveloperRole: false
Aider LiteLLM, loaded lazily aider/llm.py, aider/models.py Model names with a provider prefix; extra rules for Ollama
OpenHands SDK LiteLLM inside an LLM class openhands/sdk/llm/llm.py native_tool_calling (default true); when false, tool calls are written into the prompt (llm/mixins/non_native_fc.py)
Crush charm.land/fantasy providers internal/agent/coordinator.go, buildProvider Provider types openai-compat, llamacpp, lmstudio, ollama, litellm, omlx

LiteLLM is a library that gives one function call for many providers. Three of the five harnesses use it. A common pattern for local servers is the OpenAI-compatible API: the local server copies the request and response shape of OpenAI's API, so any client that speaks that shape can talk to it.

Two details are visible in code. The program reproduces Crush's provider switch and Aider's context-size rule for Ollama. The first function maps a provider type to the builder function Crush calls. The second computes the num_ctx value (the context size Ollama should allocate) from the prompt size.

Java
import java.util.List;
import java.util.Set;

public class ProviderRouting {
    // The switch in buildProvider (internal/agent/coordinator.go, Crush v0.97.1), reduced to the builder it picks.
    static final Set<String> KNOWN_CUSTOM = Set.of("litellm", "llamacpp", "lmstudio", "ollama", "omlx");

    static String crushBuilder(String type) {
        return switch (type) {
            case "openai" -> "buildOpenaiProvider";
            case "anthropic" -> "buildAnthropicProvider";
            case "google" -> "buildGoogleProvider";
            case "openai-compat", "hyper" -> "buildOpenaiCompatProvider";
            default -> KNOWN_CUSTOM.contains(type) ? "buildOpenaiCompatProvider"
                    : "error: provider type not supported: \"" + type + "\"";
        };
    }

    // Aider sets num_ctx for Ollama models when the request does not (send_completion in aider/models.py).
    static int aiderOllamaNumCtx(int promptTokens) {
        return (int) (promptTokens * 1.25) + 8192;
    }

    public static void main(String[] args) {
        for (String type : List.of("openai", "openai-compat", "llamacpp", "ollama", "vllm"))
            System.out.printf("crush type %-14s -> %s%n", type, crushBuilder(type));
        for (int tokens : List.of(1_000, 6_000, 20_000))
            System.out.printf("aider prompt %6d tokens -> num_ctx %d%n", tokens, aiderOllamaNumCtx(tokens));
    }
}
Output
crush type openai         -> buildOpenaiProvider
crush type openai-compat  -> buildOpenaiCompatProvider
crush type llamacpp       -> buildOpenaiCompatProvider
crush type ollama         -> buildOpenaiCompatProvider
crush type vllm           -> error: provider type not supported: "vllm"
aider prompt   1000 tokens -> num_ctx 9442
aider prompt   6000 tokens -> num_ctx 15692
aider prompt  20000 tokens -> num_ctx 33192

In Crush, openai and openai-compat are different builders. openai targets OpenAI's own API (and its OAuth login), while openai-compat is for any server that speaks the same protocol. The local server types llamacpp and ollama have no builder of their own. They reach the default branch, which uses the OpenAI-compatible builder for every type that registered an "enricher" in internal/discover/ (code that asks the local server for its model list). The type vllm is not registered in v0.97.1, so the switch returns the error provider type not supported. A vLLM server is reached with type openai-compat and its URL.

Aider computes num_ctx for Ollama models when the request does not set it: the prompt's token count times 1.25, plus 8,192. A 6,000-token prompt gets 6,000 x 1.25 + 8,192 = 15,692, which matches the output. The value is computed for every request, so the context Ollama allocates grows with the prompt and always leaves 8,192 tokens of room beyond it.

The OpenHands flag explains a design choice for small local models. Some local servers cannot return structured tool_calls, so a harness that depends on them would fail. With native_tool_calling false, pre_request_prompt_mock rewrites the tool definitions into the prompt and adds an in-context example (skipped for model names containing openhands-lm, devstral, or nemotron). Then post_response_prompt_mock parses tool calls back out of the reply text. The same harness then works with servers that do not return structured tool_calls.

Example 15: context compaction compared

A conversation with an agent grows with every command and file it reads, and the model's context window is limited. Something must give. This example asks when each harness shrinks the conversation, and what it keeps. Compaction here means replacing old messages with a shorter summary written by a model.

Harness Trigger What is kept Source
mini-swe-agent None; long outputs are cut instead Observations over 10,000 characters keep the first and last 5,000 config/mini.yaml, observation_template
Pi contextTokens > contextWindow - reserveTokens (16,384) About keepRecentTokens (20,000) of recent messages, plus a structured summary dist/core/compaction/compaction.js, docs/compaction.md
Aider Done messages over max_chat_history_tokens A summary of older messages; the tail that fits half the budget stays aider/history.py (ChatSummary), aider/models.py
OpenHands SDK Requested, tokens over the limit, or events over max_size First keep_first events, a summary event, a tail of recent events context/condenser/llm_summarizing_condenser.py
Crush Remaining tokens at or under 20,000 (windows over 200,000) or 20% of the window A summary message; later loads start from it internal/agent/agent.go (StopWhen, Summarize)

The thresholds depend on the context window, so the program computes them for four window sizes: 8,192, 32,768, 131,072, and 262,144 tokens. Each column is one harness rule. Pi and Crush give a token count at which they act. The two Aider columns are budgets.

Java
import java.util.List;

public class CompactionTriggers {
    // Pi: compact when contextTokens > contextWindow - reserveTokens (default 16384).
    static long piTrigger(long window) { return window - 16_384; }

    // Crush: summarize when remaining <= threshold; 20,000 above a 200,000 window, else 20% of the window.
    static long crushTrigger(long window) {
        long threshold = window > 200_000 ? 20_000 : (long) (window * 0.2);
        return window - threshold;               // used tokens at which remaining reaches the threshold
    }

    // Aider: chat history budget = min(max(max_input_tokens / 16, 1024), 8192); repo map = max_input / 8 in [1024, 4096].
    static long aiderHistory(long window) { return Math.min(Math.max(window / 16, 1024), 8192); }
    static long aiderMap(long window) { return Math.min(Math.max(window / 8, 1024), 4096); }

    public static void main(String[] args) {
        System.out.println("window   pi-compacts-above crush-summarizes-at aider-history aider-map");
        for (long w : List.of(8_192L, 32_768L, 131_072L, 262_144L))
            System.out.printf("%7d %18d %19d %13d %9d%n", w, piTrigger(w), crushTrigger(w), aiderHistory(w), aiderMap(w));
    }
}
Output
window   pi-compacts-above crush-summarizes-at aider-history aider-map
   8192              -8192                6554          1024      1024
  32768              16384               26215          2048      4096
 131072             114688              104858          8192      4096
 262144             245760              242144          8192      4096

For an 8,192-token window, Pi's trigger value is negative. shouldCompact compares the context with 8192 - 16384 = -8192, so any context, even an empty one, is "too big". _checkCompaction in agent-session.js runs this test after every agent run, so Pi attempts auto-compaction after every prompt. With a local model at 8K, reserveTokens must be lowered in settings.json. At 32,768 tokens Pi compacts above 16,384, half the window. For 131,072 and larger, the reserve is a small fraction of the window.

Crush computes the remaining tokens as the window minus the session's prompt and completion tokens and summarizes when that is at most the threshold: 20% of the window up to 200,000 tokens, 20,000 above. At 8,192 that is 1,638 tokens of headroom (20% of 8,192), so summarization starts at 6,554 used tokens. The source skips this check when the window size is unknown (0), with a comment that it avoids "immediately truncating custom/local models". So a local model without a configured window is never summarized automatically.

Aider's numbers are budgets rather than triggers. Chat history may use 1/16 of the input limit, between 1,024 and 8,192 tokens, and the repository map 1/8, between 1,024 and 4,096. At 8,192 tokens both are 1,024, so a quarter of a small window goes to history and map before any file content.

mini-swe-agent never summarizes and relies on short observations. The other four summarize with a model call, which costs time and tokens and can lose details.

Example 16: Pi's cut point in Java

When Pi compacts, it keeps the newest messages and summarizes the rest. This example asks which messages Pi keeps. findCutPoint in dist/core/compaction/compaction.js walks backwards from the newest message, adding a size estimate of each one, until the sum reaches keepRecentTokens. The estimate is Math.ceil(chars / 4) (estimateTokens), a rough rule that one token is about four characters.

There is one rule that makes the cut more than a count. The cut may land only on a user or assistant message, never on a tool result, because a tool result must follow the assistant message that called the tool. A tool result without its call would make no sense to the model. The program reproduces this on a ten-message session with large tool outputs. It tries three keepRecentTokens values.

Java
import java.util.List;

public class PiCutPoint {
    record Msg(String role, int chars) {
        int tokens() { return (chars + 3) / 4; }             // Math.ceil(chars / 4), the estimateTokens heuristic
    }

    // findCutPoint in dist/core/compaction/compaction.js: walk back from the newest message, summing estimates,
    // until keepRecentTokens is reached; then cut at the nearest valid cut point at or after that message.
    static int findCutPoint(List<Msg> msgs, int keepRecentTokens) {
        int acc = 0;
        for (int i = msgs.size() - 1; i >= 0; i--) {
            acc += msgs.get(i).tokens();
            if (acc >= keepRecentTokens) {
                for (int c = i; c < msgs.size(); c++)
                    if (!msgs.get(c).role().equals("toolResult")) return c;   // never cut at a tool result
                return msgs.size();
            }
        }
        return 0;                                            // everything fits: keep all
    }

    public static void main(String[] args) {
        List<Msg> session = List.of(
                new Msg("user", 400), new Msg("assistant", 800), new Msg("toolResult", 24_000),
                new Msg("assistant", 600), new Msg("toolResult", 36_000), new Msg("assistant", 1_200),
                new Msg("user", 300), new Msg("assistant", 900), new Msg("toolResult", 8_000),
                new Msg("assistant", 500));
        int total = session.stream().mapToInt(Msg::tokens).sum();
        System.out.println("estimated tokens in session: " + total);
        for (int keep : List.of(2_000, 3_000, 20_000)) {
            int cut = findCutPoint(session, keep);
            int kept = session.subList(cut, session.size()).stream().mapToInt(Msg::tokens).sum();
            boolean split = !session.get(cut).role().equals("user");  // the turn's user message is summarized
            System.out.printf("keep=%-6d first kept=%d (%s) summarized=%d kept=%d tokens split turn=%s%n",
                    keep, cut, session.get(cut).role(), cut, kept, split);
        }
    }
}
Output
estimated tokens in session: 18175
keep=2000   first kept=9 (assistant) summarized=9 kept=125 tokens split turn=true
keep=3000   first kept=5 (assistant) summarized=5 kept=2725 tokens split turn=true
keep=20000  first kept=0 (user) summarized=0 kept=18175 tokens split turn=false

The session's estimate is 18,175 tokens, and two tool results (24,000 and 36,000 characters) make up most of it.

With keepRecentTokens 2,000, the walk adds message 9 (125 tokens) and message 8, a tool result of 2,000 tokens, which reaches the budget. Message 8 cannot be a cut point, so the cut moves forward to the next valid point, message 9. Only 125 tokens are kept, far less than 2,000: moving forward past a tool result shrinks what is kept. The kept part starts with an assistant message whose user message (index 6) is summarized, which Pi calls a split turn. For split turns, docs/compaction.md states that Pi writes two summaries, one for the history and one for the early part of the cut turn, so the model still sees what the user asked.

With 3,000, the walk reaches the 36,000-character tool result at index 4 and moves forward to index 5, keeping 2,725 tokens. It is also a split turn.

With 20,000, the walk never reaches the budget, so the cut stays at index 0 and nothing is summarized. In Pi this case does not arise from auto-compaction with these numbers, since the session is under the trigger. It shows the default keepRecentTokens value.

Example 17: sandboxing and permissions compared

An agent runs commands that a model chose, and a model can be wrong. This example asks how each harness limits what those commands can do. Two separate mechanisms exist. An approval step asks a person before an action. A sandbox limits what an action can reach even when approved, for example by hiding most of the file system.

Harness Approval Sandbox
mini-swe-agent Interactive agent modes confirm (default), yolo, human; whitelist_actions regexes skip confirmation Environment classes: local, docker, singularity, and extra/bubblewrap.py, extra/swerex_docker.py, extra/swerex_modal.py
Pi None built in; extensions can block tool_call events None built in; the README points to containers
Aider io.confirm_ask before creating files, running shell commands, adding output to the chat None; commands run in the repository on the host
OpenHands SDK confirmation_policy (AlwaysConfirm, NeverConfirm, ConfirmRisky) with a risk level per action from a security analyzer Workspaces in openhands/sdk/workspace/, local or remote
Crush permission.Service with allow lists, hooks, per-session grants, --yolo None for the shell; commands run in an in-process shell interpreter on the host

The program reproduces three approval rules from the source: OpenHands' ConfirmRisky, Aider's --yes-always, and mini-swe-agent's whitelist. Look at the output in three groups: the risk table, the two Aider lines, and the five mini-swe-agent commands.

Java
import java.util.List;
import java.util.regex.Pattern;

public class ApprovalRules {
    enum Risk { UNKNOWN, LOW, MEDIUM, HIGH }

    // OpenHands SDK: ConfirmRisky in openhands/sdk/security/confirmation_policy.py.
    static boolean confirmRisky(Risk risk, Risk threshold, boolean confirmUnknown) {
        if (risk == Risk.UNKNOWN) return confirmUnknown;
        return risk.ordinal() >= threshold.ordinal();          // is_riskier is reflexive: HIGH >= HIGH
    }

    // Aider: confirm_ask in aider/io.py when --yes-always is set (self.yes is True).
    static String aiderYesAlways(boolean explicitYesRequired) { return explicitYesRequired ? "n" : "y"; }

    // mini-swe-agent: confirm mode asks unless re.match(r, action) succeeds for a whitelist regex.
    static boolean miniAsks(String action, List<String> whitelist) {
        return whitelist.stream().noneMatch(r -> Pattern.compile(r).matcher(action).lookingAt());
    }

    public static void main(String[] args) {
        System.out.println("risk     ConfirmRisky(HIGH)  ConfirmRisky(MEDIUM, confirm_unknown=false)");
        for (Risk r : Risk.values())
            System.out.printf("%-8s %-19s %s%n", r, confirmRisky(r, Risk.HIGH, true), confirmRisky(r, Risk.MEDIUM, false));
        System.out.println("aider --yes-always, 'Create new file?'  -> " + aiderYesAlways(false));
        System.out.println("aider --yes-always, 'Run shell command?' -> " + aiderYesAlways(true));
        List<String> whitelist = List.of("ls\\b", "cat ", "git (status|diff)");
        for (String a : List.of("ls -la", "cat app.py", "git diff", "pytest -q", "ls; rm -rf build"))
            System.out.printf("mini confirm mode %-16s -> %s%n", a, miniAsks(a, whitelist) ? "ask" : "run");
    }
}
Output
risk     ConfirmRisky(HIGH)  ConfirmRisky(MEDIUM, confirm_unknown=false)
UNKNOWN  true                false
LOW      false               false
MEDIUM   false               true
HIGH     true                true
aider --yes-always, 'Create new file?'  -> y
aider --yes-always, 'Run shell command?' -> n
mini confirm mode ls -la           -> run
mini confirm mode cat app.py       -> run
mini confirm mode git diff         -> run
mini confirm mode pytest -q        -> ask
mini confirm mode ls; rm -rf build -> run

The first table is OpenHands' ConfirmRisky. Each action carries a predicted risk: the model is asked to label its own tool calls (add_security_risk_prediction=True in _step), and a security analyzer may add its own judgment. With the default threshold HIGH, only HIGH actions are confirmed, and UNKNOWN ones too, because confirm_unknown defaults to true. The comparison is reflexive (HIGH counts as at least as risky as HIGH), which the source comment states. Lowering the threshold to MEDIUM with confirm_unknown=false confirms MEDIUM and HIGH and lets unlabeled actions through. A single finish or think action never needs confirmation (_requires_user_confirmation).

The next two lines are Aider's --yes-always. It answers yes to ordinary questions, but confirm_ask returns "n" when a question has explicit_yes_required=True, and handle_shell_commands sets that flag. So --yes-always creates files without asking but never runs shell commands suggested by the model.

The last lines are mini-swe-agent's whitelist. In confirm mode, a command runs without a question when re.match(r, action) succeeds for any whitelist regex. re.match anchors at the start of the string only, so the regex ls\b also accepts ls; rm -rf build. The rule sees ls at the start and ignores the rest. A whitelist entry must end with $ or reject shell operators to cover only the command it names. The default mini.yaml sets mode: confirm and an empty whitelist.

On sandboxes, mini-swe-agent's bubblewrap environment (environments/extra/bubblewrap.py) builds each command as bwrap, its default wrapper arguments, --bind <cwd> <cwd> --chdir <cwd>, --setenv for each variable, then bash -c <command>. Bubblewrap is a Linux tool that runs a program with a restricted view of the system. The default wrapper arguments mount /usr, /bin, /lib, /lib64, and /etc read-only, give the command a private /tmp, /proc, and /dev, and start a new session. The working directory, by default a fresh minisweagent-<8 hex digits> directory under the system temp directory, is the only writable host path.

A sandbox limits what an approved command can change. A rule on command text, such as Crush's safe list or a whitelist regex, only decides whether to ask. The two work at different layers, so a careful setup can use both.

Example 18: session storage compared

When a program closes, its state is lost unless it was written down. This example asks where each harness writes its state and in what shape. The program prints the locations from the source: Pi's directory name rule, and OpenHands' event file name pattern.

Java
import java.util.List;

public class SessionPaths {
    // Pi: getDefaultSessionDir in dist/core/session-manager.js.
    static String piDir(String cwd) {
        return "~/.pi/agent/sessions/--" + cwd.replaceFirst("^[/\\\\]", "").replaceAll("[/\\\\:]", "-") + "--/";
    }

    // OpenHands SDK: EVENT_FILE_PATTERN in openhands/sdk/conversation/persistence_const.py.
    static String openHandsEvent(int idx, String eventId) {
        return String.format("events/event-%05d-%s.json", idx, eventId);
    }

    public static void main(String[] args) {
        String cwd = "/home/dev/shop";
        System.out.println("mini-swe-agent: <config dir>/last_mini_run.traj.json (-o), rewritten after every step");
        System.out.println("pi:             " + piDir(cwd) + "<timestamp>_<uuid>.jsonl, one entry appended per line");
        System.out.println("aider:          " + cwd + "/.aider.chat.history.md and .aider.input.history (git root)");
        List<String> ids = List.of("3f2a9c1e-77b0", "8d41e0aa-1c52", "c09b7f63-4e1d");
        for (int i = 0; i < ids.size(); i++)
            System.out.println("openhands:      <persistence_dir>/" + openHandsEvent(i, ids.get(i)));
        System.out.println("openhands:      <persistence_dir>/" + openHandsEvent(123_456, "5e6f7a8b-9c0d"));
        System.out.println("crush:          " + cwd + "/.crush/crush.db, SQLite tables sessions, messages, files");
    }
}
Output
mini-swe-agent: <config dir>/last_mini_run.traj.json (-o), rewritten after every step
pi:             ~/.pi/agent/sessions/--home-dev-shop--/<timestamp>_<uuid>.jsonl, one entry appended per line
aider:          /home/dev/shop/.aider.chat.history.md and .aider.input.history (git root)
openhands:      <persistence_dir>/events/event-00000-3f2a9c1e-77b0.json
openhands:      <persistence_dir>/events/event-00001-8d41e0aa-1c52.json
openhands:      <persistence_dir>/events/event-00002-c09b7f63-4e1d.json
openhands:      <persistence_dir>/events/event-123456-5e6f7a8b-9c0d.json
crush:          /home/dev/shop/.crush/crush.db, SQLite tables sessions, messages, files

Here is what each harness stores and why that shape is useful.

mini-swe-agent writes one JSON file, by default last_mini_run.traj.json in its config directory (-o changes it). The file holds info (exit status, submission, cost, API calls, config) and the full messages list, with trajectory_format: "mini-swe-agent-1.1". It is rewritten after every step, which costs a full write per step and keeps the file complete after a crash. The inspector utility (run/utilities/inspector.py) displays these files.

Pi's directory name is the working directory with the leading slash removed and /, \, and : replaced by -, between -- markers, so sessions are grouped by project. For /home/dev/shop that gives --home-dev-shop--. Each file is a JSONL tree (Example 7) appended one line at a time.

Aider writes .aider.chat.history.md, a Markdown transcript, and .aider.input.history, the user's input lines, both in the git root. --restore-chat-history defaults to false, so a new Aider process starts with an empty chat. Resuming is opt-in through that flag.

The OpenHands SDK writes one JSON file per event, event-{idx:05d}-{event_id}.json in an events/ directory, plus base_state.json. The index is zero-padded to five digits but not capped, so the 123,457th event gets a six-digit index (the output shows event-123456). EVENT_NAME_RE accepts five or more digits for that reason. One file per event makes appends cheap and lets a remote server sync events one by one.

Crush keeps everything in SQLite (a database stored in a single file) at .crush/crush.db, in the closest .crush directory up to the project boundary, or one in the working directory. The first migration creates three tables:

  • sessions, with token counts and cost.
  • messages, with the role and a JSON parts column.
  • files, with file contents and a version per session, used for history and undo.

A later migration adds summary_message_id to sessions. getSessionMessages loads messages starting from that id and turns the summary message's role into user. A database gives indexed queries over all sessions at the cost of a schema and migrations.

Example 19: mapping table, each harness concept to its loop counterpart

This example collects everything above into one table and asks where each concept lives in loop, the coding agent this course builds in section 8 (Tool Calling and Coding Agents). The loop names below are the classes and features that section's lessons build:

Concept mini-swe-agent 2.4.6 Pi 0.73.1 Aider 0.86.2 OpenHands SDK 1.51.0 Crush v0.97.1 loop
Entry point run/mini.py dist/cli.js, dist/main.js aider/main.py library: conversation/conversation.py main.go, internal/cmd/ picocli command loop agent
Loop agents/default.py run, step pi-agent-core Agent, wrapped by dist/core/agent-session.js coders/base_coder.py run_one, reflections conversation/impl/local_conversation.py run, agent/agent.py _step internal/agent/agent.go, fantasy Stream Agent class, stop conditions
Tool interface environments/*.py execute dist/core/tools/*.js definitions edit-format coders tool/tool.py ToolDefinition, ToolExecutor internal/agent/tools/*.go + .md Tool interface, registry
Shell environments/local.py (Popen, process group, timeout) dist/core/tools/bash.js run_cmd.py not in this package internal/shell/ (mvdan.cc/sh) shell tool with ProcessBuilder
Edits sed and heredocs from the prompt edit.js, edit-diff.js editblock_coder.py and other coders not in this package edit.go, multiedit.go old-string/new-string edit tool, search/replace parser
System prompt config/mini.yaml dist/core/system-prompt.js coders/*_prompts.py context/prompts/presets.py, sections/ internal/agent/templates/coder.md.tpl system prompt sections
Instruction files none AGENTS.md, CLAUDE.md up the tree --read files AGENTS.md and similar files as skills, skills/skill.py defaultContextPaths in internal/config/config.go AGENTS.md loader in ContextManager
Context limits head and tail of long outputs compaction/compaction.js history.py, repomap.py context/condenser/ StopWhen and Summarize ContextManager, repository map
Providers LiteLLM pi-ai, models.json LiteLLM LiteLLM, non-native tool calls fantasy, buildProvider OpenAI-compatible client to llama-server or vllm serve
Permissions agent mode, whitelist_actions extensions confirm_ask confirmation_policy, security analyzer internal/permission/ PermissionPolicy with allow and deny rules
Repeat guards max_consecutive_format_errors (3), step_limit none in this package max_reflections (3) stuck_detector.py loop_detection.go repeated-call detector
State trajectory JSON JSONL tree Markdown history event files SQLite JSONL session, replay to resume

Reading the table by row gives a design checklist for a new harness: for each concept, see the five choices that exist and pick one. Reading it by column shows each project's center. The shell is the center of mini-swe-agent, the edit format is the center of Aider, the event log is the center of OpenHands, and the terminal application with hooks or extensions is the center of Crush and Pi.

Example 20: reading exercises

Reading skill grows by doing. Each exercise below names a question, the files to open, and what a complete answer contains. Pick one, open the files, and write your answer before comparing it with the last column. All paths are in the versions read in this lesson.

# Question Files A complete answer names
1 What does mini-swe-agent send when the model returns two actions in the text format? minisweagent/models/utils/actions_text.py, models/litellm_textbased_model.py The FormatError, its message text, and that the reply itself is stored in extra, not sent
2 How does mini-swe-agent kill a command that times out, including its child processes? minisweagent/environments/local.py _run start_new_session, os.killpg with SIGKILL, and the output kept from before the timeout
3 How does Pi truncate a long bash result, and where does the full output go? dist/core/tools/bash.js, dist/core/tools/truncate.js Which lines are kept, the line and byte limits, and the Full output: path given to the model
4 What does Pi's summary prompt ask for? dist/core/compaction/compaction.js SUMMARIZATION_PROMPT The section headings of the summary and how a previous summary is updated
5 Which edit format does Aider pick by default for a model, and where is that set? aider/models.py, aider/resources/model-settings.yml The edit_format field and how Coder.create reads it
6 How does Aider's udiff format apply a hunk whose context lines do not match? aider/coders/udiff_coder.py The search strategies and the error text sent back
7 What exactly does the OpenHands stuck detector compare to decide two actions are the same? openhands/sdk/conversation/stuck_detector.py The fields compared and the nudge sent before STUCK
8 How does Crush's edit tool handle a whitespace-only mismatch? internal/agent/tools/edit.go, edit_whitespace.go The normalization steps and when a match is refused
9 What does a Crush PreToolUse hook receive and return? internal/hooks/hooks.go, input.go, internal/agent/hooked_tool.go The stdin payload, the decision values, and how an allow reaches the permission service
10 How do OpenHands tool calls work against a server without native tool calling? openhands/sdk/llm/mixins/non_native_fc.py, fn_call_converter.py Where tools are written into the prompt and how calls are parsed from the reply

Interview points

  • A coding harness is the program around a model that runs the model's tool calls and feeds the results back. It has six parts: entry point, loop, tools, prompts, providers, and state. The loop itself is a few dozen lines in every project read here, and the rest is features around it.
  • mini-swe-agent's loop is 190 lines and has one tool, a shell. It ends the run when a command prints COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT, and it controls flow with exceptions that carry the messages to append.
  • A format error in mini-swe-agent adds only an error message to the history. The malformed reply is kept in extra and not sent back, so the model does not see a bad example of its own output.
  • Pi's edit tool takes several oldText/newText pairs, matches all of them against the original file, rejects duplicates and overlaps, applies them in reverse order so earlier offsets stay valid, and falls back to a whitespace and quote normalized match.
  • Pi stores sessions as an append-only JSONL tree with id/parentId. A branch is a new child entry, and the model's context is the path from a leaf to the root.
  • Aider has no edit tool calls. A coder per edit format parses the reply, and the loop repeats only through reflected messages (failed edits, lint errors, test failures), at most 3 times.
  • Aider's repository map ranks definitions with personalized PageRank over a file reference graph, with weights for long names, mentioned names, and chat files, and fits the map to a token budget with a binary search.
  • The OpenHands SDK keeps state as an event log, stops on execution statuses, detects stuck patterns, and condenses with hard (tokens, requests) and soft (event count) requirements.
  • Crush's bash tool skips the permission check for commands with a safe prefix and no chaining characters. Redirection with > is not in the chaining list, so echo x > file runs without a prompt.
  • Pi's default reserveTokens of 16,384 makes the compaction check pass after every prompt with an 8K window, and Crush skips auto-summarization when the window size is unknown.
  • re.match in a whitelist anchors only at the start, so ls\b also accepts ls; rm -rf build. An approval rule on command text needs an end anchor or an operator check, and a sandbox limits damage independently of the rule.

Summary

  • mini-swe-agent 2.4.6: agents/default.py (loop), environments/local.py (shell with a 30-second default timeout, process-group kill), exceptions.py (control flow), config/mini.yaml (prompt, observation template that keeps 5,000 characters at each end of outputs over 10,000).
  • Pi 0.73.1: AgentSession wraps pi-agent-core's Agent. It has seven tools (read, bash, edit, write, grep, find, ls), four by default. Tool output is truncated at 2,000 lines or 50 KB, and parallel tool calls use a per-file mutation queue.
  • Pi builds its system prompt from active tools, guidelines, AGENTS.md/CLAUDE.md files from the global directory and from the root down to the working directory, skills, date, and working directory.
  • Pi auto-compacts when contextTokens > contextWindow - reserveTokens (16,384). It keeps about keepRecentTokens (20,000) estimated as characters divided by 4, never cuts at a tool result, and summarizes split turns separately.
  • Aider 0.86.2 picks a coder class by edit_format. The diff format uses <<<<<<< SEARCH, =======, >>>>>>> REPLACE markers with 5 to 9 marker characters. Failed blocks produce one report with similar lines, and max_reflections is 3.
  • Aider's repository map budget is the input limit divided by 8, between 1,024 and 4,096 tokens (doubled with no files in the chat). History may use 1/16 of the input limit, between 1,024 and 8,192.
  • Aider sets Ollama's num_ctx to the prompt tokens times 1.25 plus 8,192 when the request does not set it, and --yes-always still declines shell commands.
  • OpenHands SDK 1.51.0: LocalConversation.run is the outer loop (default 500 iterations), Agent._step makes one model call, tools are ToolDefinition objects with an executor, there is a finish tool, and the stuck thresholds are 4, 3, 3, and 6.
  • LLMSummarizingCondenser defaults are 240 events and keep_first 2. default_condenser() uses 80 and 4. Token pressure and requests are hard requirements, and event count is soft.
  • OpenHands ConfirmRisky confirms actions at or above the threshold (default HIGH) and unknown-risk actions when confirm_unknown is true.
  • Crush v0.97.1 delegates the loop to charm.land/fantasy, stops on repeated tool-call signatures (more than 5 in the last 10 steps), and summarizes when remaining tokens fall to 20% of the window (20,000 above 200,000).
  • Crush's permission order is skip flag, allow list, hook approval, session auto-approve, remembered grant, then a UI request. Local server types llamacpp and ollama use the OpenAI-compatible provider.
  • State formats: mini-swe-agent a trajectory JSON rewritten per step, Pi an append-only JSONL tree, Aider Markdown and input history files, OpenHands one JSON file per event, Crush SQLite tables sessions, messages, and files.