Guide contents

Lesson 61 of 73

Edit Formats

A language model cannot change a file. It can only produce text. When a coding agent wants to change Calc.java, the model writes some text that describes the change, and a program in the agent reads that text and edits the file. The convention for what that text looks like is the edit format. The program that reads it and carries out the change is the applier.

The format matters because the applier must be sure of two things before it touches the disk: which place in which file the model means, and that the place exists exactly once. If the model describes the change badly, one of three things happens: the edit is rejected with an error and the model can try again, the edit is applied in the wrong place, or the edit silently destroys part of the file. A good format makes the first outcome common and the last one rare.

This lesson builds a parser and an applier in Java for each common format, measures where each one fails, and ends with criteria for choosing a format for a given model and task. After it you can read the edit format of any coding agent, write the applier for one, and judge which one a small local model should use.

Format The model writes Used by
Whole file The complete new content of the file Aider whole
Search/replace blocks Old lines and new lines between <<<<<<< SEARCH, =======, >>>>>>> REPLACE Aider diff
Unified diff Hunks (groups of changed lines) with , -, + line prefixes, with or without line numbers Aider udiff, git apply
File-level patch Add, delete, and update actions for several files in one text Aider patch (the V4A format)
Edit tool A tool call with path, oldString, newString (old_string, new_string in Crush) Pi, Crush, the loop edit tool

Facts about Aider come from Aider 0.86.2 (aider/coders/), about Pi from Pi 0.73.1 (dist/core/tools/), and about Crush from Crush v0.97.1 (internal/agent/tools/). The examples work on strings and temporary directories, so every output is deterministic.

Example 1: whole-file rewrites and their token cost

The simplest format needs no parser: the model writes the whole file again with the change made. The question is what that costs in output tokens. A token is a small piece of text, roughly a word or part of a word. Output tokens are generated one at a time in the decode phase (Generation, Prefill, Decode, and the KV Cache), so they are the slow part of a reply: a model that writes 12 tokens per second needs a full second for 12 tokens, no matter how short the change is.

This program changes one line of a generated class (return x * 2; to return x * 3; in value3) and compares the size of the three ways to say so: the whole file, a search/replace block, and a unified diff (formats that Examples 2 and 4 explain). It does this for files of 54, 204, and 804 lines. Look at which columns grow with the file and which stay constant.

Java
import java.util.Locale;

public class EditCost {
    static String file(int methods) {
        StringBuilder sb = new StringBuilder("package app;\n\npublic class Calc {\n");
        for (int i = 1; i <= methods; i++)
            sb.append("    public int value").append(i).append("() {\n        int x = ").append(i)
              .append(";\n        return x * 2;\n    }\n\n");
        return sb.append("}\n").toString();
    }

    public static void main(String[] args) {
        String searchReplace = """
                src/Calc.java
                <<<<<<< SEARCH
                    public int value3() {
                        int x = 3;
                        return x * 2;
                =======
                    public int value3() {
                        int x = 3;
                        return x * 3;
                >>>>>>> REPLACE
                """;
        String udiff = """
                --- src/Calc.java
                +++ src/Calc.java
                @@ ... @@
                     public int value3() {
                         int x = 3;
                -        return x * 2;
                +        return x * 3;
                """;
        double charsPerToken = 3.09;     // measured: the 204-line file is 3,072 chars and 995 Qwen2 tokens
        double tokensPerSecond = 12;     // an assumed decode speed for the arithmetic
        System.out.println("lines  format          chars  ~tokens  ~seconds");
        for (int methods : new int[] {10, 40, 160}) {
            String whole = "src/Calc.java\n" + file(methods).replace("x = 3;\n        return x * 2;", "x = 3;\n        return x * 3;");
            int lines = (int) file(methods).lines().count();
            for (String[] f : new String[][] {{"whole file", whole}, {"search/replace", searchReplace}, {"udiff", udiff}}) {
                int tokens = (int) Math.ceil(f[1].length() / charsPerToken);
                System.out.println(String.format(Locale.ROOT, "%5d  %-14s %6d  %7d  %8.1f",
                        lines, f[0], f[1].length(), tokens, tokens / tokensPerSecond));
            }
        }
    }
}
Output
lines  format          chars  ~tokens  ~seconds
   54  whole file        792      257      21.4
   54  search/replace    187       61       5.1
   54  udiff             139       45       3.8
  204  whole file       3072      995      82.9
  204  search/replace    187       61       5.1
  204  udiff             139       45       3.8
  804  whole file      12314     3986     332.2
  804  search/replace    187       61       5.1
  804  udiff             139       45       3.8

The program turns characters into tokens with a ratio (3.09 characters per token) that comes from a measurement. The 204-line version of the file was written out and counted with the Qwen2 tokenizer from the pinned llama.cpp build. The three commands count the whole file, the search/replace block, and the diff:

Terminal
llama-tokenize -m models/ggml-vocab-qwen2.gguf -f whole.txt --no-bos --show-count --log-disable | tail -1
llama-tokenize -m models/ggml-vocab-qwen2.gguf -f sr.txt --no-bos --show-count --log-disable | tail -1
llama-tokenize -m models/ggml-vocab-qwen2.gguf -f ud.txt --no-bos --show-count --log-disable | tail -1

Real output (pinned llama.cpp build). Each line is the token count of one file, in the order of the commands:

Output
Total number of tokens: 995
Total number of tokens: 54
Total number of tokens: 45

The file is 3,072 characters and 995 tokens, about 3.09 characters per token. That is lower than the 4.83 measured for ordinary Java in Context Engineering for Coding Agents, because this file is full of short lines, eight-space indents, and digits, and the Qwen2 tokenizer gives every digit its own token. The program uses 3.09 for all three formats, which slightly overestimates the two small ones (61 estimated against 54 real for search/replace, 45 against 45 for the diff).

The output table shows the pattern that decides most format choices:

  • Whole file grows with the file: about 257 tokens for 54 lines, 995 for 204 lines, and 3,986 for 804 lines. At an assumed 12 tokens per second, rewriting the 804-line file to change one character takes about 332 seconds.
  • Search/replace and udiff stay the same size for every file, because they contain only the changed region and a few lines around it: 61 and 45 estimated tokens, about 5 and 4 seconds.
  • The rewrite also spends input tokens: the model must have seen the whole file to reproduce it, and the reply becomes part of the history that the next request sends again.

Whole-file output has one advantage the table does not show: nothing has to match. The model cannot fail to locate the old text, because there is no old text. Aider uses whole as the default edit format for models it has no settings for (edit_format: str = "whole" in ModelSettings, aider/models.py), and its prompt for the format (wholefile_prompts.py) insists that the model must "NEVER skip, omit or elide content from a file listing", which names the main failure of the format: a model that writes // ... rest of the code unchanged and deletes the rest of the file. Example 11 measures that failure.

Example 2: search/replace blocks, format and parser

Writing the whole file is wasteful when only one line changes. A cheaper idea is to tell the applier what the old text looks like and what should replace it, as with the find-and-replace dialog of an editor. That is a search/replace block. It names a file, the exact lines to find, and the lines to put in their place.

The format comes from Aider (editblock_coder.py and editblock_prompts.py): the file path alone on a line, then <<<<<<< SEARCH, the old lines, =======, the new lines, and >>>>>>> REPLACE. Aider asks the model to put each block inside a code fence; the parser below works without fences, because it looks only at the marker lines and the line before them. A parser is the code that turns the model's text into structured data, here a list of Edit records.

The program parses a reply that mixes ordinary prose with three blocks, then parses a broken block to show the error. In the output, check which file each block was assigned to, especially the third block, which has no file name.

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

public class SearchReplaceParser {
    record Edit(String path, String search, String replace) {}

    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*$");

    static List<Edit> parse(String reply) {
        String[] lines = reply.split("\n", -1);
        List<Edit> edits = new ArrayList<>();
        String currentPath = null;
        for (int i = 0; i < lines.length; i++) {
            if (!HEAD.matcher(lines[i].strip()).matches()) continue;
            String path = i > 0 ? lines[i - 1].strip() : "";        // the file name is the line before
            if (path.isEmpty() || HEAD.matcher(path).matches() || UPDATED.matcher(path).matches()) path = currentPath;
            if (path == null) throw new IllegalArgumentException("line " + (i + 1) + ": missing file name before SEARCH");
            currentPath = path;
            StringBuilder search = new StringBuilder(), replace = new StringBuilder();
            int j = i + 1;
            while (j < lines.length && !DIVIDER.matcher(lines[j].strip()).matches()) search.append(lines[j++]).append('\n');
            if (j == lines.length) throw new IllegalArgumentException("line " + (i + 1) + ": expected =======");
            j++;
            while (j < lines.length && !UPDATED.matcher(lines[j].strip()).matches()) replace.append(lines[j++]).append('\n');
            if (j == lines.length) throw new IllegalArgumentException("line " + (i + 1) + ": expected >>>>>>> REPLACE");
            edits.add(new Edit(path, search.toString(), replace.toString()));
            i = j;
        }
        return edits;
    }

    public static void main(String[] args) {
        String reply = """
                I will rename the method and update the caller.

                src/Calc.java
                <<<<<<< SEARCH
                    public int value3() {
                =======
                    public int tripled() {
                >>>>>>> REPLACE

                src/Main.java
                <<<<<<< SEARCH
                        System.out.println(calc.value3());
                =======
                        System.out.println(calc.tripled());
                >>>>>>> REPLACE
                <<<<<<< SEARCH
                import app.Calc;
                =======
                import app.Calc;
                import java.util.List;
                >>>>>>> REPLACE
                """;
        for (Edit e : parse(reply))
            System.out.println(e.path() + ": " + e.search().lines().count() + " line(s) -> "
                    + e.replace().lines().count() + " line(s), search starts with \"" + e.search().strip() + "\"");
        try {
            parse("src/A.java\n<<<<<<< SEARCH\nint a = 1;\n>>>>>>> REPLACE\n");
        } catch (IllegalArgumentException ex) {
            System.out.println("error: " + ex.getMessage());
        }
    }
}
Output
src/Calc.java: 1 line(s) -> 1 line(s), search starts with "public int value3() {"
src/Main.java: 1 line(s) -> 1 line(s), search starts with "System.out.println(calc.value3());"
src/Main.java: 1 line(s) -> 2 line(s), search starts with "import app.Calc;"
error: line 2: expected =======

The reply mixes prose with three blocks, and the parser finds all three:

  • The first block belongs to src/Calc.java, the line right before its SEARCH marker. It replaces one line with one line.
  • The second block names src/Main.java.
  • The third block has no file name before it: the line before its SEARCH marker is the REPLACE marker of the previous block. The parser reuses the last file name, as Aider does (current_filename in find_original_update_blocks), so the block applies to src/Main.java and turns one import line into two.

The marker patterns are Aider's own: ^<{5,9} SEARCH>?\s*$, ^={5,9}\s*$, and ^>{5,9} REPLACE\s*$. Allowing five to nine marker characters accepts the near misses that models produce, such as six < characters instead of seven.

The last line shows a malformed block: a SEARCH section followed directly by REPLACE, with no divider. The parser reports line 2: expected =======, which names the block by line and says which marker is missing. Aider raises the same kind of error, with the text of the reply up to the problem and the message Expected `=======` , and sends it back to the model.

Each SEARCH section in this reply is short, a single line. Search/replace is cheap because the model copies only what it needs to identify the place, and Aider's prompt asks for exactly that: "Include just the changing lines, and a few surrounding lines if needed for uniqueness."

Example 3: applying search/replace blocks

Parsing produces edits; applying them is where a format succeeds or fails. The applier looks for the SEARCH text in the file and swaps it for the REPLACE text. Three situations need a decision:

  • An empty SEARCH section means there is nothing to find, so the applier creates the file or appends to it.
  • A SEARCH text that is not in the file fails, because the model's copy of the code was wrong.
  • A SEARCH text that appears more than once is ambiguous, and the applier follows one of two policies: replace the first match, or require exactly one match.

The program runs four edits against a small class twice, once for each policy. The first edit is the interesting one: it means to change b(), but its text return 1; appears in both methods. Compare the file printed at the end of each run.

Java
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class ApplySearchReplace {
    record Edit(String path, String search, String replace) {}

    // Returns null on success, or an error message. requireUnique=false replaces the first match.
    static String apply(Map<String, String> files, Edit e, boolean requireUnique) {
        String content = files.get(e.path());
        if (e.search().isBlank()) {                                  // empty SEARCH: create or append
            files.put(e.path(), (content == null ? "" : content) + e.replace());
            return null;
        }
        if (content == null) return e.path() + ": no such file";
        int first = content.indexOf(e.search());
        if (first < 0) return e.path() + ": SEARCH text not found";
        if (requireUnique && content.indexOf(e.search(), first + 1) >= 0)
            return e.path() + ": SEARCH text matches more than once; add surrounding lines";
        files.put(e.path(), content.substring(0, first) + e.replace() + content.substring(first + e.search().length()));
        return null;
    }

    public static void main(String[] args) {
        String calc = """
                class Calc {
                    int a() { return 1; }
                    int b() { return 1; }
                }
                """;
        List<Edit> edits = List.of(
                new Edit("Calc.java", "return 1;", "return 2;"),          // meant for b()
                new Edit("Calc.java", "    int a() { return 1; }\n", "    int a() { return 10; }\n"),
                new Edit("Util.java", "", "class Util {}\n"),
                new Edit("Calc.java", "int c()", "int d()"));
        for (boolean unique : new boolean[] {false, true}) {
            Map<String, String> files = new TreeMap<>(Map.of("Calc.java", calc));
            System.out.println(unique ? "== require a unique match" : "== replace the first match (Aider)");
            for (Edit e : edits) {
                String error = apply(files, e, unique);
                System.out.println(error == null ? "ok    " + e.path() : "FAIL  " + error);
            }
            System.out.print(files.get("Calc.java"));
            System.out.println("files: " + files.keySet());
        }
    }
}
Output
== replace the first match (Aider)
ok    Calc.java
FAIL  Calc.java: SEARCH text not found
ok    Util.java
FAIL  Calc.java: SEARCH text not found
class Calc {
    int a() { return 2; }
    int b() { return 1; }
}
files: [Calc.java, Util.java]
== require a unique match
FAIL  Calc.java: SEARCH text matches more than once; add surrounding lines
ok    Calc.java
ok    Util.java
FAIL  Calc.java: SEARCH text not found
class Calc {
    int a() { return 10; }
    int b() { return 1; }
}
files: [Calc.java, Util.java]

The four edits run twice on a fresh copy of Calc.java, once per policy. The first edit, return 1; to return 2;, was meant for b(), but the text return 1; appears in both methods.

With replace the first match, the policy that Aider's prompt describes ("SEARCH/REPLACE blocks will only replace the first match occurrence"):

  • The first edit succeeds and changes a(), the first occurrence, not b().
  • The second edit then fails: it searches for int a() { return 1; }, which the first edit has already changed.
  • The third edit has an empty SEARCH section and creates Util.java.
  • The fourth edit searches for int c(), which does not exist, and fails.

The final file has a() returning 2 and b() returning 1, the opposite of what the model meant, and nothing reported the cause: the first edit's report says ok.

With require a unique match, the ambiguous edit fails with a message that tells the model to add surrounding lines. The second edit then finds a() unchanged and succeeds, so the file ends with a() returning 10 and b() unchanged; the model can retry the first edit with more context. The other two edits behave as before.

The unique-match rule turns a silent wrong edit into a visible error. The edit tools of Pi and Crush both use it, as Example 7 shows.

Example 4: unified diffs, format and parser

A unified diff is the format of the diff -u command and of git diff, so most developers have seen one. Instead of quoting the old text and the new text separately, it lists the changed lines once, with a marker in front of each line. The advantage is that the change is shown in the order it appears in the file, with unchanged lines around it for orientation.

Each file starts with --- old path and +++ new path. Each hunk, a group of nearby changes, starts with a header @@ -oldStart,oldCount +newStart,newCount @@ and continues with lines that start with a space (context, unchanged), - (removed), or + (added). The header counts must agree with the body: context plus removed lines equal oldCount, and context plus added lines equal newCount. For a hunk with three context lines, one removed line, and one added line, that is 3 + 1 = 4 old lines and 3 + 1 = 4 new lines.

The program parses a diff with two hunks and checks each header against its body. The second hunk has a wrong count on purpose.

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

public class UnifiedDiffParser {
    record Hunk(int oldStart, int oldCount, int newStart, int newCount, List<String> lines) {}
    record FileDiff(String oldPath, String newPath, List<Hunk> hunks) {}

    static final Pattern HUNK = Pattern.compile("^@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@.*");

    static List<FileDiff> parse(String diff) {
        List<FileDiff> files = new ArrayList<>();
        String[] lines = diff.split("\n");
        for (int i = 0; i < lines.length; i++) {
            if (!lines[i].startsWith("--- ") || i + 1 >= lines.length || !lines[i + 1].startsWith("+++ ")) continue;
            FileDiff fd = new FileDiff(strip(lines[i].substring(4)), strip(lines[i + 1].substring(4)), new ArrayList<>());
            i += 2;
            while (i < lines.length && !lines[i].startsWith("--- ")) {
                Matcher m = HUNK.matcher(lines[i]);
                if (!m.matches()) throw new IllegalArgumentException("line " + (i + 1) + ": expected @@ header");
                List<String> body = new ArrayList<>();
                for (i++; i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("--- "); i++)
                    body.add(lines[i]);
                fd.hunks().add(new Hunk(num(m.group(1)), m.group(2) == null ? 1 : num(m.group(2)),
                        num(m.group(3)), m.group(4) == null ? 1 : num(m.group(4)), body));
            }
            i--;
            files.add(fd);
        }
        return files;
    }

    static String strip(String path) { return path.startsWith("a/") || path.startsWith("b/") ? path.substring(2) : path; }
    static int num(String s) { return Integer.parseInt(s); }

    // A hunk's header counts must equal its context+removed and context+added lines.
    static String check(Hunk h) {
        long before = h.lines().stream().filter(l -> l.startsWith(" ") || l.startsWith("-")).count();
        long after = h.lines().stream().filter(l -> l.startsWith(" ") || l.startsWith("+")).count();
        return before == h.oldCount() && after == h.newCount() ? "counts ok"
                : "header says -" + h.oldCount() + " +" + h.newCount() + ", body has -" + before + " +" + after;
    }

    public static void main(String[] args) {
        String diff = """
                --- a/src/Calc.java
                +++ b/src/Calc.java
                @@ -3,4 +3,4 @@ public class Calc {
                     public int value1() {
                         int x = 1;
                -        return x * 2;
                +        return x * 3;
                     }
                @@ -20,2 +20,2 @@
                     }
                +    // end of values
                 }
                """;
        for (FileDiff fd : parse(diff)) {
            System.out.println(fd.oldPath() + " -> " + fd.newPath() + ", " + fd.hunks().size() + " hunks");
            for (Hunk h : fd.hunks())
                System.out.println("  @@ -" + h.oldStart() + "," + h.oldCount() + " +" + h.newStart() + "," + h.newCount()
                        + ": " + h.lines().size() + " lines, " + check(h));
        }
    }
}
Output
src/Calc.java -> src/Calc.java, 2 hunks
  @@ -3,4 +3,4: 5 lines, counts ok
  @@ -20,2 +20,2: 3 lines, header says -2 +2, body has -2 +3

The parser strips the a/ and b/ prefixes that git diff adds, so both paths read src/Calc.java, and it finds two hunks:

  • The first hunk's header says 4 old and 4 new lines starting at line 3. The body has three context lines, one removed, and one added: 3 + 1 = 4 on each side, so the counts agree. Text after the second @@ (public class Calc {) is an optional section name that the parser ignores.
  • The second hunk's header says 2 old and 2 new lines, but its body adds a line: 2 context lines plus 1 added line make 3 new lines. The check reports the mismatch.

Header counts are hard for a model to get right, because the header comes first: the model must write the number of lines before it writes the lines, so it has to plan the count ahead instead of counting what it has produced. An applier that trusts the header misreads a hunk whose counts disagree with its body, so a strict applier rejects such a hunk. Aider avoids the problem by not asking for numbers: its udiff prompt (udiff_prompts.py) tells the model to start each hunk with @@ ... @@ and says "Don't include line numbers like diff -U0 does. The user's patch tool doesn't need them." Aider's parser (find_diffs and process_fenced_block in udiff_coder.py) splits hunks at @@ lines and never reads the numbers.

Example 5: applying unified diffs with context matching and wrong line numbers

The line numbers in a hunk are a promise that the file looks the way the model last saw it. That promise breaks easily: an earlier edit may have added lines above the hunk, or the model may miscount. The question is what the applier does when the hunk's line numbers are wrong but its lines are right.

The program applies one hunk in two modes. Strict mode accepts the hunk only at the stated line. Search mode looks outward from the stated line, one line up and one line down at a time, and uses the nearest place where the hunk's old lines match. The hunk says line 4, but the code is at line 7.

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

public class ApplyUnifiedDiff {
    record Hunk(int oldStart, List<String> lines) {
        List<String> before() { return lines.stream().filter(l -> !l.startsWith("+")).map(l -> l.substring(1)).toList(); }
        List<String> after() { return lines.stream().filter(l -> !l.startsWith("-")).map(l -> l.substring(1)).toList(); }
    }

    static boolean matchesAt(List<String> file, List<String> part, int at) {
        return at >= 0 && at + part.size() <= file.size() && file.subList(at, at + part.size()).equals(part);
    }

    // strict: only at oldStart. search: the match nearest to oldStart, looking outward line by line.
    static String apply(List<String> file, Hunk h, boolean search) {
        List<String> before = h.before();
        int expected = h.oldStart() - 1, found = -1;
        if (matchesAt(file, before, expected)) found = expected;
        else if (search)
            for (int d = 1; d < file.size() && found < 0; d++) {
                if (matchesAt(file, before, expected - d)) found = expected - d;
                else if (matchesAt(file, before, expected + d)) found = expected + d;
            }
        if (found < 0) return "FAILED (context not found" + (search ? ")" : " at line " + h.oldStart() + ")");
        List<String> out = new ArrayList<>(file.subList(0, found));
        out.addAll(h.after());
        out.addAll(file.subList(found + before.size(), file.size()));
        file.clear();
        file.addAll(out);
        return "applied at line " + (found + 1) + " (offset " + (found + 1 - h.oldStart()) + ")";
    }

    public static void main(String[] args) {
        List<String> original = List.of("package app;", "", "import java.util.List;", "import java.util.Map;", "",
                "public class Calc {", "    public int value1() {", "        int x = 1;", "        return x * 2;", "    }", "}");
        // The model wrote line 4; the code is at line 7 because of the two imports it did not see.
        Hunk hunk = new Hunk(4, List.of(" " + "    public int value1() {", " " + "        int x = 1;",
                "-" + "        return x * 2;", "+" + "        return x * 3;", " " + "    }"));
        for (boolean search : new boolean[] {false, true}) {
            List<String> file = new ArrayList<>(original);
            System.out.println((search ? "search: " : "strict: ") + apply(file, hunk, search));
            if (search) System.out.println("line 9 is now: " + file.get(8).strip());
        }
    }
}
Output
strict: FAILED (context not found at line 4)
search: applied at line 7 (offset 3)
line 9 is now: return x * 3;

The model wrote the hunk for line 4, but the method starts at line 7, because the file has two import lines and a blank line that the model's view lacked.

  • Strict compares the hunk's old lines (the context lines and the removed line) with lines 4 to 7 of the file, finds import java.util.Map; there instead of the method header, and fails.
  • Search looks outward from line 4, one line up and one line down at a time, finds the old lines starting at line 7, and applies the hunk there with an offset of 3. Line 9 now holds return x * 3;.

Searching outward from the stated line and taking the nearest match uses the line number as a hint instead of a requirement. When the same lines occur twice in the file, the nearest match is the likely one, which is the reason to keep the number at all. Aider drops the number entirely: apply_hunk in udiff_coder.py turns each hunk into before and after text and calls a search-and-replace on the whole file. It also refuses a match on a tiny context: directly_apply_hunk returns no result when the before text has fewer than 10 non-whitespace characters and occurs more than once, and when a hunk matches several places the error UnifiedDiffNotUnique asks the model for more context lines. When a whole hunk fails, Aider retries it in pieces with less context (apply_partial_hunk), dropping context lines from the edges until the changed lines alone apply, which accepts edits whose outer context lines were miscopied.

Example 6: a custom file-level patch format

Search/replace blocks and diffs describe changes inside files that exist. Some tasks also add and delete files, for example when a class is split into two or an old class is removed. A file-level patch format puts all of these actions into one text, so the whole change is one unit.

The format below is a small subset of the V4A format that Aider implements as its patch edit format (patch_coder.py, whose helpers are marked "Adapted from apply_patch.py"). The text is wrapped in *** Begin Patch and *** End Patch. Inside, *** Add File: is followed by + lines, *** Delete File: stands alone, and *** Update File: is followed by hunks that start with @@.

The program parses one patch that adds a file, updates a file, and deletes a file, prints the operations it found, applies them to an in-memory set of files, and prints the result.

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

public class PatchFormat {
    sealed interface Op permits Add, Delete, Update {}
    record Add(String path, String content) implements Op {}
    record Delete(String path) implements Op {}
    record Update(String path, List<List<String>> hunks) implements Op {}

    static List<Op> parse(String patch) {
        List<String> lines = patch.lines().toList();
        if (!lines.get(0).equals("*** Begin Patch") || !lines.get(lines.size() - 1).equals("*** End Patch"))
            throw new IllegalArgumentException("patch must start with *** Begin Patch and end with *** End Patch");
        List<Op> ops = new ArrayList<>();
        int i = 1;
        while (i < lines.size() - 1) {
            String l = lines.get(i++);
            if (l.startsWith("*** Delete File: ")) ops.add(new Delete(l.substring(17)));
            else if (l.startsWith("*** Add File: ")) {
                StringBuilder sb = new StringBuilder();
                while (lines.get(i).startsWith("+")) sb.append(lines.get(i++).substring(1)).append('\n');
                ops.add(new Add(l.substring(14), sb.toString()));
            } else if (l.startsWith("*** Update File: ")) {
                List<List<String>> hunks = new ArrayList<>();
                while (lines.get(i).equals("@@")) {
                    List<String> h = new ArrayList<>();
                    for (i++; !lines.get(i).startsWith("@@") && !lines.get(i).startsWith("***"); i++) h.add(lines.get(i));
                    hunks.add(h);
                }
                ops.add(new Update(l.substring(17), hunks));
            } else throw new IllegalArgumentException("line " + i + ": unknown action: " + l);
        }
        return ops;
    }

    static void apply(Map<String, String> files, Op op) {
        switch (op) {
            case Add a -> files.put(a.path(), a.content());
            case Delete d -> { if (files.remove(d.path()) == null) throw new IllegalStateException("no file " + d.path()); }
            case Update u -> {
                String content = files.get(u.path());
                for (List<String> h : u.hunks()) {
                    String before = join(h, "+"), after = join(h, "-");
                    int at = content.indexOf(before);
                    if (at < 0) throw new IllegalStateException(u.path() + ": context not found");
                    content = content.substring(0, at) + after + content.substring(at + before.length());
                }
                files.put(u.path(), content);
            }
        }
    }

    // Lines of a hunk without the marker; lines starting with `skip` are left out.
    static String join(List<String> hunk, String skip) {
        StringBuilder sb = new StringBuilder();
        for (String l : hunk) if (!l.startsWith(skip)) sb.append(l.substring(1)).append('\n');
        return sb.toString();
    }

    public static void main(String[] args) {
        Map<String, String> files = new TreeMap<>(Map.of(
                "src/Calc.java", "class Calc {\n    int a() {\n        return 1;\n    }\n}\n",
                "src/Old.java", "class Old {}\n"));
        String patch = """
                *** Begin Patch
                *** Add File: src/Util.java
                +class Util {
                +}
                *** Update File: src/Calc.java
                @@
                     int a() {
                -        return 1;
                +        return 10;
                *** Delete File: src/Old.java
                *** End Patch
                """;
        List<Op> ops = parse(patch);
        for (Op op : ops) System.out.println(switch (op) {
            case Add a -> "add " + a.path() + " (" + a.content().lines().count() + " lines)";
            case Delete d -> "delete " + d.path();
            case Update u -> "update " + u.path() + " (" + u.hunks().size() + " hunk)";
        });
        ops.forEach(op -> apply(files, op));
        files.forEach((path, content) -> System.out.print("== " + path + "\n" + content));
    }
}
Output
add src/Util.java (2 lines)
update src/Calc.java (1 hunk)
delete src/Old.java
== src/Calc.java
class Calc {
    int a() {
        return 10;
    }
}
== src/Util.java
class Util {
}

The parser turned the patch into three operations, in the order the patch lists them:

  • add src/Util.java (2 lines): every line after the marker starts with +, and the parser keeps the text after the +.
  • update src/Calc.java (1 hunk): the hunk has one context line, one removed line, and one added line, with no line numbers. The applier joins the context and removed lines into the old text, the context and added lines into the new text, and replaces the first occurrence, as in Example 3.
  • delete src/Old.java: the file is removed from the map.

After applying, the map holds src/Calc.java with return 10; and the new src/Util.java; src/Old.java is gone. The TreeMap prints the files in path order.

The format's markers do the parser's work. Every line starts with ***, @@, a space, -, or +, so each line's role is known without looking ahead, and the parser can report unknown action with a line number for anything else. The sentinel lines (the begin and end markers) make a cut-off reply detectable: a reply that hits the token limit has no *** End Patch, and the parser rejects it before any file changes.

Aider's version is lenient here: the check for *** End Patch is commented out in its parser, and when *** Begin Patch is missing but the reply contains patch actions, it prints "Patch format warning: Missing '*** Begin Patch'/'*** End Patch' sentinels." and parses the reply anyway. Aider's prompt for V4A (patch_prompts.py) also requires each file to appear only once in a patch and asks for three lines of context before and after each change, with @@ ClassName markers when three lines are not unique.

Example 7: the oldString/newString edit tool

The formats so far live in the reply text, and the agent must find them inside prose. The edit tool moves the edit into a tool call, the structured request that the model already uses to read files or run commands. Its arguments are a path, the exact text to replace, the new text, and an optional replaceAll flag. The agent receives these as JSON, so no marker lines need parsing.

File Tools builds this tool as edit_file against a real workspace, with the arguments path, oldString, newString, and replaceAll taken from its EditArgs record. The version below restates its rules on an in-memory file so that the formats can be compared. The rules are: the old text must match exactly, and it must be unique unless replaceAll is true.

The program makes four calls and prints the tool's answer to each. It ends by measuring how much longer a three-line replacement becomes when it is written as a JSON string.

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

public class EditTool {
    static final ObjectMapper JSON = new ObjectMapper();
    static final Map<String, String> files = new HashMap<>();

    // The edit tool of the file tools lesson: exact oldString, unique unless replaceAll is true.
    static String edit(String argumentsJson) throws Exception {
        JsonNode a = JSON.readTree(argumentsJson);
        String path = a.path("path").asText(), oldS = a.path("oldString").asText(), newS = a.path("newString").asText();
        boolean all = a.path("replaceAll").asBoolean(false);
        String content = files.get(path);
        if (content == null) return "error: file not found: " + path;
        int count = 0;
        for (int i = content.indexOf(oldS); i >= 0; i = content.indexOf(oldS, i + oldS.length())) count++;
        if (count == 0) return "error: oldString not found in " + path;
        if (count > 1 && !all)
            return "error: oldString appears " + count + " times in " + path + "; add context or set replaceAll";
        files.put(path, all ? content.replace(oldS, newS) : content.replaceFirst(
                java.util.regex.Pattern.quote(oldS), java.util.regex.Matcher.quoteReplacement(newS)));
        return "ok: replaced " + (all ? count : 1) + " occurrence(s) in " + path;
    }

    public static void main(String[] args) throws Exception {
        files.put("Calc.java", "class Calc {\n    int a() { return 1; }\n    int b() { return 1; }\n}\n");
        System.out.println(edit("{\"path\":\"Calc.java\",\"oldString\":\"return 1;\",\"newString\":\"return 3;\"}"));
        String call = JSON.writeValueAsString(JSON.createObjectNode().put("path", "Calc.java")
                .put("oldString", "    int a() { return 1; }")
                .put("newString", "    int a() {\n        return \"one\".length();\n    }"));
        System.out.println("arguments: " + call);
        System.out.println(edit(call));
        System.out.println(edit("{\"path\":\"Calc.java\",\"oldString\":\"return 2;\",\"newString\":\"return 3;\"}"));
        System.out.println(edit("{\"path\":\"Calc.java\",\"oldString\":\"int \",\"newString\":\"long \",\"replaceAll\":true}"));
        System.out.print(files.get("Calc.java"));

        String raw = "    int a() {\n        return \"one\".length();\n    }";
        String escaped = JSON.writeValueAsString(raw);
        System.out.println("newString: " + raw.length() + " chars raw, " + escaped.length() + " chars as a JSON string");
    }
}
Output
error: oldString appears 2 times in Calc.java; add context or set replaceAll
arguments: {"path":"Calc.java","oldString":"    int a() { return 1; }","newString":"    int a() {\n        return \"one\".length();\n    }"}
ok: replaced 1 occurrence(s) in Calc.java
error: oldString not found in Calc.java
ok: replaced 2 occurrence(s) in Calc.java
class Calc {
    long a() {
        return "one".length();
    }
    long b() { return 1; }
}
newString: 50 chars raw, 56 chars as a JSON string

The four calls show each rule of the tool:

  1. return 1; appears twice, and replaceAll is not set, so the tool refuses and says how many times the text occurs. This is the unique-match policy of Example 3.
  2. The second call is built with Jackson, so the arguments are a valid JSON object. The new text spans three lines and contains quotes, which JSON encodes as \n and \". The tool replaces the single occurrence.
  3. return 2; is not in the file, and the tool says so.
  4. int with replaceAll: true replaces both remaining occurrences, in a() and b(), so both methods now return long.

The last line measures the cost of carrying code inside JSON: the three-line replacement is 50 characters as raw text and 56 characters as a JSON string, because every newline and every quote gains a backslash, plus the two surrounding quotes. Code with many quotes or backslashes (string literals, regular expressions, Windows paths) grows more. The model must produce these escapes correctly while writing code, and a missing backslash makes the whole tool call invalid JSON. Grammar-constrained decoding, which the later lesson Making Small Local Models Reliable uses, guarantees the JSON syntax but not that the escaped text is what the model meant.

Pi and Crush use this tool shape. Crush's edit tool (edit.go) requires exactly one match unless replace_all is true and returns "old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true" otherwise. Pi's edit tool accepts several replacements in one call (applyEditsToNormalizedContent in edit-diff.js): it matches every old text against the original content, rejects a call where two replacements overlap, and applies them from the end of the file backwards so that earlier offsets stay valid.

Example 8: fuzzy matching levels

Exact matching fails on differences that do not change the meaning of the code: trailing spaces, a dropped indentation level, extra spaces inside a line. Models make these small copying errors often, and rejecting the edit costs a whole retry. Fuzzy matching tries looser comparisons in a fixed order, from strict to loose, and stops at the first level with exactly one match.

The levels below come from the three harnesses:

Level What is ignored Source
EXACT Nothing All
TRAILING_WS Spaces at the end of a line, typographic quotes Pi
INDENT Leading whitespace, if every line is off by the same amount Aider
WS_RUNS Differences in runs of whitespace Crush
SIMILAR Small character differences (80 percent similarity) Aider

The program runs seven searches against a small class, each with a typical model error, and prints which level found a unique match. The file's total method header ends with three trailing spaces on purpose.

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

public class FuzzyMatch {
    enum Level { EXACT, TRAILING_WS, INDENT, WS_RUNS, SIMILAR }

    static final List<UnaryOperator<String>> NORMALIZE = List.of(
            s -> s,                                                     // EXACT
            s -> s.stripTrailing().replace('‘', '\'').replace('’', '\''), // TRAILING_WS
            String::strip,                                              // INDENT (offset checked below)
            s -> String.join(" ", s.strip().split("\\s+")));            // WS_RUNS

    // Returns "level line" of the unique match, or a reason for failure.
    static String find(List<String> file, List<String> search) {
        for (Level level : Level.values()) {
            int found = -1, count = 0;
            for (int i = 0; i + search.size() <= file.size(); i++) {
                List<String> window = file.subList(i, i + search.size());
                boolean ok = level == Level.SIMILAR
                        ? similarity(String.join("\n", window), String.join("\n", search)) >= 0.8
                        : sameUnder(NORMALIZE.get(level.ordinal()), window, search)
                          && (level != Level.INDENT || sameOffset(window, search));
                if (ok) { found = i; count++; }
            }
            if (count == 1) return level + " at line " + (found + 1);
            if (count > 1) return level + " matches " + count + " places: ambiguous";
        }
        return "no match";
    }

    static boolean sameUnder(UnaryOperator<String> n, List<String> a, List<String> b) {
        for (int i = 0; i < a.size(); i++) if (!n.apply(a.get(i)).equals(n.apply(b.get(i)))) return false;
        return true;
    }

    static int indent(String s) { return s.length() - s.stripLeading().length(); }

    static boolean sameOffset(List<String> file, List<String> search) {   // one indent difference for all lines
        Integer offset = null;
        for (int i = 0; i < file.size(); i++) {
            if (file.get(i).isBlank()) continue;
            int d = indent(file.get(i)) - indent(search.get(i));
            if (offset == null) offset = d; else if (offset != d) return false;
        }
        return true;
    }

    // 2 * LCS / (|a| + |b|), the shape of Python difflib's ratio.
    static double similarity(String a, String b) {
        int[][] dp = new int[a.length() + 1][b.length() + 1];
        for (int i = 1; i <= a.length(); i++)
            for (int j = 1; j <= b.length(); j++)
                dp[i][j] = a.charAt(i - 1) == b.charAt(j - 1) ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
        return 2.0 * dp[a.length()][b.length()] / (a.length() + b.length());
    }

    public static void main(String[] args) {
        List<String> file = List.of("class Calc {", "    int total(int a, int b) {   ", "        int sum = a + b;",
                "        return sum;", "    }", "    String name() {", "        return \"calc\";", "    }", "}");
        String[] labels = {"exact copy", "file has trailing spaces", "indentation dropped", "extra spaces inside",
                "typo: retrun", "invented: sum * 2", "closing brace only"};
        String[][] searches = {
                {"        int sum = a + b;", "        return sum;"},
                {"    int total(int a, int b) {", "        int sum = a + b;"},
                {"int sum = a + b;", "return sum;"},
                {"        int sum  =  a + b;"},
                {"        int sum = a + b;", "        retrun sum;"},
                {"        return sum * 2;", "    }"},
                {"    }"}};
        for (int i = 0; i < searches.length; i++)
            System.out.println(String.format(Locale.ROOT, "%-25s -> %s", labels[i], find(file, List.of(searches[i]))));
        System.out.println(String.format(Locale.ROOT, "similarity of the invented block to lines 4-5: %.3f",
                similarity("        return sum * 2;\n    }", "        return sum;\n    }")));
    }
}
Output
exact copy                -> EXACT at line 3
file has trailing spaces  -> TRAILING_WS at line 2
indentation dropped       -> INDENT at line 3
extra spaces inside       -> WS_RUNS at line 3
typo: retrun              -> SIMILAR at line 3
invented: sum * 2         -> SIMILAR at line 4
closing brace only        -> EXACT matches 2 places: ambiguous
similarity of the invented block to lines 4-5: 0.926

The file is a small class whose total method header ends with three trailing spaces. Each search is a typical model error, and the output shows the first level that found exactly one match:

  • exact copy matches at line 3 with no normalization.
  • file has trailing spaces: the search line has no trailing spaces, the file line has three. Stripping trailing whitespace per line, which Pi's normalizeForFuzzyMatch does along with mapping typographic quotes, dashes, and special spaces to ASCII, finds line 2.
  • indentation dropped: the model wrote the two lines with no indentation. Comparing stripped lines matches at line 3, and the extra check sameOffset confirms that every line is off by the same amount (eight spaces), which is the condition in Aider's match_but_for_leading_whitespace. A replacement applied here must be re-indented by that offset, as Aider does.
  • extra spaces inside: int sum = a + b; has double spaces. Collapsing every run of whitespace to one space, as Crush's normalizeWS does, matches line 3. Crush then re-indents the new text to the file's style and adds a note to the tool result: "old_string did not match exactly. The edit was applied to whitespace-equivalent text in the file and new_string was re-indented to match the file's style. Verify the result."
  • typo: retrun: no whitespace rule helps, but the similarity of the two-line block to lines 3 and 4 is above 0.8, so the similarity level matches.
  • invented: sum * 2: the model invented code that is not in the file. The similarity of return sum * 2; plus } to the real lines 4 and 5 is 0.926, far above the threshold, so the similarity level accepts it and an edit would replace the real return sum;. This is the danger of similarity matching: on short blocks, one invented token barely moves the ratio.
  • closing brace only: } occurs twice, so even the exact level reports an ambiguous match and stops instead of trying looser levels.

The similarity function is two times the longest common subsequence divided by the total length. A longest common subsequence is the longest run of characters that both texts contain in the same order, not necessarily next to each other. For abc and axc it is ac, so the similarity is 2 x 2 / 6 = 0.667. The formula has the same shape as Python's difflib.SequenceMatcher.ratio(), which computes two times the matched characters over the total, with matched characters found by its own block-matching algorithm.

Aider's replace_closest_edit_distance (in editblock_coder.py) uses that ratio with a threshold of 0.8, but in Aider 0.86.2 the function that would call it, replace_most_similar_chunk, has a bare return before the call, so similarity matching is disabled and only exact, leading-whitespace, blank-line, and ... handling run. The invented-code case above shows a reason a harness might leave it disabled.

Example 9: error messages that help the model retry

When an edit fails, the tool result is the only thing the model learns from. An error that says "not found" leads to the same mistake again, because the model does not know which character was wrong. An error that shows the lines the model probably meant gives it the text to copy.

This program builds such a message the way Aider's find_similar_lines does: it finds the window of file lines most similar to the SEARCH lines and shows it with context. A window is a run of consecutive lines with the same length as the SEARCH block. Two failures are shown: a SEARCH block with a typo in one line, and a SEARCH block whose replacement is already in the file.

Java
import java.util.List;

public class RetryErrors {
    // 2 * (number of equal lines in order) / (total lines): difflib's ratio applied to lists of lines.
    static double lineRatio(List<String> a, List<String> b) {
        int[][] dp = new int[a.size() + 1][b.size() + 1];
        for (int i = 1; i <= a.size(); i++)
            for (int j = 1; j <= b.size(); j++)
                dp[i][j] = a.get(i - 1).equals(b.get(j - 1)) ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
        return 2.0 * dp[a.size()][b.size()] / (a.size() + b.size());
    }

    static String failure(String path, String file, String search, String replace) {
        List<String> lines = file.lines().toList(), want = search.lines().toList();
        double best = 0;
        int at = -1;
        for (int i = 0; i + want.size() <= lines.size(); i++) {
            double r = lineRatio(lines.subList(i, i + want.size()), want);
            if (r > best) { best = r; at = i; }
        }
        StringBuilder msg = new StringBuilder("The SEARCH block failed to match lines in " + path + " exactly.\n");
        if (best >= 0.6) {
            int from = Math.max(0, at - 2), to = Math.min(lines.size(), at + want.size() + 2);   // 2 lines of context
            msg.append("Did you mean to match these actual lines from ").append(path)
               .append(" (lines ").append(from + 1).append('-').append(to).append(")?\n");
            lines.subList(from, to).forEach(l -> msg.append("| ").append(l).append('\n'));
        }
        if (!replace.isBlank() && file.contains(replace))
            msg.append("The REPLACE lines are already in ").append(path).append(": the edit may be applied already.\n");
        return msg.append("SEARCH must copy the existing lines exactly, including indentation and comments.").toString();
    }

    public static void main(String[] args) {
        String file = """
                class Calc {
                    // adds two numbers
                    int total(int a, int b) {
                        int sum = a + b;
                        return sum;
                    }
                }
                """;
        String search = "    int total(int a, int b) {\n        int s = a + b;\n        return sum;\n";
        System.out.println(failure("Calc.java", file, search, "    int total(long a, long b) {\n"));
        System.out.println();
        System.out.println(failure("Calc.java", file, "        return total;\n", "        return sum;\n"));
    }
}
Output
The SEARCH block failed to match lines in Calc.java exactly.
Did you mean to match these actual lines from Calc.java (lines 1-7)?
| class Calc {
|     // adds two numbers
|     int total(int a, int b) {
|         int sum = a + b;
|         return sum;
|     }
| }
SEARCH must copy the existing lines exactly, including indentation and comments.

The SEARCH block failed to match lines in Calc.java exactly.
The REPLACE lines are already in Calc.java: the edit may be applied already.
SEARCH must copy the existing lines exactly, including indentation and comments.

The first failure is a SEARCH block whose middle line says int s = a + b; where the file says int sum = a + b;. The program compares each three-line window of the file with the three SEARCH lines, counting whole lines that are equal. The window at lines 3 to 5 shares two of the three lines, a ratio of 2 x 2 / 6 = 0.667, above the 0.6 threshold that Aider's find_similar_lines uses. The message shows that window with two lines of context on each side, which here covers the whole seven-line file, with | in front of each line so that the model can tell the quoted lines from the message text. Aider adds five lines of context, and only when the best window's first or last line differs from the SEARCH block.

The second failure searches for return total;, which matches nothing well, so no lines are suggested. But the REPLACE text, return sum;, is already in the file, and the message says so. This catches a common loop: the model applied an edit, lost track of it (for example after compaction), and tries to apply it again. Aider's message for this case reads "Are you sure you need this SEARCH/REPLACE block? The REPLACE lines are already in" the file.

The last line of both messages repeats the rule that was broken. Aider ends its failure report with "The SEARCH section must exactly match an existing block of lines including all white space, comments, indentation, docstrings, etc". When some blocks of a reply applied and others failed, Aider says which ones succeeded and adds "Don't re-send them. Just reply with fixed versions of the blocks above that failed to match", which keeps the model from applying the successful blocks twice. Crush takes the same approach in diagnoseMismatch (edit_whitespace.go): it first checks whether the old text matches after whitespace normalization, then searches for the best line match, and appends what it found to the "old_string not found" error.

Example 10: multi-file edits applied atomically

A rename touches the declaration and every caller. If the agent writes the first file and then fails on the second, the project no longer compiles, and the model must work out which half was applied. An atomic multi-file edit applies all edits or none (atomic means it cannot be left half done).

The program below does this in two phases. Phase 1 computes every new file content in memory and stops at the first error. Phase 2 writes the files only when phase 1 had no error. The program runs a batch with a typo in the second edit, prints the files (unchanged), then runs the corrected batch and prints the files again.

Java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class AtomicEdits {
    record Edit(String path, String search, String replace) {}

    // Phase 1 computes every new file in memory; phase 2 writes only if phase 1 had no error.
    static String applyAll(Path root, List<Edit> edits) throws IOException {
        Map<Path, String> pending = new LinkedHashMap<>();
        for (Edit e : edits) {
            Path p = root.resolve(e.path());
            String content = pending.containsKey(p) ? pending.get(p) : Files.readString(p);
            int at = content.indexOf(e.search());
            if (at < 0) return "rejected: " + e.path() + ": SEARCH text not found; no file was changed";
            pending.put(p, content.substring(0, at) + e.replace() + content.substring(at + e.search().length()));
        }
        for (var entry : pending.entrySet()) {          // write a sibling temp file, then rename it over the target
            Path tmp = Files.createTempFile(entry.getKey().getParent(), ".edit", ".tmp");
            Files.writeString(tmp, entry.getValue());
            Files.move(tmp, entry.getKey(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
        }
        return "applied " + edits.size() + " edits to " + pending.size() + " files";
    }

    static void show(Path root) throws IOException {
        try (var s = Files.list(root)) {
            for (Path p : s.sorted().toList()) System.out.println("  " + p.getFileName() + ": " + Files.readString(p).strip());
        }
    }

    public static void main(String[] args) throws IOException {
        Path root = Files.createTempDirectory("atomic");
        Files.writeString(root.resolve("Calc.java"), "class Calc { int scale() { return 2; } }\n");
        Files.writeString(root.resolve("Main.java"), "class Main { int run() { return new Calc().scale(); } }\n");
        List<Edit> broken = List.of(new Edit("Calc.java", "scale()", "factor()"),
                new Edit("Main.java", "Calc().scal()", "Calc().factor()"));       // typo in SEARCH
        System.out.println(applyAll(root, broken));
        show(root);
        List<Edit> fixed = List.of(new Edit("Calc.java", "scale()", "factor()"),
                new Edit("Main.java", "Calc().scale()", "Calc().factor()"));
        System.out.println(applyAll(root, fixed));
        show(root);
        try (var s = Files.walk(root)) { s.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); }
    }
}
Output
rejected: Main.java: SEARCH text not found; no file was changed
  Calc.java: class Calc { int scale() { return 2; } }
  Main.java: class Main { int run() { return new Calc().scale(); } }
applied 2 edits to 2 files
  Calc.java: class Calc { int factor() { return 2; } }
  Main.java: class Main { int run() { return new Calc().factor(); } }

The first batch renames scale() to factor() in two files, but the second edit's SEARCH text has a typo (scal()). Phase 1 computes the new Calc.java in memory, then fails on Main.java, and the method returns before phase 2. Both files are printed unchanged: Calc.java still declares scale(), which is what makes the rejection safe to retry.

The second batch is correct. Phase 1 succeeds for both files, and phase 2 writes each one. Two edits to the same file in one batch see each other's result, because phase 1 reads from pending before it reads from disk.

Each file is written to a temporary file in the same directory and then moved over the target with ATOMIC_MOVE. A rename within one file system replaces the target in one step, so a reader, or a crash, sees either the old file or the new file, never half of one. The batch as a whole is not crash-proof: a crash between the two moves leaves one file renamed and one not. Full multi-file atomicity needs a journal or a version-control snapshot; Workshop: loop agent takes a git checkpoint before each step for this reason.

Harnesses make different choices here. Aider applies the blocks that match and reports the ones that fail (apply_edits in editblock_coder.py), so a reply can be partly applied, and its error message lists which blocks went in. Pi's edit tool validates all replacements for one file before writing it, so within one file it is all or nothing.

Example 11: measuring edit success per format with scripted outputs

Format choices should come from measurement, not from how a format looks. This harness takes one intended change (line 12 of a 26-line class: return x * 2; to return x * 3; in value3) and five scripted model behaviors, writes each behavior in each format, applies it, and compares the result with the expected file. A harness here means a small test program that runs the same inputs through every candidate.

Each result has one of three outcomes:

  • ok: the result equals the expected file.
  • FAIL: the applier rejected the edit, so the model gets an error and can retry.
  • WRONG: the applier accepted the edit but the file differs from the expected one, and nobody is told.

WRONG is the dangerous outcome, because the agent believes the edit worked. The program prints a table with one row per format and one column per model behavior.

Java
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.function.UnaryOperator;

public class EditBench {
    record Edit(int hintLine, List<String> before, List<String> after) {}

    static List<String> file() {
        List<String> f = new ArrayList<>(List.of("class Calc {"));
        for (int i = 1; i <= 6; i++) f.addAll(List.of("    int value" + i + "() {", "        int x = " + i + ";", "        return x * 2;", "    }"));
        f.add("}");
        return f;
    }

    static final List<String> FILE = file();
    static final List<String> EXPECTED = file();
    static { EXPECTED.set(11, "        return x * 3;"); }       // line 12: the return of value3

    static int find(List<String> f, List<String> part, UnaryOperator<String> norm) {
        int found = -1, count = 0;
        for (int i = 0; i + part.size() <= f.size(); i++) {
            boolean ok = true;
            for (int j = 0; j < part.size() && ok; j++) ok = norm.apply(f.get(i + j)).equals(norm.apply(part.get(j)));
            if (ok) { found = i; count++; }
        }
        return count == 1 ? found : -1;
    }

    static int indent(String s) { return s.length() - s.stripLeading().length(); }

    static List<String> apply(Edit e, String mode) {        // mode: strict-sr, fuzzy, strict-udiff
        int at = mode.equals("strict-udiff")
                ? (FILE.subList(e.hintLine() - 1, e.hintLine() - 1 + e.before().size()).equals(e.before()) ? e.hintLine() - 1 : -1)
                : find(FILE, e.before(), s -> s);
        String pad = "";
        if (at < 0 && mode.equals("fuzzy")) {               // fallbacks: ignore indentation, then all whitespace
            at = find(FILE, e.before(), String::strip);
            if (at < 0) at = find(FILE, e.before(), s -> s.replaceAll("\\s+", ""));
            if (at >= 0) pad = " ".repeat(Math.max(0, indent(FILE.get(at)) - indent(e.before().get(0))));
        }
        if (at < 0) return null;
        List<String> out = new ArrayList<>(FILE.subList(0, at));
        for (String l : e.after()) out.add(pad + l);
        out.addAll(FILE.subList(at + e.before().size(), FILE.size()));
        return out;
    }

    public static void main(String[] args) {
        Edit good = new Edit(11, List.of("        int x = 3;", "        return x * 2;", "    }"),
                List.of("        int x = 3;", "        return x * 3;", "    }"));
        UnaryOperator<List<String>> dedent = l -> l.stream().map(s -> s.substring(4)).toList();
        UnaryOperator<List<String>> respace = l -> l.stream().map(s -> s.replace("int x = 3;", "int x=3;")).toList();
        String[] behaviors = {"correct", "dedented", "respaced", "elided", "stale-line"};
        List<Edit> edits = List.of(good, new Edit(11, dedent.apply(good.before()), dedent.apply(good.after())),
                new Edit(11, respace.apply(good.before()), respace.apply(good.after())), good, new Edit(14, good.before(), good.after()));
        List<List<String>> wholes = new ArrayList<>();
        for (String b : behaviors) {
            List<String> w = new ArrayList<>(EXPECTED);
            if (b.equals("dedented")) for (int i = 10; i <= 12; i++) w.set(i, w.get(i).substring(4));
            if (b.equals("respaced")) w.set(10, "        int x=3;");
            if (b.equals("elided")) { w.subList(17, 25).clear(); w.add(17, "    // ... value5 and value6 unchanged"); }
            wholes.add(w);
        }
        String fmt = "%-13s %-8s %-9s %-9s %-7s %s";
        List<String> header = new ArrayList<>(List.of("format"));
        header.addAll(List.of(behaviors));
        System.out.println(String.format(Locale.ROOT, fmt, header.toArray()));
        String[][] rows = {{"whole file", ""}, {"s/r strict", "strict-sr"}, {"s/r fuzzy", "fuzzy"},
                {"udiff strict", "strict-udiff"}, {"udiff fuzzy", "fuzzy"}};
        for (String[] row : rows) {
            List<String> cells = new ArrayList<>(List.of(row[0]));
            for (int b = 0; b < behaviors.length; b++) {
                List<String> result = row[1].isEmpty() ? wholes.get(b) : apply(edits.get(b), row[1]);
                cells.add(result == null ? "FAIL" : result.equals(EXPECTED) ? "ok" : "WRONG");
            }
            System.out.println(String.format(Locale.ROOT, fmt, cells.toArray()));
        }
    }
}
Output
format        correct  dedented  respaced  elided  stale-line
whole file    ok       WRONG     WRONG     WRONG   ok
s/r strict    ok       FAIL      FAIL      ok      ok
s/r fuzzy     ok       ok        WRONG     ok      ok
udiff strict  ok       FAIL      FAIL      ok      FAIL
udiff fuzzy   ok       ok        WRONG     ok      ok

The five behaviors are common model errors:

  • correct: the edit is right.
  • dedented: the model dropped one indentation level (four spaces) from every line it wrote.
  • respaced: the model copied int x = 3; as int x=3;.
  • elided: in a whole-file reply, the model replaced two unchanged methods with // ... value5 and value6 unchanged. Edit formats copy only the changed region, so for them this behavior is the same as correct.
  • stale-line: the line number in the diff is 14 instead of 11. Only the diff carries line numbers.

Reading the table row by row:

  • whole file succeeds only when the model reproduces all 26 lines perfectly. Every error becomes WRONG, never FAIL, because a whole-file reply has nothing to check against: the dedented and respaced versions are written as they are, and the elided version deletes two methods from the file. The format never asks the model to retry, which is why its errors survive.
  • s/r strict fails on dedented and respaced, which is the safe outcome: the model gets an error and the file is untouched.
  • s/r fuzzy turns the dedented case into ok, because it matches with indentation ignored and re-indents the new lines by the 4-space offset. The respaced case becomes WRONG: the whitespace-insensitive fallback found the place, and the REPLACE lines, which carry the model's re-spaced int x=3;, were written into the file. Here the difference is formatting, but the same fallback would also accept a changed space inside a string literal.
  • udiff strict fails on stale-line as well, because it insists on the stated position.
  • udiff fuzzy ignores the line number, so stale-line becomes ok; it then behaves like s/r fuzzy.

The counts that matter are ok (useful edits), FAIL (cost one retry), and WRONG (silent damage, found later by a failing build or never). Fuzzy matching moved one case from FAIL to ok and one from FAIL to WRONG. Whether that trade is good depends on how often each behavior happens with the model in use, which is what this harness measures when its scripted outputs are replaced by real replies of the model to the same tasks. Evaluating Models and Agents builds the full task runner; the ok/FAIL/WRONG split is the per-edit part of it.

Example 12: choosing a format

The last example turns the measurements into criteria. The program encodes them as rules over a few properties of the situation:

  • the file size,
  • whether the file is new,
  • whether the model's native tool calls are reliable (native means the server turns the model's output into tool calls itself),
  • how often the model copies lines exactly (the share of ok results with a strict applier in the Example 11 harness),
  • whether the change adds or deletes files.

The rules are checked in order and the first one that applies decides. The program runs six realistic situations through them and prints the chosen format with the reason.

Java
import java.util.List;

public class ChooseFormat {
    // Measured or known facts about one situation.
    record Situation(String name, int fileLines, boolean newFile, boolean reliableToolCalls,
                     double copyAccuracy, boolean addsOrDeletesFiles) {}

    static String choose(Situation s) {
        if (s.newFile()) return "whole file: there is nothing to match against";
        if (s.addsOrDeletesFiles()) return "patch format: one reply can add, delete, and update files";
        if (s.fileLines() <= 60 && s.copyAccuracy() < 0.9)
            return "whole file: short file, and copying exact lines is unreliable";
        if (s.reliableToolCalls()) return "edit tool (edit_file): arguments arrive as checked JSON";
        if (s.copyAccuracy() >= 0.9) return "search/replace blocks in text: small output, no JSON escaping";
        return "search/replace with fuzzy matching and retry errors; measure a larger model";
    }

    public static void main(String[] args) {
        List<Situation> cases = List.of(
                new Situation("create Util.java", 0, true, true, 0.95, false),
                new Situation("rename + delete old class", 300, false, true, 0.95, true),
                new Situation("40-line file, 3B model", 40, false, false, 0.70, false),
                new Situation("800-line file, native tools", 800, false, true, 0.95, false),
                new Situation("800-line file, prompt-based tools", 800, false, false, 0.93, false),
                new Situation("800-line file, weak copying", 800, false, false, 0.60, false));
        for (Situation s : cases) System.out.println(s.name() + " -> " + choose(s));
    }
}
Output
create Util.java -> whole file: there is nothing to match against
rename + delete old class -> patch format: one reply can add, delete, and update files
40-line file, 3B model -> whole file: short file, and copying exact lines is unreliable
800-line file, native tools -> edit tool (edit_file): arguments arrive as checked JSON
800-line file, prompt-based tools -> search/replace blocks in text: small output, no JSON escaping
800-line file, weak copying -> search/replace with fuzzy matching and retry errors; measure a larger model

Each line applies the rules in order:

  • create Util.java: a new file has no old text to match, so the whole content is the edit. Every format handles creation this way: an empty SEARCH section in Aider's blocks, --- /dev/null in a diff, *** Add File: in a patch.
  • rename + delete old class: a change that deletes a file needs a format that can say so. The patch format of Example 6 can; search/replace blocks and the edit tool cannot.
  • 40-line file, 3B model: when the model copies exact lines poorly (70 percent here) and the file is short, the whole-file cost from Example 1 is small (a 54-line file was about 257 tokens) and matching failures disappear. The elision risk stays, so the agent checks that the new file is not much shorter than the old one.
  • 800-line file, native tools: with reliable tool calls the edit tool wins: its arguments are JSON that the server can constrain and the agent can validate, and the unique-match rule turns ambiguity into an error.
  • 800-line file, prompt-based tools: a model without native tool calling writes edits in its reply text. Search/replace blocks carry the code without JSON escaping, which removes one class of errors from Example 7.
  • 800-line file, weak copying: no format is cheap and safe. Fuzzy matching with good retry errors (Examples 8 and 9) recovers most failures; the remaining step is to measure a larger model or a less aggressive quantization with the same harness. (Quantization means storing the model's weights with fewer bits; a more aggressive setting saves memory but lowers accuracy.)

The same criteria as a table:

Criterion Favors
File is new or short (under about 60 lines) Whole file
Model copies exact lines reliably Search/replace, edit tool
Native tool calls are reliable Edit tool
Change adds, deletes, or renames files File-level patch
Line numbers in the model's view may be stale Search/replace, or udiff applied by content
Output tokens are slow (CPU decode) Any format except whole file for large files
Code with many quotes and backslashes Formats in plain text, not JSON arguments

Aider applies a per-model version of this table. aider/resources/model-settings.yml sets edit_format: diff (search/replace) for most models it knows, udiff for the GPT-4 Turbo models, and whole for a few, and aider/models.py falls back to whole for unknown models, the safe choice when nothing has been measured.

Interview points

  • An edit format is a contract between the model's output and an applier. The applier decides whether the text names exactly one change it can make.
  • Whole-file output costs output tokens in proportion to the file (995 tokens for a 204-line file against about 50 for a search/replace block) and fails silently when the model elides code.
  • Search/replace blocks copy only the changed lines. Requiring a unique match turns ambiguous edits into errors, while replacing the first match can change the wrong place and break later edits.
  • Unified diff headers need line counts that a model must plan ahead. Aider asks for @@ ... @@ without numbers and applies hunks by content.
  • A file-level patch format (V4A, *** Begin Patch) covers add, delete, and update in one reply, and sentinel lines detect truncated replies.
  • The edit tool carries code in JSON. Escaping adds characters and failure points, and grammar constraints guarantee syntax but not intent.
  • Fuzzy matching levels go from trailing whitespace to indentation to whitespace runs to similarity. Similarity on short blocks accepts invented code, and Aider 0.86.2 leaves its similarity fallback disabled.
  • Retry errors should show the closest real lines, note when the replacement is already present, and say which blocks applied so they are not resent.
  • Multi-file edits should be validated in memory before writing. A temp file plus atomic rename protects each file, and a checkpoint protects the batch.
  • Measure each format with ok, FAIL, and WRONG outcomes. WRONG (silently accepted damage) is the outcome to minimize.

Summary

  • Whole-file output for a 204-line Java file was 995 Qwen2 tokens. The search/replace block and the diff for a one-line change were 54 and 45 tokens.
  • Aider's search/replace markers are <<<<<<< SEARCH, =======, >>>>>>> REPLACE, matched with five to nine marker characters. The file path goes on the line before the block, and a block without one reuses the previous path.
  • Aider's search/replace blocks replace the first match. Pi's and Crush's edit tools require a unique match unless replace_all is set (Crush).
  • A unified diff hunk is @@ -oldStart,oldCount +newStart,newCount @@ followed by space, -, and + lines. Aider's udiff omits the numbers and applies hunks as search and replace.
  • Applying a hunk by searching outward from the stated line tolerates stale line numbers. Strict application at the stated line does not.
  • Aider's patch format is V4A: *** Begin Patch, *** Add File:, *** Delete File:, *** Update File:, *** Move to:, @@ hunks without line numbers, *** End Patch.
  • A three-line code replacement grew from 50 to 56 characters as a JSON string.
  • Pi's fuzzy match normalizes with NFKC, strips trailing whitespace, and maps typographic quotes, dashes, and spaces to ASCII. Crush collapses whitespace runs and re-indents. Aider matches with a uniform leading-whitespace offset.
  • Aider's similarity fallback (threshold 0.8) is unreachable in 0.86.2 because of an early return. Its find_similar_lines suggestion uses a threshold of 0.6.
  • In the measurement harness, whole-file errors were always WRONG, strict appliers turned errors into FAIL, and fuzzy appliers turned some into ok and some into WRONG.
  • Aider's default edit format for unknown models is whole. Most known models use diff, and the GPT-4 Turbo models use udiff.