Lesson 56 of 73
File Tools
A language model cannot touch your files by itself. It only produces text. A coding agent changes a repository because the program around the model offers a small set of file tools, and the model asks for them by name: read a file, write a file, edit part of a file, list a directory, find files by name pattern, and search inside files. The program runs the tool and sends the result back to the model as text.
These tools look simple, but a model is an unreliable user. It may send a path that leaves the project, edit a file it has never opened, copy line numbers into the text it wants to replace, or overwrite a change you made in your editor a minute ago. Each tool in this lesson therefore does two jobs. It does the file operation, and it checks the request and gives an error message the model can act on. A good error message matters as much as the working case, because the model reads it and tries again.
This lesson builds each tool in Java, one example at a time, on temporary directories. The last example, Project 22, registers all six tools in the agent from Project 21 (The Agent Loop and a Tool Framework) and runs a scripted session that finds, reads, edits, and creates files. After the lesson you can implement file tools whose results and error messages a model can use to correct itself.
Each example creates its own temporary directory, prints paths relative to it so the output is the same on every run, and deletes the directory at the end. The file classes used here (Files, Path, PathMatcher) are covered in Blocking I/O and Files. If a name such as Path.resolve looks unfamiliar, that lesson explains it.
| Tool or idea | What it is for |
|---|---|
Workspace root, normalize, toRealPath |
Keep every path inside the project |
read_file |
Show numbered lines, a window of lines, with size and binary checks |
write_file |
Create or replace a whole file, without ever leaving half a file |
edit_file |
Replace an exact piece of text once, or every occurrence |
| Closest-line hints | Error messages the model can fix its next edit from |
| Line endings, BOM, encodings | Edit a file without changing its format |
| Read tracking with hashes | Require a read before an edit, and detect files changed by someone else |
list_files, glob, grep |
Find files and find text |
| Unified diff | Show the user what changed |
| Project 22 | All of the tools inside the agent |
Example 1: the workspace root and path confinement
The first problem is containment. The model chooses the path, so it could ask for ../../home/user/.ssh/id_rsa or /etc/passwd. The agent must make sure every path stays inside the project directory it was started in. That directory is the workspace root.
The idea is to take whatever text the model sent, resolve it against the root, clean it up, and check where it really points. There are three ways to leave the root: with .. (go up one directory), with an absolute path (one that starts at the top of the file system), and through a symbolic link. A symbolic link is a small file that points to another path. When a program opens the link, the operating system follows it to the target, so a link inside the project can lead anywhere.
The program below creates a workspace called demo, a sibling directory demo-secrets next to it, a link inside the workspace that points at the secrets, and a link that points at a file that does not exist. It then asks the Workspace class to resolve nine paths and prints whether each is accepted. Read the output line by line and compare each result with the path on the left.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.Comparator;
public class PathConfinement {
static final class Workspace {
final Path root;
Workspace(Path root) throws IOException { this.root = root.toRealPath(); }
Path resolve(String userPath) throws IOException {
if (userPath == null || userPath.isBlank()) throw new IllegalArgumentException("empty path");
Path p = root.resolve(userPath).normalize(); // an absolute userPath replaces root here
if (!p.startsWith(root)) throw new SecurityException("outside the workspace: " + userPath);
Path existing = p; // the deepest part that exists decides
while (!Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) existing = existing.getParent();
Path real;
try {
real = existing.toRealPath(); // follows every symbolic link
} catch (NoSuchFileException e) {
throw new SecurityException("dangling symbolic link: " + userPath);
}
if (!real.startsWith(root)) throw new SecurityException("leaves the workspace through a symbolic link: " + userPath);
return p;
}
}
static void deleteTree(Path p) throws IOException {
try (var s = Files.walk(p)) { for (Path q : s.sorted(Comparator.reverseOrder()).toList()) Files.delete(q); }
}
public static void main(String[] args) throws IOException {
Path base = Files.createTempDirectory("confine");
Path rootDir = Files.createDirectories(base.resolve("demo"));
Files.createDirectories(base.resolve("demo-secrets"));
Files.writeString(base.resolve("demo-secrets/key.txt"), "secret");
Files.createDirectories(rootDir.resolve("src"));
Files.createSymbolicLink(rootDir.resolve("link"), base.resolve("demo-secrets"));
Files.createSymbolicLink(rootDir.resolve("dangling"), base.resolve("nowhere.txt"));
Workspace ws = new Workspace(rootDir);
String[] paths = {"src/Main.java", "./src/../README.md", "new/dir/App.java", "../demo-secrets/key.txt",
"/etc/passwd", "src/../../demo/src", "link/key.txt", "dangling", ""};
for (String p : paths) {
String result;
try { result = "ok " + ws.root.relativize(ws.resolve(p)); }
catch (RuntimeException e) { result = "rejected " + e.getMessage(); }
System.out.printf("%-26s %s%n", "\"" + p + "\"", result);
}
String sibling = base.resolve("demo-secrets/key.txt").toString();
System.out.println("string prefix check accepts the sibling: " + sibling.startsWith(ws.root.toString()));
System.out.println("Path.startsWith accepts the sibling: " + Path.of(sibling).startsWith(ws.root));
deleteTree(base);
}
}"src/Main.java" ok src/Main.java
"./src/../README.md" ok README.md
"new/dir/App.java" ok new/dir/App.java
"../demo-secrets/key.txt" rejected outside the workspace: ../demo-secrets/key.txt
"/etc/passwd" rejected outside the workspace: /etc/passwd
"src/../../demo/src" ok src
"link/key.txt" rejected leaves the workspace through a symbolic link: link/key.txt
"dangling" rejected dangling symbolic link: dangling
"" rejected empty path
string prefix check accepts the sibling: true
Path.startsWith accepts the sibling: falseThe program creates demo as the workspace and a sibling directory demo-secrets next to it, then resolves nine paths. Here is what happens to each group of paths.
src/Main.java is inside the root and is accepted even though the file does not exist yet. This is deliberate: a write tool must be able to create new files. ./src/../README.md is normalized to README.md. The method normalize() removes . and resolves .. against the name before it, and it does this by working on the text of the path only, without touching the disk. new/dir/App.java is accepted because the deepest part that exists, the root itself, is inside the workspace.
../demo-secrets/key.txt normalizes to a path outside the root and is rejected. /etc/passwd is rejected for the same reason. When the argument to root.resolve is an absolute path, Java returns it unchanged, so the result does not start with the root. src/../../demo/src goes up out of the root and back in through the root's own name. After normalization it is src inside the root, so it is accepted. The point is that the check runs on the normalized path, not on the text the model wrote.
"src/../../demo/src"
root = /tmp/x/demo
root + text = /tmp/x/demo/src/../../demo/src
normalize = /tmp/x/demo/src (src/.. cancels, then .. leaves demo, then demo comes back)
starts with root? yes -> acceptedlink/key.txt is the case that normalize cannot see. link is a symbolic link inside the workspace that points to demo-secrets, so the normalized path starts with the root, but the file that gets opened is outside it. The method therefore finds the deepest existing part of the path (link/key.txt itself here) and calls toRealPath(), which follows every link and returns the real location. That location is outside the root, so the path is rejected.
dangling is a link to a file that does not exist. toRealPath() throws NoSuchFileException for it, and the method rejects it. The reason is that writing to a dangling link creates the target file wherever the link points, which could be outside the workspace. The empty path is rejected before anything else.
The last two lines show why the check uses Path.startsWith and not String.startsWith. The sibling path /tmp/.../demo-secrets/key.txt starts with the text /tmp/.../demo, so a string check accepts it. Path.startsWith compares whole name elements, and demo-secrets is not demo, so it rejects the sibling. This is a common real-world bug, and the fix is to compare paths as paths.
The root itself is stored as a real path (toRealPath() in the constructor), so that comparisons work when the root is reached through a link. On macOS, for example, /tmp is a link to /private/tmp. A check like this can still be raced: another process can replace a directory with a link between the check and the write. Operating-system sandboxes close that gap, and Permissions, Sandboxing, and Prompt Injection covers them.
Example 2: read with numbered lines, offset, and limit
The next problem is how a model reads a file in a form it can refer to later. If the tool returned the raw text, the model would have to count lines to say "change line 5". Models count badly, so the read tool prefixes each line with its number. It also returns only a window of the file, selected by two arguments: offset is the first line to show, starting at 1, and limit is how many lines to show. A footer tells the model when there is more.
The program writes a 12-line file and reads it four times: lines 1 to 4, lines 10 to 13 (the file ends at 12), then two bad offsets. Look at the footer in the first result and at the wording of the two errors.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class ReadTool {
static String read(Path file, String display, int offset, int limit) throws IOException {
List<String> lines = Files.readAllLines(file);
if (offset < 1) throw new IllegalArgumentException("offset starts at 1, got " + offset);
if (lines.isEmpty()) return "(" + display + " is empty)";
if (offset > lines.size())
throw new IllegalArgumentException("offset " + offset + " is past the end of " + display + " (" + lines.size() + " lines)");
int end = Math.min(lines.size(), offset - 1 + limit);
StringBuilder sb = new StringBuilder();
for (int i = offset - 1; i < end; i++) sb.append(String.format("%5d| %s\n", i + 1, lines.get(i)));
if (end < lines.size())
sb.append("[lines ").append(offset).append('-').append(end).append(" of ").append(lines.size())
.append(" in ").append(display).append("; continue with offset=").append(end + 1).append("]\n");
return sb.toString();
}
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("read");
Path file = dir.resolve("Main.java");
Files.writeString(file, IntStream.rangeClosed(1, 12).mapToObj(i -> i == 1 ? "public class Main {"
: i == 12 ? "}" : " // line " + i).collect(Collectors.joining("\n", "", "\n")));
System.out.print(read(file, "Main.java", 1, 4));
System.out.println("---");
System.out.print(read(file, "Main.java", 10, 4));
System.out.println("---");
for (int offset : new int[] {0, 40}) {
try { read(file, "Main.java", offset, 4); }
catch (IllegalArgumentException e) { System.out.println("error: " + e.getMessage()); }
}
Files.delete(file);
Files.delete(dir);
}
} 1| public class Main {
2| // line 2
3| // line 3
4| // line 4
[lines 1-4 of 12 in Main.java; continue with offset=5]
---
10| // line 10
11| // line 11
12| }
---
error: offset starts at 1, got 0
error: offset 40 is past the end of Main.java (12 lines)The first read shows lines 1 to 4 and a footer: [lines 1-4 of 12 in Main.java; continue with offset=5]. The footer tells the model three things it cannot otherwise know: that the file has more lines, how many lines it has, and the exact argument for the next call. Without it, a model that sees four lines can assume it has read the whole file.
The second read starts at line 10 and asks for 4 lines, but the file ends at line 12. The tool shows the three lines that exist and no footer, because there is nothing more to read.
The two errors handle invalid offsets. Offset 0 is rejected because numbering starts at 1. Offset 40 is past the end, and the message includes the file's length (12 lines), so the model can correct the call in one step instead of guessing again.
The number prefix ( 1|) belongs to the tool's output, not to the file. A model sometimes copies it into an edit. The edit tool then reports that the text was not found, so the tool description for edits says to copy text without line numbers. The format uses a fixed width of five digits and a | separator so that the file's own indentation stays visible after the separator.
Example 3: large files and binary detection
The problem now is what the read tool does with files that are too large, have very long lines, or are not text at all. Reading a 2 MB log into the model's context wastes the space the model needs for its work. Showing the bytes of a PNG image is worse, because they are meaningless as text and can confuse the model.
The program therefore does four things. It streams lines instead of loading the whole file into memory. It cuts any line longer than 120 characters and says how long the line really was. It refuses a file that looks binary. And it refuses a file that is not valid UTF-8 (UTF-8 is the usual way to store text as bytes) instead of showing damaged text.
The program creates four files: a log with 50,000 lines, a one-line minified JavaScript file, a fake PNG, and a text file saved in the older ISO-8859-1 encoding. It asks for the first two lines of each. Compare the four results.
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.Locale;
public class SafeRead {
static final int MAX_LINE = 120;
/** Rejects files that are not text, then streams only the requested lines. */
static String read(Path file, String display, int offset, int limit) throws IOException {
byte[] head;
try (InputStream in = Files.newInputStream(file)) { head = in.readNBytes(8000); }
for (byte b : head) if (b == 0) return "error: " + display + " looks binary (NUL byte in the first 8000 bytes); not shown\n";
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
StringBuilder sb = new StringBuilder();
long total = 0;
try (BufferedReader r = new BufferedReader(new java.io.InputStreamReader(Files.newInputStream(file), decoder))) {
for (String line; (line = r.readLine()) != null; ) {
total++;
if (total < offset || total >= offset + limit) continue;
if (line.length() > MAX_LINE) line = line.substring(0, MAX_LINE) + "... [" + line.length() + " characters]";
sb.append(String.format("%5d| %s\n", total, line));
}
} catch (CharacterCodingException e) {
return "error: " + display + " is not valid UTF-8 (" + e.getClass().getSimpleName() + ")\n";
}
return sb + String.format(Locale.ROOT, "[%s: %,d bytes, %,d line%s; shown %d-%d]\n",
display, Files.size(file), total, total == 1 ? "" : "s", offset, Math.min(total, offset + limit - 1));
}
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("safe");
StringBuilder log = new StringBuilder();
for (int i = 1; i <= 50_000; i++) log.append("2026-10-03 INFO request ").append(i).append(" served\n");
Files.writeString(dir.resolve("server.log"), log);
Files.writeString(dir.resolve("bundle.min.js"), "var a=1;" + "x=x+1;".repeat(2000) + "\n");
Files.write(dir.resolve("logo.png"), new byte[] {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 13});
Files.write(dir.resolve("notes.txt"), "café\n".getBytes(StandardCharsets.ISO_8859_1));
for (String name : new String[] {"server.log", "bundle.min.js", "logo.png", "notes.txt"})
System.out.print(read(dir.resolve(name), name, 1, 2));
try (var s = Files.walk(dir)) { for (Path p : s.sorted(Comparator.reverseOrder()).toList()) Files.delete(p); }
}
} 1| 2026-10-03 INFO request 1 served
2| 2026-10-03 INFO request 2 served
[server.log: 1,838,894 bytes, 50,000 lines; shown 1-2]
1| var a=1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+1;x=x+... [12008 characters]
[bundle.min.js: 12,009 bytes, 1 line; shown 1-1]
error: logo.png looks binary (NUL byte in the first 8000 bytes); not shown
error: notes.txt is not valid UTF-8 (MalformedInputException)server.log is 1,838,894 bytes in 50,000 lines. The tool read it line by line with a BufferedReader, kept only lines 1 and 2, and counted the rest. The footer reports the size and the line count, so the model can decide to read another range or to search the file with grep instead.
bundle.min.js is one line of 12,008 characters, which is the shape of minified JavaScript or generated code. The tool kept the first 120 characters and appended ... [12008 characters]. Without a per-line limit, a window of "2 lines" could still be megabytes. The footer says 1 line, so the model understands that the file is one huge line and not a short file.
logo.png holds the 8-byte PNG signature followed by the start of the first chunk's length field, which contains NUL bytes (bytes with the value 0). The tool looks for a NUL byte in the first 8,000 bytes and calls the file binary if it finds one. Git uses the same rule. In a test repository at this course's setup, git reported a file with a NUL at byte 7,999 as binary in git show --numstat and a file with its first NUL at byte 8,000 as text. Text files almost never contain NUL bytes, so the check is cheap and has few false positives.
notes.txt contains café encoded in ISO-8859-1, where é is the single byte E9. That byte is not valid UTF-8 in this position. The decoder was created with CodingErrorAction.REPORT, so it threw MalformedInputException instead of silently replacing the byte with the replacement character U+FFFD. A silent replacement would show the model a slightly wrong file, and a later write would save the wrong character over the original. Reporting the problem keeps the file intact. Example 7 shows the same rule in the edit tool.
Example 4: write with parent directories and overwrite rules
The next problem is when the write tool may create or replace a file, and how it writes so that a crash cannot leave half a file. Three rules answer this. The tool creates missing parent directories. It refuses to overwrite a file the model has not read. And it writes the new content to a temporary file first, then moves that file over the target in one step.
The program sets up a README.md and an executable script run.sh, then makes a series of write attempts. A set called readFiles stands in for the read tool, which would add a path to it each time the model reads a file. Watch which attempts succeed, which are refused, and what the file permissions are at the end.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Set;
public class WriteTool {
static final Set<Path> readFiles = new HashSet<>(); // filled by the read tool
static String write(Path root, String path, String content) throws IOException {
Path file = root.resolve(path).normalize();
if (Files.isDirectory(file)) throw new IllegalArgumentException(path + " is a directory");
boolean exists = Files.exists(file);
if (exists && !readFiles.contains(file))
throw new IllegalStateException(path + " exists; read it before overwriting it");
if (exists && Files.readString(file).equals(content)) return "unchanged " + path;
long oldLines = exists ? Files.readString(file).lines().count() : 0;
Files.createDirectories(file.getParent());
Path tmp = Files.createTempFile(file.getParent(), ".loop-", ".tmp"); // same directory, same file system
Files.writeString(tmp, content);
Files.setPosixFilePermissions(tmp, exists ? Files.getPosixFilePermissions(file) // keep the old mode,
: PosixFilePermissions.fromString("rw-r--r--")); // not the temp file's rw-------
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
long newLines = content.lines().count();
return (exists ? "overwrote " + path + " (" + oldLines + " -> " + newLines + " lines)" : "created " + path + " (" + newLines + " lines)");
}
static void attempt(Path root, String path, String content) {
try { System.out.println(write(root, path, content)); }
catch (Exception e) { System.out.println("error: " + e.getMessage()); }
}
public static void main(String[] args) throws IOException {
Path root = Files.createTempDirectory("write");
Files.writeString(root.resolve("README.md"), "# Demo\n");
Files.writeString(root.resolve("run.sh"), "#!/bin/sh\necho hi\n");
Files.setPosixFilePermissions(root.resolve("run.sh"), PosixFilePermissions.fromString("rwxr-xr-x"));
attempt(root, "src/main/java/App.java", "class App {\n public static void main(String[] a) {}\n}\n");
attempt(root, "README.md", "# Demo\n\nA demo project.\n");
readFiles.add(root.resolve("README.md"));
attempt(root, "README.md", "# Demo\n\nA demo project.\n");
attempt(root, "README.md", "# Demo\n\nA demo project.\n");
attempt(root, "src", "oops");
readFiles.add(root.resolve("run.sh"));
attempt(root, "run.sh", "#!/bin/sh\necho hello\n");
for (String f : new String[] {"run.sh", "src/main/java/App.java"})
System.out.println(f + " permissions: " + PosixFilePermissions.toString(Files.getPosixFilePermissions(root.resolve(f))));
try (var s = Files.list(root.resolve("src/main/java"))) { System.out.println("files in src/main/java: " + s.map(p -> p.getFileName().toString()).toList()); }
try (var s = Files.walk(root)) { for (Path p : s.sorted(Comparator.reverseOrder()).toList()) Files.delete(p); }
}
}created src/main/java/App.java (3 lines)
error: README.md exists; read it before overwriting it
overwrote README.md (1 -> 3 lines)
unchanged README.md
error: src is a directory
overwrote run.sh (2 -> 2 lines)
run.sh permissions: rwxr-xr-x
src/main/java/App.java permissions: rw-r--r--
files in src/main/java: [App.java]The first write created src/main/java/App.java. The directories src/main/java did not exist, and Files.createDirectories created them. A write tool that fails on missing directories costs the model an extra step for every new package.
The second write tried to overwrite README.md and was refused, because the file exists and the model has not read it. Replacing a file the model has not seen discards content it does not know about, such as a license header or a section a person wrote. After the program marks the file as read, the same write succeeds and reports 1 -> 3 lines. The fourth call writes identical content and reports unchanged. The tool skips the disk write in that case, so file timestamps and build tools are not disturbed.
Writing to src fails because it is a directory.
The write itself goes to a temporary file in the same directory, named .loop-<random>.tmp, followed by Files.move with ATOMIC_MOVE and REPLACE_EXISTING. An atomic operation happens completely or not at all. On the same file system, the move is a rename, so other programs see either the old file or the new one, never a partial one. If the program crashed halfway through writing, only the temporary file would be damaged and the original would still be intact. The temporary file must be in the target's directory, because a rename across file systems is not atomic, and ATOMIC_MOVE then fails with AtomicMoveNotSupportedException.
write README.md safely
1. create .loop-123.tmp (same directory as README.md)
2. write new content into .loop-123.tmp
3. rename .loop-123.tmp -> README.md (one step: old file or new file, never half)The permission lines show a detail of this approach. Files.createTempFile creates files with mode rw-------, which means only the owner can read and write. A rename keeps the mode of the file that was moved. Without the setPosixFilePermissions call, every file the agent wrote would become private, and run.sh would lose its execute bit. The tool therefore copies the old file's mode (rwxr-xr-x for run.sh) and gives new files rw-r--r--. The listing of src/main/java shows only App.java, so no temporary file was left behind.
Example 5: edit by exact replacement
Rewriting a whole file to change one line is slow, costs many tokens, and risks changing lines by accident. The better design lets the model say "find this text and replace it with that text". The edit tool takes oldString, an exact piece of the file, and newString, its replacement. It replaces exactly one occurrence unless the argument replaceAll is true.
Exact matching makes an edit unambiguous and checkable. The tool knows whether the text is there and how often, and it can refuse when the request is a guess. The program below runs six edits on a small Counter class. Each result shows a different rule: a normal edit, a missing string, an ambiguous string, a string made unique with more context, replaceAll, and an edit that changes nothing.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public class EditTool {
static String edit(Path file, String oldString, String newString, boolean replaceAll) throws IOException {
if (oldString.isEmpty()) throw new IllegalArgumentException("oldString is empty; use write_file to create a file");
if (oldString.equals(newString)) throw new IllegalArgumentException("oldString and newString are identical");
String content = Files.readString(file);
List<Integer> lines = new ArrayList<>(); // line number of every match
for (int i = content.indexOf(oldString); i >= 0; i = content.indexOf(oldString, i + oldString.length()))
lines.add((int) content.substring(0, i).chars().filter(c -> c == '\n').count() + 1);
if (lines.isEmpty())
throw new IllegalArgumentException("oldString not found in " + file.getFileName());
if (lines.size() > 1 && !replaceAll)
throw new IllegalArgumentException("oldString matches " + lines.size() + " times (lines " + lines
+ "); include more surrounding lines to make it unique, or set replaceAll to true");
String updated = replaceAll ? content.replace(oldString, newString)
: content.substring(0, content.indexOf(oldString)) + newString
+ content.substring(content.indexOf(oldString) + oldString.length());
Files.writeString(file, updated);
return "replaced " + lines.size() + (lines.size() == 1 ? " occurrence" : " occurrences") + " at lines " + lines;
}
static void attempt(Path file, String oldString, String newString, boolean replaceAll) {
try { System.out.println(edit(file, oldString, newString, replaceAll)); }
catch (Exception e) { System.out.println("error: " + e.getMessage()); }
}
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("edit");
Path file = dir.resolve("Counter.java");
Files.writeString(file, """
class Counter {
int count;
void inc() { count++; }
void reset() { count = 0; }
int get() { return count; }
}
""");
attempt(file, "void inc() { count++; }", "void inc() { count += 1; }", false);
attempt(file, "void dec()", "void decrement()", false);
attempt(file, "count", "total", false);
attempt(file, " int count;\n void inc()", " int count;\n\n void inc()", false);
attempt(file, "count", "total", true);
attempt(file, "total", "total", false);
System.out.print(Files.readString(file));
Files.delete(file);
Files.delete(dir);
}
}replaced 1 occurrence at lines [3]
error: oldString not found in Counter.java
error: oldString matches 4 times (lines [2, 3, 4, 5]); include more surrounding lines to make it unique, or set replaceAll to true
replaced 1 occurrence at lines [2]
replaced 4 occurrences at lines [2, 4, 5, 6]
error: oldString and newString are identical
class Counter {
int total;
void inc() { total += 1; }
void reset() { total = 0; }
int get() { return total; }
}The first edit found void inc() { count++; } once, at line 3, and replaced it.
The second edit looked for void dec(), which does not exist, and failed with oldString not found in Counter.java. Example 6 makes this message more useful.
The third edit asked to replace count without replaceAll. The word occurs four times (on lines 2, 3, 4, and 5 at that point), so the tool refused and listed the lines. Replacing the first occurrence would have been a guess. The message gives the model two ways forward: include more surrounding text so the match is unique, or set replaceAll.
The fourth edit used two lines of context, int count;\n void inc(), which occur once, and inserted a blank line between them. Multi-line oldString values are how a model makes a match unique.
The fifth edit set replaceAll and replaced all four occurrences of count. The reported lines (2, 4, 5, 6) differ from the third attempt because the blank line inserted by the fourth edit moved lines 3 to 5 down by one. The final file shows every count renamed to total.
The last attempt replaced total with total, which changes nothing, and was rejected. A model that sends such an edit usually believes it changed something, and the error tells it otherwise. An empty oldString is also rejected, because the empty string matches at every position. Creating files is the write tool's job.
Example 6: whitespace mismatches and errors that show the closest lines
An exact match is strict, so a model's oldString often almost matches. The most common cause is whitespace. The model writes a tab where the file has spaces, or a different indentation depth, and a tab and four spaces look the same on screen but are different characters. Another cause is a small change in the text, such as a typo.
A plain "not found" gives the model nothing to fix. A better tool finds the part of the file most similar to what the model sent, and shows the two next to each other. The program does this in three steps. It normalizes whitespace on both sides (squash), slides a window the size of oldString over the file, and scores each window by similarity, a number from 0 to 1 where 1 means identical. It reports the best window if the score is at least 0.6.
The program runs three failing oldString values against a five-line file: one indented with tabs instead of spaces, one with a typo, and one that does not resemble anything.
import java.util.List;
import java.util.Locale;
public class ClosestLines {
static double similarity(String a, String b) { // 1 - edit distance / longer length
int[] prev = new int[b.length() + 1], cur = new int[b.length() + 1];
for (int j = 0; j <= b.length(); j++) prev[j] = j;
for (int i = 1; i <= a.length(); i++) {
cur[0] = i;
for (int j = 1; j <= b.length(); j++)
cur[j] = Math.min(Math.min(cur[j - 1] + 1, prev[j] + 1), prev[j - 1] + (a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1));
int[] t = prev; prev = cur; cur = t;
}
return 1.0 - (double) prev[b.length()] / Math.max(1, Math.max(a.length(), b.length()));
}
static String visible(String s) { return s.replace("\t", "\\t"); } // a tab shows as \t, unlike spaces
static String squash(String s) { return s.strip().replaceAll("\\s+", " "); }
static String notFound(List<String> fileLines, String oldString) {
List<String> want = oldString.lines().toList();
int bestAt = -1;
double best = 0;
for (int start = 0; start + want.size() <= fileLines.size(); start++) {
double score = 0;
for (int k = 0; k < want.size(); k++) score += similarity(squash(fileLines.get(start + k)), squash(want.get(k)));
score /= want.size();
if (score > best) { best = score; bestAt = start; }
}
if (best < 0.6) return "oldString not found, and no similar lines exist; read the file again\n";
boolean whitespaceOnly = best == 1.0;
String where = want.size() == 1 ? "line " + (bestAt + 1) : "lines " + (bestAt + 1) + "-" + (bestAt + want.size());
StringBuilder sb = new StringBuilder("oldString not found. Closest match at " + where
+ (whitespaceOnly ? " (differs only in whitespace)" : String.format(Locale.ROOT, " (%.0f%% similar)", best * 100)) + ":\n");
for (int k = 0; k < want.size(); k++) sb.append(String.format("%5d| %s\n", bestAt + k + 1, visible(fileLines.get(bestAt + k))));
sb.append("your oldString:\n");
for (String w : want) sb.append(" | ").append(visible(w)).append('\n');
return sb.toString();
}
public static void main(String[] args) {
List<String> file = List.of("public class Hello {", " public static void main(String[] args) {",
" System.out.println(\"hi\");", " }", "}");
System.out.print(notFound(file, "\tpublic static void main(String[] args) {\n\t\tSystem.out.println(\"hi\");"));
System.out.print(notFound(file, "System.out.prinltn(\"hi\");"));
System.out.print(notFound(file, "import java.util.List;"));
}
}oldString not found. Closest match at lines 2-3 (differs only in whitespace):
2| public static void main(String[] args) {
3| System.out.println("hi");
your oldString:
| \tpublic static void main(String[] args) {
| \t\tSystem.out.println("hi");
oldString not found. Closest match at line 3 (92% similar):
3| System.out.println("hi");
your oldString:
| System.out.prinltn("hi");
oldString not found, and no similar lines exist; read the file againThe first case sends a two-line oldString indented with tabs, while the file uses 4 and 8 spaces. The tool compared every window of two lines in the file with the two lines of oldString, after squash removed leading and trailing whitespace and collapsed runs of whitespace into one space. Lines 2 and 3 matched exactly after squashing, so the message says differs only in whitespace. It shows the file's lines with their real indentation and the model's lines with each tab written as \t, so the difference is visible in the text the model reads.
The second case has a typo, prinltn. No line matches after squashing, so the tool used similarity: one minus the edit distance divided by the longer length. The edit distance (also called Levenshtein distance) is the number of single-character insertions, deletions, and substitutions needed to turn one string into the other. System.out.prinltn("hi"); and System.out.println("hi"); are 25 characters long and differ by two substitutions (the swapped l and t).
similarity = 1 - edit distance / longer length
= 1 - 2 / 25
= 1 - 0.08
= 0.92 -> printed as "92% similar"The message points at line 3, and the model can see the typo by comparing the two lines.
The third case, import java.util.List;, resembles nothing in the file. Its best score was under the 0.6 threshold, so the tool says that no similar lines exist and suggests reading the file again. A model in this state usually works from an outdated or imagined version of the file, and a fresh read is the right fix.
The tool reports the closest match but does not apply it. Applying a fuzzy match changes code the model did not quote exactly, which can hit the wrong place. Edit Formats compares tools that do apply fuzzy matches and the safeguards they use.
Example 7: line endings and encodings
A file can contain bytes that the model never sees. Windows programs often end each line with two characters, carriage return and line feed (\r\n, called CRLF), while Linux and macOS use one (\n, LF). Some editors also put a byte order mark (BOM) at the start of a UTF-8 file. The BOM is the three bytes EF BB BF, an invisible marker. Other files use a completely different encoding.
The model writes \n and no BOM. If the tool does not handle this, two things go wrong. An oldString that spans two lines never matches, because the file has \r\n between them. And if the edit succeeds, the tool might write LF throughout and silently change every line ending of the file, which version control then shows as a change on every line.
The fix has three parts. When loading, remove the BOM and convert CRLF to LF, and remember that both were there. Match and edit on the normalized text. When saving, put the BOM and the CRLF endings back. The program below does this on a file with both, prints the raw bytes after an edit, and also tries an ISO-8859-1 file.
import java.io.IOException;
import java.nio.charset.MalformedInputException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HexFormat;
public class LineEndings {
record Decoded(String text, boolean bom, boolean crlf) {}
static final String BOM = "";
static Decoded load(Path file) throws IOException {
String raw = Files.readString(file); // strict UTF-8: throws on invalid bytes
boolean bom = raw.startsWith(BOM);
if (bom) raw = raw.substring(1);
boolean crlf = raw.contains("\r\n");
return new Decoded(crlf ? raw.replace("\r\n", "\n") : raw, bom, crlf);
}
static void store(Path file, Decoded original, String text) throws IOException {
String out = original.crlf() ? text.replace("\n", "\r\n") : text;
Files.writeString(file, (original.bom() ? BOM : "") + out);
}
static void editFile(Path file, String oldString, String newString) throws IOException {
Decoded d = load(file);
if (!d.text().contains(oldString)) throw new IllegalArgumentException("oldString not found");
store(file, d, d.text().replace(oldString, newString));
}
public static void main(String[] args) throws IOException {
HexFormat hex = HexFormat.ofDelimiter(" ");
Path dir = Files.createTempDirectory("eol");
Path win = dir.resolve("Win.java");
Files.writeString(win, "class Win {\r\n int a;\r\n}\r\n");
String naive = Files.readString(win);
System.out.println("naive contains \"class Win {\\n int a;\": " + naive.contains("class Win {\n int a;"));
System.out.println("naive starts with \"class\": " + naive.startsWith("class"));
Decoded d = load(win);
System.out.println("bom=" + d.bom() + " crlf=" + d.crlf() + " normalized contains it: " + d.text().contains("class Win {\n int a;"));
editFile(win, " int a;\n", " int a;\n int b;\n");
System.out.println("after edit: " + hex.formatHex(Files.readAllBytes(win)));
Path latin = dir.resolve("Latin.java");
Files.write(latin, "// café\nclass Latin {}\n".getBytes(StandardCharsets.ISO_8859_1));
try {
editFile(latin, "class Latin", "class Latin1");
} catch (MalformedInputException e) {
System.out.println("Latin.java: " + e.getClass().getSimpleName() + " (input length " + e.getInputLength() + "); file left unchanged: "
+ hex.formatHex(Files.readAllBytes(latin), 0, 8));
}
Files.delete(win);
Files.delete(latin);
Files.delete(dir);
}
}naive contains "class Win {\n int a;": false
naive starts with "class": false
bom=true crlf=true normalized contains it: true
after edit: ef bb bf 63 6c 61 73 73 20 57 69 6e 20 7b 0d 0a 20 20 20 20 69 6e 74 20 61 3b 0d 0a 20 20 20 20 69 6e 74 20 62 3b 0d 0a 7d 0d 0a
Latin.java: MalformedInputException (input length 3); file left unchanged: 2f 2f 20 63 61 66 e9 0aWin.java has a BOM and CRLF line endings. Read naively, the content contains \r\n, so the model's "class Win {\n int a;" does not occur in it (false). The content also does not start with class, because Java's UTF-8 decoder keeps the BOM as the character U+FEFF at position 0 (false again). An edit on the first line would fail for a reason the model cannot see.
load removes the BOM and converts CRLF to LF, and records both facts. The normalized text contains the model's string (true). store reverses both steps. The hex dump after the edit shows the result, and it is worth reading in three parts:
- It starts with
ef bb bf, so the BOM is back. - Every line ends with
0d 0a(CR LF), so the line endings are unchanged. - The new line
int b;(the bytes20 20 20 20 69 6e 74 20 62 3b) also ends with0d 0a. The model wrote it with\n, andstoreconverted it.
The edit changed one line and kept the file's format. Without the conversion back, the file would have mixed endings, and version control would show every line of a CRLF file as changed if the tool wrote LF throughout.
Latin.java is encoded in ISO-8859-1. Files.readString decodes strictly as UTF-8, so it threw MalformedInputException and reported an input length of 3. The tool let the exception propagate, so the agent returns it to the model as an error. The first eight bytes of the file (2f 2f 20 63 61 66 e9 0a, which is // café and a newline) are unchanged. Refusing is the safe default for an agent. Converting the file to UTF-8, or editing it in its own encoding, is a decision for the user.
Example 8: read-before-edit and stale-file detection
Two situations break an edit even when the text matches. In the first, the model edits a file it never read and is guessing at the content. In the second, the model read the file, but a person then changed it in an editor, or a formatter rewrote it, so the model's picture of the file is out of date. A file in the second state is called stale.
The agent can detect both with a hash. A hash is a short fingerprint computed from the bytes of a file: the same bytes always give the same fingerprint, and any change gives a different one. This example uses SHA-256 as the hash function and prints the first 6 bytes in hex. The tracker records the hash when the model reads a file. Before every edit, it computes the hash again and compares.
The program runs five steps on Config.java: an edit with no read, an edit after a read, a second edit, an edit after the program changes the file behind the model's back, and an edit after a fresh read. Compare the hash values in the messages.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map;
public class StaleFiles {
static final class FileTracker {
private final Map<Path, String> seen = new HashMap<>(); // file -> hash of the content the model saw
static String hash(Path file) throws IOException {
try {
byte[] d = MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file));
return HexFormat.of().formatHex(d, 0, 6);
} catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
void markRead(Path file) throws IOException { seen.put(file, hash(file)); }
void checkFresh(Path file) throws IOException {
String before = seen.get(file);
if (before == null) throw new IllegalStateException(file.getFileName() + " has not been read in this session; read it first");
String now = hash(file);
if (!now.equals(before))
throw new IllegalStateException(file.getFileName() + " changed on disk since it was read (" + before + " -> " + now + "); read it again");
}
}
static final FileTracker tracker = new FileTracker();
static String read(Path file) throws IOException { tracker.markRead(file); return Files.readString(file); }
static String edit(Path file, String oldString, String newString) throws IOException {
tracker.checkFresh(file);
String content = Files.readString(file);
if (!content.contains(oldString)) throw new IllegalArgumentException("oldString not found");
Files.writeString(file, content.replace(oldString, newString));
tracker.markRead(file); // the model knows the result of its own edit
return "edited " + file.getFileName() + ", now " + FileTracker.hash(file);
}
static void attempt(String label, Path file, String o, String n) {
try { System.out.println(label + ": " + edit(file, o, n)); }
catch (Exception e) { System.out.println(label + ": error: " + e.getMessage()); }
}
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("stale");
Path file = dir.resolve("Config.java");
Files.writeString(file, "class Config { int port = 8080; }\n");
attempt("edit without reading", file, "8080", "9090");
read(file);
attempt("edit after reading", file, "8080", "9090");
attempt("second edit", file, "9090", "9191");
Files.writeString(file, "class Config { int port = 7070; }\n"); // the user changes the file in an editor
attempt("edit after an outside change", file, "9191", "9292");
System.out.print("model reads again: " + read(file));
attempt("edit after reading again", file, "7070", "9292");
Files.delete(file);
Files.delete(dir);
}
}edit without reading: error: Config.java has not been read in this session; read it first
edit after reading: edited Config.java, now e383875ca526
second edit: edited Config.java, now 33465bbd6bf8
edit after an outside change: error: Config.java changed on disk since it was read (33465bbd6bf8 -> e970c09158ad); read it again
model reads again: class Config { int port = 7070; }
edit after reading again: edited Config.java, now 0f0fb2ce7dbbThe first edit was refused because Config.java had not been read in this session. An edit based on no reading is a guess about the file's content. The requirement costs one extra tool call per file.
After the read, the edit replaced 8080 with 9090 and printed the new hash e383875ca526. The tracker then stored that hash as the model's view of the file, because the model knows the result of its own edit. The second edit, 9090 to 9191, therefore needed no new read.
Then the program changed the file directly, as a user would in an editor, setting the port to 7070. The next edit compared hashes. It found 33465bbd6bf8 (what the model last saw) different from e970c09158ad (the file now), and refused with changed on disk since it was read; read it again. Without this check, the model would edit a version of the file that no longer exists. In this case the edit would have failed to find 9191 anyway. But an edit whose oldString still matched would silently overwrite the user's change elsewhere in the file.
After the model read the file again, the edit from 7070 to 9292 succeeded.
read -> tracker remembers hash A
edit -> compare hash of the file now with A
same -> edit, then remember the hash of the result
different -> refuse: "changed on disk since it was read"
never read -> refuse: "has not been read in this session"A content hash is used rather than the file's modification time. Modification times have limited resolution on some file systems, so two writes in the same tick look like one. Also, a tool that rewrites a file with the same content changes the time but not the hash, and the content is what matters. Hashing reads the whole file, which takes milliseconds for source files.
Example 9: list as a tree with depth limits and ignored directories
When a model starts work in a repository, it needs an overview: which folders exist and where the source lives. The list tool prints a tree for this. Three rules keep the output useful. The entries are sorted, with directories first. The tree has a depth limit and a limit on the number of entries, so a huge repository cannot flood the context. And some directories are not opened, because their content is noise for the model: version control data (.git), downloaded dependencies (node_modules), and build output (target, build).
The program creates a small Maven-style project, including three directories that should be ignored, and lists it twice: first with a depth limit of 2, then with a depth limit of 5. The entry limit is 12. Compare what disappears and what is explained by markers.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
public class ListTool {
static final Set<String> IGNORED = Set.of(".git", "node_modules", "target", "build", ".idea");
static final int MAX_ENTRIES = 12;
static int entries;
static void list(Path dir, int depth, int maxDepth, StringBuilder out) throws IOException {
List<Path> children;
try (var s = Files.list(dir)) {
children = s.sorted(Comparator.comparing((Path p) -> !Files.isDirectory(p)).thenComparing(p -> p.getFileName().toString())).toList();
}
for (Path child : children) {
String name = child.getFileName().toString();
boolean isDir = Files.isDirectory(child);
if (entries == MAX_ENTRIES) { out.append(" ".repeat(depth)).append("... more entries not shown\n"); entries++; return; }
if (entries > MAX_ENTRIES) return;
entries++;
if (isDir && IGNORED.contains(name)) { out.append(" ".repeat(depth)).append(name).append("/ (ignored)\n"); continue; }
out.append(" ".repeat(depth)).append(name).append(isDir ? "/" : "").append('\n');
if (isDir && depth + 1 < maxDepth) list(child, depth + 1, maxDepth, out);
else if (isDir) {
long n;
try (var s = Files.list(child)) { n = s.count(); }
out.append(" ".repeat(depth + 1)).append("(").append(n).append(n == 1 ? " entry" : " entries").append(" not shown, depth limit)\n");
}
}
}
public static void main(String[] args) throws IOException {
Path root = Files.createTempDirectory("list");
for (String f : new String[] {"pom.xml", "README.md", "src/main/java/demo/App.java", "src/main/java/demo/Util.java",
"src/test/java/demo/AppTest.java", "target/classes/demo/App.class", ".git/HEAD", "node_modules/x/index.js"}) {
Files.createDirectories(root.resolve(f).getParent());
Files.writeString(root.resolve(f), "");
}
for (int maxDepth : new int[] {2, 5}) {
StringBuilder out = new StringBuilder();
entries = 0;
list(root, 0, maxDepth, out);
System.out.println("depth " + maxDepth + ":");
System.out.print(out);
}
try (var s = Files.walk(root)) { for (Path p : s.sorted(Comparator.reverseOrder()).toList()) Files.delete(p); }
}
}depth 2:
.git/ (ignored)
node_modules/ (ignored)
src/
main/
(1 entry not shown, depth limit)
test/
(1 entry not shown, depth limit)
target/ (ignored)
README.md
pom.xml
depth 5:
.git/ (ignored)
node_modules/ (ignored)
src/
main/
java/
demo/
App.java
Util.java
test/
java/
demo/
AppTest.java
... more entries not shownWith depth 2, the tree shows the top level and one level below it. .git, node_modules, and target appear with (ignored) and are not opened. .git holds version-control objects, node_modules can hold tens of thousands of files, and target holds Maven build output that duplicates the sources. Listing their names, without content, tells the model that they exist. src/main and src/test are at the depth limit, so the tool prints how many entries each contains instead of descending.
With depth 5, the tree reaches the Java files. The output stopped after 12 entries with ... more entries not shown, which is why README.md and pom.xml are missing from the second tree. The limit counts every printed entry, and the directories were listed first, so they took the space. A model that sees the marker can list a subdirectory or use glob.
Sorting matters for two reasons. Files.list returns entries in the order the file system stores them, which differs between machines, so unsorted output would make transcripts and tests unstable. And a fixed order (directories first, then names) makes the tree easier to read. Real agents usually also honor .gitignore, the file in which a project lists what version control should skip. This listing uses a fixed set to stay short.
Example 10: glob with PathMatcher
Sometimes the model knows part of a file name but not where it lives, for example "all test classes". A glob is a pattern with wildcards for file paths. Three wildcards matter here:
*matches any characters within one path element, so it never crosses a/.**matches across directories.{a,b}matches either alternative.
Java's PathMatcher implements globs, and the tool matches it against paths relative to the root, skipping the same ignored directories as the list tool. The program runs six patterns over a small project with a target directory. Watch two results closely: **/*.java does not return Main.java, and **.java does.
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
public class GlobTool {
static final Set<String> IGNORED = Set.of(".git", "node_modules", "target", "build");
static List<String> glob(Path root, String pattern, int limit) throws IOException {
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern);
List<String> hits = new ArrayList<>();
Files.walkFileTree(root, new SimpleFileVisitor<>() {
@Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes a) {
return !dir.equals(root) && IGNORED.contains(dir.getFileName().toString()) ? FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE;
}
@Override public FileVisitResult visitFile(Path file, BasicFileAttributes a) {
Path rel = root.relativize(file);
if (matcher.matches(rel)) hits.add(rel.toString());
return FileVisitResult.CONTINUE;
}
});
hits.sort(null);
if (hits.size() <= limit) return hits;
List<String> shown = new ArrayList<>(hits.subList(0, limit));
shown.add("(" + (hits.size() - limit) + " more)");
return shown;
}
public static void main(String[] args) throws IOException {
Path root = Files.createTempDirectory("glob");
for (String f : new String[] {"Main.java", "pom.xml", "README.md", "src/main/java/demo/App.java",
"src/main/java/demo/Util.java", "src/test/java/demo/AppTest.java", "target/generated/Gen.java"}) {
Files.createDirectories(root.resolve(f).getParent());
Files.writeString(root.resolve(f), "");
}
for (String pattern : new String[] {"*.java", "**/*.java", "**.java", "src/**/*Test.java", "{pom.xml,README.md}", "src/*/java/*.java"})
System.out.printf("%-20s %s%n", pattern, glob(root, pattern, 3));
try (var s = Files.walk(root)) { for (Path p : s.sorted(Comparator.reverseOrder()).toList()) Files.delete(p); }
}
}*.java [Main.java]
**/*.java [src/main/java/demo/App.java, src/main/java/demo/Util.java, src/test/java/demo/AppTest.java]
**.java [Main.java, src/main/java/demo/App.java, src/main/java/demo/Util.java, (1 more)]
src/**/*Test.java [src/test/java/demo/AppTest.java]
{pom.xml,README.md} [README.md, pom.xml]
src/*/java/*.java []*.java matched only Main.java at the root, because * does not cross a /.
**/*.java matched the three Java files under src but not Main.java. In Java's glob syntax, **/*.java requires at least one directory and a / before the file name, and Main.java has neither. Glob implementations differ here. Bash with the globstar option matches Main.java with the same pattern (checked with bash 5 in the course container), and models often write the pattern expecting that behavior. **.java matched all four files, because ** matches any characters including /, and it also matches nothing. The result was cut to three entries with (1 more). target/generated/Gen.java was not matched by either pattern, because the walk skipped target.
src/**/*Test.java found the test class. {pom.xml,README.md} matched both alternatives. Results are sorted, and uppercase letters sort before lowercase in Java's string order, so README.md comes first. src/*/java/*.java matched nothing. The files are one directory deeper (demo), and * matches exactly one level.
The **/*.java difference is a reason for a tool description to show an example pattern. It is also a reason for the tool to say no files match (as Project 22 does) instead of returning an empty string, so the model knows the search ran and can try another pattern.
Example 11: grep with a regex, line numbers, and result limits
Glob finds files by name. To find files by what they contain, such as every use of a method, the model needs a search tool. The grep tool takes a regular expression, a pattern language for text (for example, \blog\( means the whole word log followed by an opening parenthesis). It compiles the pattern with Java's Pattern, walks the workspace, skips ignored directories and binary files, and prints path:line: text for every matching line, up to a limit.
The program creates a small project with a target directory and a binary file, then runs four searches: a case-sensitive one, a case-insensitive one, one with a limit of 1, and one with an invalid pattern. A final search finds nothing. Look at the trailing notes in brackets, which tell the model what the tool did not search or show.
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
public class GrepTool {
static final Set<String> IGNORED = Set.of(".git", "node_modules", "target", "build");
static String grep(Path root, String regex, boolean ignoreCase, int maxMatches) throws IOException {
Pattern p;
try {
p = Pattern.compile(regex, ignoreCase ? Pattern.CASE_INSENSITIVE : 0);
} catch (PatternSyntaxException e) {
return "error: invalid regex: " + e.getDescription() + " near index " + e.getIndex() + "\n";
}
List<Path> files;
try (var s = Files.walk(root)) {
files = s.filter(Files::isRegularFile)
.filter(f -> root.relativize(f).getNameCount() == 1 || !IGNORED.contains(root.relativize(f).getName(0).toString()))
.sorted().toList();
}
StringBuilder out = new StringBuilder();
int matches = 0, skippedBinary = 0;
for (Path f : files) {
byte[] head;
try (InputStream in = Files.newInputStream(f)) { head = in.readNBytes(8000); }
boolean binary = false;
for (byte b : head) binary |= b == 0;
if (binary) { skippedBinary++; continue; }
List<String> lines = Files.readAllLines(f);
for (int i = 0; i < lines.size(); i++) {
if (!p.matcher(lines.get(i)).find()) continue;
if (++matches <= maxMatches) out.append(root.relativize(f)).append(':').append(i + 1).append(": ").append(lines.get(i).strip()).append('\n');
}
}
int more = matches - maxMatches;
if (more > 0) out.append("[").append(more).append(more == 1 ? " more match" : " more matches").append(" not shown; narrow the pattern]\n");
if (matches == 0) out.append("no matches\n");
if (skippedBinary > 0) out.append("[").append(skippedBinary).append(skippedBinary == 1 ? " binary file" : " binary files").append(" skipped]\n");
return out.toString();
}
public static void main(String[] args) throws IOException {
Path root = Files.createTempDirectory("grep");
Files.createDirectories(root.resolve("src/demo"));
Files.createDirectories(root.resolve("target"));
Files.writeString(root.resolve("src/demo/App.java"), "class App {\n // TODO: parse args\n void run() { log(\"start\"); }\n // todo: exit code\n}\n");
Files.writeString(root.resolve("src/demo/Util.java"), "class Util {\n static void log(String s) { System.out.println(s); }\n}\n");
Files.writeString(root.resolve("target/App.java"), "// TODO: generated copy\n");
Files.write(root.resolve("src/demo/icon.bin"), new byte[] {'T', 'O', 'D', 'O', 0, 1, 2});
System.out.print(grep(root, "TODO", false, 10));
System.out.println("---");
System.out.print(grep(root, "todo", true, 10));
System.out.println("---");
System.out.print(grep(root, "\\blog\\(", false, 1));
System.out.println("---");
System.out.print(grep(root, "log(", false, 10));
System.out.print(grep(root, "FIXME", false, 10));
try (var s = Files.walk(root)) { for (Path q : s.sorted(Comparator.reverseOrder()).toList()) Files.delete(q); }
}
}src/demo/App.java:2: // TODO: parse args
[1 binary file skipped]
---
src/demo/App.java:2: // TODO: parse args
src/demo/App.java:4: // todo: exit code
[1 binary file skipped]
---
src/demo/App.java:3: void run() { log("start"); }
[1 more match not shown; narrow the pattern]
[1 binary file skipped]
---
error: invalid regex: Unclosed group near index 4
no matches
[1 binary file skipped]The first search for TODO found line 2 of App.java. It did not find target/App.java, because target is ignored, and it did not find the lower-case todo on line 4, because the search is case-sensitive. icon.bin begins with the bytes TODO but contains a NUL byte, so it was skipped and counted. The note [1 binary file skipped] tells the model that some files were not searched.
The second search used ignoreCase, and found both lines. The third search for \blog\( matched two lines, the call in App.java and the definition in Util.java. The limit was 1, so the tool printed the first and reported 1 more match not shown. Files are visited in sorted order, so the first match is the same on every run.
The fourth search used log( without escaping the parenthesis. In a regular expression, ( opens a group, so the pattern is invalid, and Java reports Unclosed group near index 4. The tool returned the error from PatternSyntaxException as text instead of failing, and the model can escape the character or retry. The last search, for FIXME, found nothing and says no matches.
Each match line is stripped of leading whitespace to save tokens (a token is the unit of text a model reads and is billed by). The line number lets the model call read_file with an offset near the match. For large repositories, external tools such as ripgrep are faster than a Java walk, and the output format can stay the same.
Example 12: a unified diff for the user
After an edit, the person who started the agent wants to see what changed. The standard text format for this is the unified diff, the same format git diff prints. In it:
- A line starting with
-was removed. - A line starting with
+was added. - A line starting with a space is unchanged context, shown so the reader can find the place.
Each region of change, called a hunk, starts with a header @@ -a,b +c,d @@. The numbers a and b are the start line and the length in the old file, and c and d are the same for the new file. For example, @@ -13,7 +14,7 @@ means "seven lines starting at old line 13, which are seven lines starting at new line 14".
To compute a diff, the program finds the longest common subsequence of the two files. That is the longest list of lines that appears in both files in the same order. Every line outside that list is a removal or an addition. The program then prints hunks with three lines of context around each change.
The test file has 20 one-line methods. The new version inserts a comment before line 3 and changes the body of f16. Expect two hunks.
import java.util.ArrayList;
import java.util.List;
public class UnifiedDiff {
/** Line diff by longest common subsequence; ops are ' ', '-', '+' with the line. */
static List<String> ops(List<String> a, List<String> b) {
int[][] lcs = new int[a.size() + 1][b.size() + 1];
for (int i = a.size() - 1; i >= 0; i--)
for (int j = b.size() - 1; j >= 0; j--)
lcs[i][j] = a.get(i).equals(b.get(j)) ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
List<String> out = new ArrayList<>();
int i = 0, j = 0;
while (i < a.size() || j < b.size()) {
if (i < a.size() && j < b.size() && a.get(i).equals(b.get(j))) { out.add(" " + a.get(i)); i++; j++; }
else if (i < a.size() && (j == b.size() || lcs[i + 1][j] >= lcs[i][j + 1])) out.add("-" + a.get(i++));
else out.add("+" + b.get(j++));
}
return out;
}
static String unifiedDiff(String path, String before, String after, int context) {
List<String> ops = ops(before.lines().toList(), after.lines().toList());
StringBuilder sb = new StringBuilder("--- a/" + path + "\n+++ b/" + path + "\n");
int k = 0;
while (k < ops.size()) {
if (ops.get(k).charAt(0) == ' ') { k++; continue; }
int start = Math.max(0, k - context), end = k; // grow the hunk while changes are close together
while (end < ops.size()) {
int next = end;
while (next < ops.size() && ops.get(next).charAt(0) == ' ') next++;
if (next == ops.size() || next - end > 2 * context) { end = Math.min(ops.size(), end + context); break; }
while (next < ops.size() && ops.get(next).charAt(0) != ' ') next++;
end = next;
}
int oldStart = 1, newStart = 1;
for (int x = 0; x < start; x++) { if (ops.get(x).charAt(0) != '+') oldStart++; if (ops.get(x).charAt(0) != '-') newStart++; }
int oldLen = 0, newLen = 0;
for (int x = start; x < end; x++) { if (ops.get(x).charAt(0) != '+') oldLen++; if (ops.get(x).charAt(0) != '-') newLen++; }
sb.append("@@ -").append(oldLen == 0 ? oldStart - 1 : oldStart).append(',').append(oldLen) // an empty side names the line before
.append(" +").append(newLen == 0 ? newStart - 1 : newStart).append(',').append(newLen).append(" @@\n");
for (int x = start; x < end; x++) sb.append(ops.get(x)).append('\n');
k = end;
}
return sb.toString();
}
public static void main(String[] args) {
StringBuilder before = new StringBuilder(), after = new StringBuilder();
for (int i = 1; i <= 20; i++) {
String line = " int f" + i + "() { return " + i + "; }";
before.append(line).append('\n');
if (i == 3) after.append(" // first change\n");
if (i == 16) { after.append(" int f16() { return 160; }\n"); continue; }
after.append(line).append('\n');
}
System.out.print(unifiedDiff("src/F.java", before.toString(), after.toString(), 3));
}
}--- a/src/F.java
+++ b/src/F.java
@@ -1,5 +1,6 @@
int f1() { return 1; }
int f2() { return 2; }
+ // first change
int f3() { return 3; }
int f4() { return 4; }
int f5() { return 5; }
@@ -13,7 +14,7 @@
int f13() { return 13; }
int f14() { return 14; }
int f15() { return 15; }
- int f16() { return 16; }
+ int f16() { return 160; }
int f17() { return 17; }
int f18() { return 18; }
int f19() { return 19; }The file has 20 one-line methods. The new version inserts a comment before line 3 and changes the body of f16.
The first hunk, @@ -1,5 +1,6 @@, covers old lines 1 to 5 and new lines 1 to 6. It holds two context lines, the added comment, and three context lines. It starts at line 1 because there are only two lines before the change. The new side is one line longer because of the insertion.
The second hunk, @@ -13,7 +14,7 @@, starts at old line 13 and new line 14. The new number is one higher because the inserted comment shifted every later line down by one. The hunk shows three context lines, the removal of the old f16, the addition of the new one, and three more context lines. Old lines 6 to 12 are not shown. They are more than six lines away from both changes, so the changes form two hunks. Changes closer than twice the context size are merged into one hunk.
second hunk: @@ -13,7 +14,7 @@
old side: 3 context + 1 removed + 3 context = 7 lines, starting at old line 13
new side: 3 context + 1 added + 3 context = 7 lines, starting at new line 14The output is identical to diff -u from GNU diffutils 3.10 for the same two files, apart from the --- and +++ header lines, which carry file names and times in GNU's version. A replaced line appears as - followed by +, the order GNU diff and git use. The program builds a table whose size grows with the product of the two file lengths. That is fine for source files of a few thousand lines. Git and GNU diff use faster algorithms (Myers' algorithm and variants) for large inputs.
The diff is for the user, who approves or reviews changes. It can also go to the model, but a short confirmation (edited ...: 1 replacement, first at line 5) costs fewer tokens, and the model can read the file if it needs to check.
Example 13: Project 22, file tools in the Project 21 agent
So far each tool was a separate program. A real agent needs them as tools registered in the agent loop, so that the model can call them by name with JSON arguments. Project 22 does this. It restates the Project 21 code from The Agent Loop and a Tool Framework unchanged. That code defines the messages, a scripted model that replays prepared replies, the Tool interface with Tool.of, the registry that validates arguments and runs tools, and the Agent loop. Project 22 then adds a Workspace (Example 1) and a FileTools class that combines Examples 1 to 12. It registers six tools through Tool.of, each with an argument record that generates the tool's JSON schema.
A scripted model then performs a small task, so the output is the same on every run. It finds the Java files, reads Greeter.java, edits it (first with the wrong indentation, then correctly), creates a test file, and searches for callers. The part to read is the main method at the end, where the script and the listener are. The listener prints each tool call with > and the first 8 lines of each result below it, with ! marking errors.
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.RecordComponent;
import java.lang.reflect.Type;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Deque;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import java.util.regex.Pattern;
public class FileToolsProject {
static final ObjectMapper JSON = new ObjectMapper();
// ---------------- Project 21: messages ----------------
sealed interface Message permits SystemMessage, UserMessage, AssistantMessage, ToolMessage {}
record SystemMessage(String text) implements Message {}
record UserMessage(String text) implements Message {}
record ToolCall(String id, String name, String arguments) {}
record AssistantMessage(String text, List<ToolCall> toolCalls) implements Message {
AssistantMessage {
text = text == null ? "" : text;
toolCalls = toolCalls == null ? List.of() : List.copyOf(toolCalls);
}
static AssistantMessage text(String text) { return new AssistantMessage(text, List.of()); }
static AssistantMessage calls(ToolCall... calls) { return new AssistantMessage("", List.of(calls)); }
}
record ToolMessage(String callId, String name, String content, boolean isError) implements Message {}
// ---------------- Project 21: the model side ----------------
record ToolSpec(String name, String description, JsonNode parameters) {}
record ModelResponse(AssistantMessage message, int promptTokens, int completionTokens) {}
@FunctionalInterface
interface ModelClient {
ModelResponse chat(List<Message> messages, List<ToolSpec> tools) throws Exception;
}
/** Replays canned replies in order and estimates tokens as characters / 4. */
static final class ScriptedModel implements ModelClient {
private final Deque<AssistantMessage> replies;
final List<List<Message>> requests = new ArrayList<>();
ScriptedModel(AssistantMessage... replies) { this.replies = new ArrayDeque<>(List.of(replies)); }
@Override public ModelResponse chat(List<Message> messages, List<ToolSpec> tools) {
requests.add(List.copyOf(messages));
AssistantMessage reply = replies.poll();
if (reply == null) throw new IllegalStateException("script has no reply for request " + requests.size());
int prompt = messages.stream().mapToInt(ScriptedModel::chars).sum() / 4;
return new ModelResponse(reply, prompt, chars(reply) / 4);
}
static int chars(Message m) {
return switch (m) {
case SystemMessage s -> s.text().length();
case UserMessage u -> u.text().length();
case AssistantMessage a -> a.text().length()
+ a.toolCalls().stream().mapToInt(c -> c.name().length() + c.arguments().length()).sum();
case ToolMessage t -> t.content().length();
};
}
}
// ---------------- Project 21: tools ----------------
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.RECORD_COMPONENT)
@interface Param {
String value() default "";
boolean optional() default false;
}
@FunctionalInterface
interface ToolFunction<A> { String apply(A args) throws Exception; }
interface Tool {
String name();
String description();
JsonNode schema();
String run(JsonNode args) throws Exception;
default boolean readOnly() { return false; }
default ToolSpec spec() { return new ToolSpec(name(), description(), schema()); }
/** A tool whose arguments are a record; the schema comes from the record's components. */
static <A extends Record> Tool of(String name, String description, boolean readOnly, Class<A> type, ToolFunction<A> fn) {
JsonNode schema = Schemas.forRecord(type);
return new Tool() {
public String name() { return name; }
public String description() { return description; }
public JsonNode schema() { return schema; }
public boolean readOnly() { return readOnly; }
public String run(JsonNode args) throws Exception { return fn.apply(JSON.treeToValue(args, type)); }
};
}
}
static final class Schemas {
static ObjectNode forRecord(Class<? extends Record> type) {
ObjectNode schema = JSON.createObjectNode().put("type", "object");
ObjectNode properties = schema.putObject("properties");
ArrayNode required = JSON.createArrayNode();
for (RecordComponent c : type.getRecordComponents()) {
ObjectNode p = forType(c.getGenericType());
Param param = c.getAnnotation(Param.class);
if (param != null && !param.value().isEmpty()) p.put("description", param.value());
properties.set(c.getName(), p);
if (param == null || !param.optional()) required.add(c.getName());
}
schema.set("required", required);
schema.put("additionalProperties", false);
return schema;
}
@SuppressWarnings("unchecked")
static ObjectNode forType(Type t) {
ObjectNode n = JSON.createObjectNode();
if (t instanceof ParameterizedType p && p.getRawType() == List.class)
return n.put("type", "array").set("items", forType(p.getActualTypeArguments()[0]));
Class<?> c = (Class<?>) t;
if (c == String.class) n.put("type", "string");
else if (c == int.class || c == Integer.class || c == long.class || c == Long.class) n.put("type", "integer");
else if (c == double.class || c == Double.class) n.put("type", "number");
else if (c == boolean.class || c == Boolean.class) n.put("type", "boolean");
else if (c.isEnum()) {
ArrayNode values = n.put("type", "string").putArray("enum");
for (Object k : c.getEnumConstants()) values.add(k.toString());
} else if (c.isRecord()) return forRecord((Class<? extends Record>) c);
else throw new IllegalArgumentException("no schema for " + c);
return n;
}
static List<String> validate(JsonNode schema, JsonNode value) {
List<String> errors = new ArrayList<>();
check(schema, value, "arguments", errors);
return errors;
}
private static void check(JsonNode s, JsonNode v, String path, List<String> errors) {
String type = s.path("type").asText();
boolean ok = switch (type) {
case "object" -> v.isObject();
case "array" -> v.isArray();
case "string" -> v.isTextual();
case "integer" -> v.isIntegralNumber();
case "number" -> v.isNumber();
case "boolean" -> v.isBoolean();
default -> true;
};
if (!ok) { errors.add(path + ": expected " + type + ", got " + kind(v)); return; }
if (s.has("enum")) {
boolean found = false;
for (JsonNode e : s.get("enum")) found |= e.equals(v);
if (!found) errors.add(path + ": must be one of " + s.get("enum"));
}
if (v.isObject()) {
for (JsonNode r : s.path("required"))
if (!v.has(r.asText())) errors.add(path + ": missing required property '" + r.asText() + "'");
for (Map.Entry<String, JsonNode> e : v.properties()) {
JsonNode ps = s.path("properties").get(e.getKey());
if (ps != null) check(ps, e.getValue(), path + "." + e.getKey(), errors);
else if (!s.path("additionalProperties").asBoolean(true)) errors.add(path + ": unknown property '" + e.getKey() + "'");
}
}
if (v.isArray() && s.has("items"))
for (int i = 0; i < v.size(); i++) check(s.get("items"), v.get(i), path + "[" + i + "]", errors);
}
static String kind(JsonNode v) {
return v.isNull() ? "null" : v.isTextual() ? "string" : v.isIntegralNumber() ? "integer"
: v.isNumber() ? "number" : v.isBoolean() ? "boolean" : v.isArray() ? "array" : "object";
}
}
static final class ToolRegistry {
private final Map<String, Tool> tools = new LinkedHashMap<>();
private final Duration timeout;
private final int maxOutputChars;
ToolRegistry(Duration timeout, int maxOutputChars) {
this.timeout = timeout;
this.maxOutputChars = maxOutputChars;
}
ToolRegistry register(Tool tool) { tools.put(tool.name(), tool); return this; }
List<ToolSpec> specs() { return tools.values().stream().map(Tool::spec).toList(); }
boolean isReadOnly(ToolCall call) { Tool t = tools.get(call.name()); return t != null && t.readOnly(); }
/** Never throws: every problem becomes an error result that the model can read. */
ToolMessage execute(ToolCall call) {
Tool tool = tools.get(call.name());
if (tool == null)
return result(call, true, "unknown tool '" + call.name() + "'. Available tools: " + String.join(", ", tools.keySet()));
JsonNode args;
try {
args = JSON.readTree(call.arguments() == null || call.arguments().isBlank() ? "{}" : call.arguments());
} catch (JsonProcessingException e) {
return result(call, true, "arguments are not valid JSON: " + e.getOriginalMessage());
}
List<String> problems = Schemas.validate(tool.schema(), args);
if (!problems.isEmpty()) return result(call, true, "invalid arguments: " + String.join("; ", problems));
FutureTask<String> task = new FutureTask<>(() -> tool.run(args));
Thread.ofVirtual().name("tool-" + call.name()).start(task);
try {
return result(call, false, task.get(timeout.toMillis(), TimeUnit.MILLISECONDS));
} catch (TimeoutException e) {
task.cancel(true);
return result(call, true, "timed out after " + timeout.toMillis() + " ms");
} catch (ExecutionException e) {
Throwable cause = e.getCause();
return result(call, true, cause.getClass().getSimpleName() + ": " + cause.getMessage());
} catch (InterruptedException e) {
task.cancel(true);
Thread.currentThread().interrupt();
return result(call, true, "interrupted");
}
}
private ToolMessage result(ToolCall call, boolean isError, String content) {
return new ToolMessage(call.id(), call.name(), truncate(content == null ? "" : content, maxOutputChars), isError);
}
/** Keeps whole lines from the head and the tail, about maxChars / 2 characters each. */
static String truncate(String text, int maxChars) {
if (text.length() <= maxChars) return text;
int budget = maxChars / 2;
String[] lines = text.split("\n", -1);
int head = 0, used = 0;
while (head < lines.length && used + lines[head].length() + 1 <= budget) used += lines[head++].length() + 1;
int tail = lines.length;
used = 0;
while (tail > head && used + lines[tail - 1].length() + 1 <= budget) used += lines[--tail].length() + 1;
int omitted = tail - head;
String marker = "[... " + omitted + (omitted == 1 ? " line" : " lines") + " omitted, " + text.length() + " characters in total ...]";
if (head == 0 && tail == lines.length) // one huge line: cut characters instead
return text.substring(0, budget) + "\n" + marker + "\n" + text.substring(text.length() - budget);
List<String> out = new ArrayList<>(Arrays.asList(lines).subList(0, head));
out.add(marker);
out.addAll(Arrays.asList(lines).subList(tail, lines.length));
return String.join("\n", out);
}
}
// ---------------- Project 21: the agent ----------------
enum StopReason { COMPLETED, MAX_STEPS, MAX_TOKENS, CANCELLED }
record AgentResult(StopReason stopReason, String finalText, int steps, int tokens) {}
record AgentConfig(String systemPrompt, int maxSteps, int maxTokens) {}
interface AgentListener {
default void onAssistant(AssistantMessage message) {}
default void onToolCall(ToolCall call) {}
default void onToolResult(ToolMessage result) {}
default void onStop(AgentResult result) {}
}
static final class Agent {
private final ModelClient model;
private final ToolRegistry tools;
private final AgentConfig config;
private final List<Message> messages = new ArrayList<>();
private final List<AgentListener> listeners = new CopyOnWriteArrayList<>();
private volatile boolean cancelled;
Agent(ModelClient model, ToolRegistry tools, AgentConfig config) {
this.model = model;
this.tools = tools;
this.config = config;
messages.add(new SystemMessage(config.systemPrompt()));
}
Agent addListener(AgentListener listener) { listeners.add(listener); return this; }
void cancel() { cancelled = true; }
List<Message> messages() { return Collections.unmodifiableList(messages); }
AgentResult run(String task) throws Exception {
cancelled = false;
messages.add(new UserMessage(task));
int steps = 0, tokens = 0;
while (true) {
if (cancelled) return stop(StopReason.CANCELLED, "", steps, tokens);
if (steps >= config.maxSteps()) return stop(StopReason.MAX_STEPS, "", steps, tokens);
if (tokens >= config.maxTokens()) return stop(StopReason.MAX_TOKENS, "", steps, tokens);
ModelResponse response = model.chat(List.copyOf(messages), tools.specs());
steps++;
tokens += response.promptTokens() + response.completionTokens();
AssistantMessage reply = response.message();
messages.add(reply);
listeners.forEach(l -> l.onAssistant(reply));
if (reply.toolCalls().isEmpty()) return stop(StopReason.COMPLETED, reply.text(), steps, tokens);
for (ToolMessage result : runTools(reply.toolCalls())) messages.add(result);
}
}
private List<ToolMessage> runTools(List<ToolCall> calls) {
List<ToolMessage> results = new ArrayList<>();
if (calls.size() > 1 && !cancelled && calls.stream().allMatch(tools::isReadOnly)) {
calls.forEach(c -> listeners.forEach(l -> l.onToolCall(c)));
List<Future<ToolMessage>> futures = new ArrayList<>();
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (ToolCall c : calls) futures.add(pool.submit(() -> tools.execute(c)));
} // close() waits for every call
for (Future<ToolMessage> f : futures) results.add(f.resultNow());
results.forEach(r -> listeners.forEach(l -> l.onToolResult(r)));
return results;
}
for (ToolCall c : calls) { // one at a time, in order
listeners.forEach(l -> l.onToolCall(c));
ToolMessage r = cancelled ? new ToolMessage(c.id(), c.name(), "cancelled before it ran", true) : tools.execute(c);
results.add(r);
listeners.forEach(l -> l.onToolResult(r));
}
return results;
}
private AgentResult stop(StopReason reason, String text, int steps, int tokens) {
AgentResult result = new AgentResult(reason, text, steps, tokens);
listeners.forEach(l -> l.onStop(result));
return result;
}
}
// ---------------- Project 22: file tools ----------------
static final class Workspace {
final Path root;
Workspace(Path root) throws IOException { this.root = root.toRealPath(); }
Path resolve(String userPath) throws IOException {
if (userPath == null || userPath.isBlank()) throw new IllegalArgumentException("empty path");
Path p = root.resolve(userPath).normalize();
if (!p.startsWith(root)) throw new SecurityException("outside the workspace: " + userPath);
Path existing = p;
while (!Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) existing = existing.getParent();
Path real;
try { real = existing.toRealPath(); }
catch (NoSuchFileException e) { throw new SecurityException("dangling symbolic link: " + userPath); }
if (!real.startsWith(root)) throw new SecurityException("leaves the workspace through a symbolic link: " + userPath);
return p;
}
String rel(Path p) { return root.relativize(p).toString(); }
}
record ReadArgs(@Param("File path relative to the workspace root") String path,
@Param(value = "First line to show, starting at 1", optional = true) Integer offset,
@Param(value = "Maximum number of lines to show", optional = true) Integer limit) {}
record WriteArgs(@Param("File path relative to the workspace root") String path,
@Param("The complete content of the file") String content) {}
record EditArgs(@Param("File path relative to the workspace root") String path,
@Param("Exact text to replace, copied from the file without line numbers") String oldString,
@Param("Replacement text") String newString,
@Param(value = "Replace every occurrence instead of exactly one", optional = true) Boolean replaceAll) {}
record ListArgs(@Param(value = "Directory relative to the workspace root; default .", optional = true) String path,
@Param(value = "Depth limit; default 3", optional = true) Integer depth) {}
record GlobArgs(@Param("Glob pattern for relative paths, for example src/**/*.java") String pattern) {}
record GrepArgs(@Param("Java regular expression") String pattern,
@Param(value = "Case-insensitive search", optional = true) Boolean ignoreCase) {}
static final class FileTools {
static final Set<String> IGNORED = Set.of(".git", "node_modules", "target", "build", ".idea");
private final Workspace ws;
private final Map<Path, String> seen = new HashMap<>(); // file -> hash of the content the model saw
private final Consumer<String> diffs; // unified diffs of every change, for the user
FileTools(Workspace ws, Consumer<String> diffs) { this.ws = ws; this.diffs = diffs; }
List<Tool> tools() {
return List.of(
Tool.of("read_file", "Read a text file. Lines are numbered; use offset and limit for long files.", true,
ReadArgs.class, a -> read(a.path(), a.offset() == null ? 1 : a.offset(), a.limit() == null ? 200 : a.limit())),
Tool.of("write_file", "Create a file, or replace a whole file that was read before.", false,
WriteArgs.class, a -> write(a.path(), a.content())),
Tool.of("edit_file", "Replace exact text in a file that was read before. oldString must occur once unless replaceAll is true.", false,
EditArgs.class, a -> edit(a.path(), a.oldString(), a.newString(), Boolean.TRUE.equals(a.replaceAll()))),
Tool.of("list_files", "Show a directory tree without build output and version control directories.", true,
ListArgs.class, a -> list(a.path() == null ? "." : a.path(), a.depth() == null ? 3 : a.depth())),
Tool.of("glob", "List files whose relative path matches a glob pattern.", true,
GlobArgs.class, a -> glob(a.pattern())),
Tool.of("grep", "Search file contents with a regular expression; prints path:line: text.", true,
GrepArgs.class, a -> grep(a.pattern(), Boolean.TRUE.equals(a.ignoreCase()))));
}
private record Text(String body, boolean bom, boolean crlf) {}
private Text load(Path f) throws IOException {
byte[] bytes = Files.readAllBytes(f);
for (int i = 0; i < Math.min(bytes.length, 8000); i++)
if (bytes[i] == 0) throw new IOException(ws.rel(f) + " looks binary; not shown");
String raw;
try { raw = StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString(); }
catch (CharacterCodingException e) { throw new IOException(ws.rel(f) + " is not valid UTF-8"); }
boolean bom = raw.startsWith("\uFEFF");
if (bom) raw = raw.substring(1);
boolean crlf = raw.contains("\r\n");
return new Text(crlf ? raw.replace("\r\n", "\n") : raw, bom, crlf);
}
private void store(Path f, Text old, String body) throws IOException {
String out = (old.bom() ? "\uFEFF" : "") + (old.crlf() ? body.replace("\n", "\r\n") : body);
Files.createDirectories(f.getParent());
Path tmp = Files.createTempFile(f.getParent(), ".loop-", ".tmp");
Files.writeString(tmp, out);
Files.setPosixFilePermissions(tmp, Files.exists(f) ? Files.getPosixFilePermissions(f) : PosixFilePermissions.fromString("rw-r--r--"));
Files.move(tmp, f, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
seen.put(f, hash(f));
}
private static String hash(Path f) throws IOException {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(f)), 0, 6); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private void checkFresh(Path f) throws IOException {
String before = seen.get(f);
if (before == null) throw new IllegalStateException(ws.rel(f) + " has not been read yet; read it first");
if (!before.equals(hash(f))) throw new IllegalStateException(ws.rel(f) + " changed on disk since it was read; read it again");
}
String read(String path, int offset, int limit) throws IOException {
Path f = ws.resolve(path);
if (Files.isDirectory(f)) throw new IllegalArgumentException(path + " is a directory; use list_files");
List<String> lines = load(f).body().lines().toList();
seen.put(f, hash(f));
if (lines.isEmpty()) return "(empty file)";
if (offset < 1 || offset > lines.size()) throw new IllegalArgumentException("offset must be between 1 and " + lines.size());
int end = Math.min(lines.size(), offset - 1 + limit);
StringBuilder sb = new StringBuilder();
for (int i = offset - 1; i < end; i++) {
String line = lines.get(i);
if (line.length() > 500) line = line.substring(0, 500) + "... [" + line.length() + " characters]";
sb.append(String.format("%5d| %s\n", i + 1, line));
}
if (end < lines.size())
sb.append("[lines ").append(offset).append('-').append(end).append(" of ").append(lines.size())
.append("; continue with offset=").append(end + 1).append("]\n");
return sb.toString();
}
String write(String path, String content) throws IOException {
Path f = ws.resolve(path);
if (Files.isDirectory(f)) throw new IllegalArgumentException(path + " is a directory");
Text old = new Text("", false, false);
if (Files.exists(f)) { checkFresh(f); old = load(f); }
boolean created = !Files.exists(f);
store(f, old, content);
diffs.accept(unifiedDiff(ws.rel(f), old.body(), content));
return (created ? "created " : "replaced ") + ws.rel(f) + " (" + content.lines().count() + " lines)";
}
String edit(String path, String oldString, String newString, boolean replaceAll) throws IOException {
Path f = ws.resolve(path);
checkFresh(f);
if (oldString.isEmpty()) throw new IllegalArgumentException("oldString is empty; use write_file to create a file");
if (oldString.equals(newString)) throw new IllegalArgumentException("oldString and newString are identical");
Text t = load(f);
String body = t.body();
int count = 0;
for (int i = body.indexOf(oldString); i >= 0; i = body.indexOf(oldString, i + oldString.length())) count++;
if (count == 0) throw new IllegalArgumentException("oldString not found in " + ws.rel(f) + hint(body, oldString));
if (count > 1 && !replaceAll)
throw new IllegalArgumentException("oldString matches " + count + " times in " + ws.rel(f) + "; add surrounding lines to make it unique, or set replaceAll");
int first = body.indexOf(oldString);
String updated = replaceAll ? body.replace(oldString, newString)
: body.substring(0, first) + newString + body.substring(first + oldString.length());
store(f, t, updated);
diffs.accept(unifiedDiff(ws.rel(f), body, updated));
long line = body.substring(0, first).chars().filter(c -> c == '\n').count() + 1;
return "edited " + ws.rel(f) + ": " + count + (count == 1 ? " replacement" : " replacements") + ", first at line " + line;
}
/** Points at a line that matches the first line of oldString except for whitespace. */
private static String hint(String body, String oldString) {
String want = oldString.lines().findFirst().orElse("").strip().replaceAll("\\s+", " ");
List<String> lines = body.lines().toList();
for (int i = 0; i < lines.size(); i++)
if (!want.isEmpty() && lines.get(i).strip().replaceAll("\\s+", " ").equals(want))
return "; line " + (i + 1) + " matches except for whitespace (the file indents it with "
+ indent(lines.get(i)) + ", oldString with " + indent(oldString) + ")";
return "; read the file again and copy the text exactly";
}
private static String indent(String line) {
int spaces = 0, tabs = 0;
for (char c : line.toCharArray()) { if (c == ' ') spaces++; else if (c == '\t') tabs++; else break; }
String s = (spaces > 0 ? spaces + (spaces == 1 ? " space" : " spaces") : "") + (spaces > 0 && tabs > 0 ? " and " : "")
+ (tabs > 0 ? tabs + (tabs == 1 ? " tab" : " tabs") : "");
return s.isEmpty() ? "no indentation" : s;
}
String list(String path, int depth) throws IOException {
StringBuilder out = new StringBuilder();
listInto(ws.resolve(path), 0, depth, out);
return out.isEmpty() ? "(empty directory)" : out.toString();
}
private void listInto(Path dir, int level, int depth, StringBuilder out) throws IOException {
List<Path> children;
try (var s = Files.list(dir)) {
children = s.sorted(Comparator.comparing((Path p) -> !Files.isDirectory(p)).thenComparing(p -> p.getFileName().toString())).toList();
}
for (Path c : children) {
String name = c.getFileName().toString();
boolean isDir = Files.isDirectory(c);
if (isDir && IGNORED.contains(name)) continue;
out.append(" ".repeat(level)).append(name).append(isDir ? "/" : "").append('\n');
if (isDir && level + 1 < depth) listInto(c, level + 1, depth, out);
}
}
String glob(String pattern) throws IOException {
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern);
try (var s = Files.walk(ws.root)) {
List<String> hits = s.filter(Files::isRegularFile).map(ws::rel)
.filter(r -> !IGNORED.contains(Path.of(r).getName(0).toString()))
.filter(r -> matcher.matches(Path.of(r))).sorted().toList();
return hits.isEmpty() ? "no files match " + pattern : String.join("\n", hits);
}
}
String grep(String regex, boolean ignoreCase) throws IOException {
Pattern p = Pattern.compile(regex, ignoreCase ? Pattern.CASE_INSENSITIVE : 0);
List<Path> files;
try (var s = Files.walk(ws.root)) {
files = s.filter(Files::isRegularFile).filter(f -> !IGNORED.contains(ws.root.relativize(f).getName(0).toString())).sorted().toList();
}
StringBuilder out = new StringBuilder();
int matches = 0;
for (Path f : files) {
List<String> lines;
try { lines = load(f).body().lines().toList(); } catch (IOException e) { continue; } // binary or not UTF-8
for (int i = 0; i < lines.size(); i++)
if (p.matcher(lines.get(i)).find() && ++matches <= 50)
out.append(ws.rel(f)).append(':').append(i + 1).append(": ").append(lines.get(i).strip()).append('\n');
}
if (matches > 50) out.append("[").append(matches - 50).append(" more matches not shown]\n");
return matches == 0 ? "no matches for " + regex : out.toString();
}
}
/** A unified diff with 3 lines of context, from a longest-common-subsequence line diff. */
static String unifiedDiff(String path, String before, String after) {
List<String> a = before.lines().toList(), b = after.lines().toList();
int[][] lcs = new int[a.size() + 1][b.size() + 1];
for (int i = a.size() - 1; i >= 0; i--)
for (int j = b.size() - 1; j >= 0; j--)
lcs[i][j] = a.get(i).equals(b.get(j)) ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
List<String> ops = new ArrayList<>();
for (int i = 0, j = 0; i < a.size() || j < b.size(); ) {
if (i < a.size() && j < b.size() && a.get(i).equals(b.get(j))) { ops.add(" " + a.get(i)); i++; j++; }
else if (i < a.size() && (j == b.size() || lcs[i + 1][j] >= lcs[i][j + 1])) ops.add("-" + a.get(i++));
else ops.add("+" + b.get(j++));
}
StringBuilder sb = new StringBuilder("--- a/" + path + "\n+++ b/" + path + "\n");
for (int k = 0; k < ops.size(); ) {
if (ops.get(k).charAt(0) == ' ') { k++; continue; }
int start = Math.max(0, k - 3), end = k;
while (true) {
int next = end;
while (next < ops.size() && ops.get(next).charAt(0) == ' ') next++;
if (next == ops.size() || next - end > 6) { end = Math.min(ops.size(), end + 3); break; }
while (next < ops.size() && ops.get(next).charAt(0) != ' ') next++;
end = next;
}
int oldStart = 1, newStart = 1, oldLen = 0, newLen = 0;
for (int x = 0; x < start; x++) { if (ops.get(x).charAt(0) != '+') oldStart++; if (ops.get(x).charAt(0) != '-') newStart++; }
for (int x = start; x < end; x++) { if (ops.get(x).charAt(0) != '+') oldLen++; if (ops.get(x).charAt(0) != '-') newLen++; }
sb.append("@@ -").append(oldLen == 0 ? oldStart - 1 : oldStart).append(',').append(oldLen)
.append(" +").append(newLen == 0 ? newStart - 1 : newStart).append(',').append(newLen).append(" @@\n");
for (int x = start; x < end; x++) sb.append(ops.get(x)).append('\n');
k = end;
}
return sb.toString();
}
// ---------------- end of Project 22 ----------------
static ToolCall call(String id, String name, String json) { return new ToolCall(id, name, json); }
public static void main(String[] args) throws Exception {
Path root = Files.createTempDirectory("p22");
Files.createDirectories(root.resolve("src/main/java/demo"));
Files.writeString(root.resolve("pom.xml"), "<project><artifactId>demo</artifactId></project>\n");
Files.writeString(root.resolve("src/main/java/demo/Greeter.java"), """
package demo;
public class Greeter {
static String greet(String name) {
return "Hello " + name;
}
}
""");
Workspace ws = new Workspace(root);
List<String> diffs = new ArrayList<>();
ToolRegistry registry = new ToolRegistry(Duration.ofSeconds(10), 4000);
new FileTools(ws, diffs::add).tools().forEach(registry::register);
String file = "src/main/java/demo/Greeter.java";
ScriptedModel model = new ScriptedModel(
AssistantMessage.calls(call("c1", "glob", "{\"pattern\": \"**/*.java\"}")),
AssistantMessage.calls(call("c2", "read_file", "{\"path\": \"" + file + "\"}")),
AssistantMessage.calls(call("c3", "edit_file", "{\"path\": \"" + file + "\", \"oldString\": \"\\treturn \\\"Hello \\\" + name;\", \"newString\": \"\\treturn \\\"Hello, \\\" + name + \\\".\\\";\"}")),
AssistantMessage.calls(call("c4", "edit_file", "{\"path\": \"" + file + "\", \"oldString\": \" return \\\"Hello \\\" + name;\", \"newString\": \" return \\\"Hello, \\\" + name + \\\".\\\";\"}")),
AssistantMessage.calls(call("c5", "write_file", "{\"path\": \"src/test/java/demo/GreeterTest.java\", \"content\": \"package demo;\\n\\nclass GreeterTest {\\n // expects Hello, Ada.\\n}\\n\"}")),
AssistantMessage.calls(call("c6", "grep", "{\"pattern\": \"greet\\\\(\"}")),
AssistantMessage.text("Greeter.greet now returns \"Hello, NAME.\" and a test file was added."));
Agent agent = new Agent(model, registry, new AgentConfig("You are a coding agent.", 10, 100_000));
agent.addListener(new AgentListener() {
public void onToolCall(ToolCall c) { System.out.println("> " + c.name() + " " + c.arguments().replace("\n", "\\n").replace("\t", "\\t")); }
public void onToolResult(ToolMessage r) {
r.content().lines().limit(8).forEach(l -> System.out.println((r.isError() ? " ! " : " ") + l));
}
public void onStop(AgentResult r) { System.out.println("[" + r.stopReason() + " after " + r.steps() + " steps] " + r.finalText()); }
});
agent.run("Make Greeter.greet return \"Hello, NAME.\" and add a test file.");
System.out.println("--- diffs shown to the user");
diffs.forEach(System.out::print);
try (var s = Files.walk(root)) { for (Path p : s.sorted(Comparator.reverseOrder()).toList()) Files.delete(p); }
}
}> glob {"pattern": "**/*.java"}
src/main/java/demo/Greeter.java
> read_file {"path": "src/main/java/demo/Greeter.java"}
1| package demo;
2|
3| public class Greeter {
4| static String greet(String name) {
5| return "Hello " + name;
6| }
7| }
> edit_file {"path": "src/main/java/demo/Greeter.java", "oldString": "\treturn \"Hello \" + name;", "newString": "\treturn \"Hello, \" + name + \".\";"}
! IllegalArgumentException: oldString not found in src/main/java/demo/Greeter.java; line 5 matches except for whitespace (the file indents it with 8 spaces, oldString with 1 tab)
> edit_file {"path": "src/main/java/demo/Greeter.java", "oldString": " return \"Hello \" + name;", "newString": " return \"Hello, \" + name + \".\";"}
edited src/main/java/demo/Greeter.java: 1 replacement, first at line 5
> write_file {"path": "src/test/java/demo/GreeterTest.java", "content": "package demo;\n\nclass GreeterTest {\n // expects Hello, Ada.\n}\n"}
created src/test/java/demo/GreeterTest.java (5 lines)
> grep {"pattern": "greet\\("}
src/main/java/demo/Greeter.java:4: static String greet(String name) {
[COMPLETED after 7 steps] Greeter.greet now returns "Hello, NAME." and a test file was added.
--- diffs shown to the user
--- a/src/main/java/demo/Greeter.java
+++ b/src/main/java/demo/Greeter.java
@@ -2,6 +2,6 @@
public class Greeter {
static String greet(String name) {
- return "Hello " + name;
+ return "Hello, " + name + ".";
}
}
--- a/src/test/java/demo/GreeterTest.java
+++ b/src/test/java/demo/GreeterTest.java
@@ -0,0 +1,5 @@
+package demo;
+
+class GreeterTest {
+ // expects Hello, Ada.
+}Each > line is a tool call as the model sent it, and the indented lines below it are the result the model received (! marks errors). The listener prints at most 8 lines of each result. The run goes through six tool calls, in order:
globwith**/*.javareturned the one Java file. The file is insrc/main/java/demo, so the pattern's required directory is present.read_filereturned the seven numbered lines and recorded the file's hash for the read-before-edit check. The file is shorter than the default limit of 200 lines, so there is no footer.- The first
edit_fileused a tab beforereturn. The registry validated the arguments against the schema generated fromEditArgs, the tool found no match, and the error points at line 5, which matches except for whitespace: the file indents it with 8 spaces, theoldStringwith 1 tab. The message names the exact difference in plain words, so the model can fix it in its next call. - The second
edit_fileused 8 spaces and succeeded: one replacement, at line 5. Because the tracker stored the hash after the read and after the edit, no extra read was needed. write_filecreatedsrc/test/java/demo/GreeterTest.javawith 5 lines. The directories did not exist and were created.grepforgreet\(found the method declaration on line 4. The test file has no call togreet(. Its comment mentionsHello, Ada., which does not match.
The run stopped with COMPLETED after 7 steps: six tool calls and a final text answer. Each step is one request to the model, and the seventh reply had no tool call, which ends the loop.
The diff section is what the user sees. The edit produced one hunk with the replaced return line between three context lines on each side. The hunk starts at line 2 because the change is on line 5, and three lines of context begin at line 2. The new file produced a hunk @@ -0,0 +1,5 @@. An empty old side starts at line 0 by convention, and every line is an addition.
Two things in the transcript come from Project 21, not from the file tools. The edit error arrived as IllegalArgumentException: ..., because the registry turns exceptions into error results with the exception's class name. And all six tools came from Tool.of with records, so their schemas (for example oldString, newString, and the optional replaceAll) were generated, and the arguments were validated before any file was touched.
The FileTools API that later lessons use is new FileTools(workspace, diffConsumer).tools(), which returns read_file, write_file, edit_file, list_files, glob, and grep. The read, list, glob, and grep tools are marked read-only, so the agent runs several of them in parallel when the model asks for several at once, and a permission layer can allow them without asking the user. The Shell Tool adds a tool for commands to the same registry, and Workshop: loop agent wires both into the loop agent command.
Interview points
- A workspace root keeps the agent inside the project. Resolve every path the model sends against a root stored as a real path, normalize it, compare with
Path.startsWith, and check the real path of the deepest existing part to catch symbolic links. A string prefix check is wrong because it accepts sibling directories such asdemo-secretsfor the rootdemo. - A read tool returns numbered lines with an offset and a limit, and a footer with the total length and the next offset. It caps the length of each line, and it refuses binary files (a NUL byte in the first 8,000 bytes, the rule git uses) and invalid UTF-8.
- A write tool writes through a temporary file in the same directory and then renames it atomically, so a crash never leaves half a file. It restores the original file mode, because temporary files are created with mode
rw-------. - Exact-string edits fail safely. Zero matches and multiple matches are both errors. The message lists the matching lines, or the closest lines, and names whitespace differences, so the model knows how to fix the next attempt.
- The tool normalizes CRLF line endings and a BOM before matching and restores them on write, so an edit changes only the intended lines.
- The tool records a content hash for each file when the model reads it. It refuses edits to files that were never read or that changed after the read.
- Listing, glob, and grep skip
.git, dependency, and build directories, sort their output so it is stable, and cap results with a marker that tells the model something was left out. - Java's
**/*.javaglob requires a directory before the file name, and*never crosses/. Other glob tools behave differently, so a tool description should show an example pattern. - A unified diff (hunks with
@@ -a,b +c,d @@headers and three context lines) shows the user each change. The model gets a short confirmation instead, which costs fewer tokens. - Tool errors are written for the model: what was wrong, where, and what to send instead.
Summary
- A workspace root stored as a real path confines the file tools.
normalize,Path.startsWith, andtoRealPathon the deepest existing part reject.., absolute paths, symbolic-link escapes, and dangling links. read_filenumbers lines (1|), takesoffsetandlimit, and reports the total and the next offset when output is cut.- Large files are streamed. Lines over 120 characters (500 in Project 22) are cut with their length shown, NUL bytes mark binary files, and strict UTF-8 decoding reports other encodings.
write_filecreates parent directories, refuses to replace unread files, skips identical content, and replaces files atomically withATOMIC_MOVEwhile keeping the file mode.edit_filereplaces one exact occurrence ofoldString, or all of them withreplaceAll. It rejects empty, identical, missing, and ambiguous strings.- Not-found errors show the closest lines, detect whitespace-only differences, and use edit-distance similarity with a threshold.
- BOM and CRLF are removed before matching and restored on write. Files that are not UTF-8 are refused and left unchanged.
- A SHA-256 hash recorded at read time detects files changed outside the agent. The tool updates the hash after its own edits.
list_filesprints a sorted tree with depth and entry limits and skips.git,node_modules,target, andbuild.globusesPathMatcheron relative paths.grepusesjava.util.regexwith line numbers, a match limit, and skipped binary files.- The diff from a longest-common-subsequence line diff with three context lines matches the hunks of GNU
diff -u. - Project 22's
FileToolsregisters six tools built withTool.ofand argument records, and sends unified diffs of every change to a consumer for the user.
