Lesson 63 of 73
Workshop: loop agent
A coding agent is a program that gives a language model a task, such as "make this failing test pass", and then lets the model act. The model cannot touch your files by itself. It can only produce text. The agent is the Java program around the model: it sends the conversation to the model, notices when the model asks to read a file or run a command, does that work, and sends the result back. This repeats until the model says it is finished.
The previous lessons of this section built the parts of such an agent one at a time. This workshop puts them together as loop agent, Project 26. The finished command takes a task, talks to any OpenAI-compatible server (a server that accepts the same HTTP requests as OpenAI's chat API, such as llama-server), reads and edits files, runs commands, asks before risky actions, keeps a session on disk, and can undo its own changes.
After this lesson you can run the agent on a real repository and read every line it prints. You will also know which class to change when you want to alter one behavior, for example how it asks for approval or where it stores sessions.
The table lists the parts and the lesson each one comes from. Think of it as a parts list: each row is a piece you already built, and this lesson connects them.
| Part | Comes from | Role in loop agent |
|---|---|---|
ChatClient mapping |
Project 11, The Chat Completions Protocol and Streaming in Java | Sends the history and tool definitions to the engine |
Agent, messages, Tool, registry |
Project 21, The Agent Loop and a Tool Framework | The loop: call the model, run tools, repeat |
read_file, write_file, edit_file |
Project 22, File Tools | Workspace-confined file access |
shell |
Project 23, The Shell Tool | Commands with timeouts and clean environments |
| Permission policy | Project 25, Permissions, Sandboxing, and Prompt Injection | Allow, ask, or deny each call |
ContextManager |
Context Engineering for Coding Agents | Keeps the history inside the context window |
picocli loop skeleton |
Project 8, picocli Basics | The agent subcommand and its options |
Each example is a complete program. Classes from earlier lessons are restated in compact form, with only the members the example needs, and the text names the lesson each one comes from. Every model in this lesson is scripted, which means it is a list of canned assistant messages that the program replays in order. The one exception is Example 13, which shows a real server. Scripting the model keeps the output identical on every run, so you can compare what you see with what is printed here.
Example 1: the layers of one agent turn
Before reading real code, it helps to see the shape of the whole system. loop agent has five layers:
- The client sends the conversation to the engine (the program that runs the model) over HTTP and reads the reply.
- The agent loop decides what happens next: ask the model, run the tools it requested, ask again, or stop.
- The permission policy decides for each tool call whether it may run: allow it, ask the person first, or deny it.
- The tools do the real work, such as reading a file or running a command.
- The context is the list of messages so far. The model sees this list on every request, because it keeps no memory between requests.
This example wires minimal stubs (tiny stand-ins) for all five layers together and traces one task through them. The model is a script of three replies. The point is the order of calls, which stays the same when the stubs are replaced by the real classes. Watch the trace lines in the output: each one is tagged with the layer that produced it.
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
public class LoopLayers {
record ToolCall(String id, String name, String arguments) {}
record Reply(String text, List<ToolCall> calls) {}
interface ModelClient { Reply chat(List<String> context); }
interface Tool { String run(String arguments); }
enum Decision { ALLOW, ASK, DENY }
public static void main(String[] args) {
List<String> trace = new ArrayList<>();
List<Reply> script = List.of(
new Reply("", List.of(new ToolCall("c1", "read_file", "{\"path\":\"README.md\"}"))),
new Reply("", List.of(new ToolCall("c2", "shell", "{\"command\":\"rm -rf /\"}"))),
new Reply("This project counts words in text files.", List.of()));
int[] turn = {0};
ModelClient client = ctx -> { // layer 1: the client
trace.add("client POST /v1/chat/completions, " + ctx.size() + " messages");
return script.get(turn[0]++);
};
Map<String, Tool> tools = Map.of( // layer 4: the tools
"read_file", a -> "# wc\nCounts words in text files.",
"shell", a -> "(never runs)");
Function<ToolCall, Decision> policy = c -> // layer 3: permissions
c.name().equals("read_file") ? Decision.ALLOW : c.arguments().contains("rm -rf /") ? Decision.DENY : Decision.ASK;
List<String> context = new ArrayList<>(List.of("system: You are loop.", "user: What does this project do?")); // layer 5
for (int step = 1; step <= 10; step++) { // layer 2: the agent loop
Reply r = client.chat(List.copyOf(context));
context.add("assistant: " + (r.calls().isEmpty() ? r.text() : r.calls()));
if (r.calls().isEmpty()) { trace.add("agent step " + step + ": final answer, stop"); break; }
for (ToolCall c : r.calls()) {
Decision d = policy.apply(c);
trace.add("policy " + c.name() + " " + c.arguments() + " -> " + d);
String result = d == Decision.DENY ? "Error: denied by policy" : tools.get(c.name()).run(c.arguments());
trace.add("tool " + c.id() + " -> " + result.lines().findFirst().orElse(""));
context.add("tool " + c.id() + ": " + result);
trace.add("context now " + context.size() + " messages");
}
}
trace.forEach(System.out::println);
}
}client POST /v1/chat/completions, 2 messages
policy read_file {"path":"README.md"} -> ALLOW
tool c1 -> # wc
context now 4 messages
client POST /v1/chat/completions, 4 messages
policy shell {"command":"rm -rf /"} -> DENY
tool c2 -> Error: denied by policy
context now 6 messages
client POST /v1/chat/completions, 6 messages
agent step 3: final answer, stopOne agent turn, drawn as a diagram, looks like this:
context --> client --> engine --> reply
|
tool calls? ---+--- no: final answer, stop
| yes
v
policy: ALLOW / ASK / DENY
|
v
tool runs (or denial text)
|
v
result appended to context, back to the clientThe trace reads from top to bottom as the program ran:
- The first
clientline sends the two starting messages (system and user). The scripted model answers with aread_filecall. policyallowsread_filewithout asking, because reading inside the workspace changes nothing. The tool returns the README, and the context grows to 4 messages: the assistant's call and the tool's result were both appended.- The second request carries 4 messages. The model now asks to run
rm -rf /. The policy denies it without running anything, and the denial goes into the context as a tool result, so the model learns that the call failed and why. The tool's own code ((never runs)) is never reached. - The third request carries 6 messages, and the model answers in text. A reply without tool calls ends the loop.
The rest of the lesson keeps this shape and replaces each stub with the class from its lesson. Two rules from the trace stay fixed:
- The policy sits between the model's request and the tool, so no tool runs without a decision.
- Every tool call gets a result message, even when it is denied. Chat APIs reject a history in which a tool call has no answer, so the denial text keeps the history valid for the next request.
Example 2: the client behind the agent's ModelClient interface
The agent loop of Project 21 does not know about HTTP. It calls a ModelClient interface and passes its own Java records: SystemMessage, UserMessage, AssistantMessage, and ToolMessage. The engine, on the other hand, speaks chat completions JSON, where each message is an object with a role and some fields. Something has to translate between the two. That translator is an adapter: a class that implements one interface by converting calls to another format. Here the adapter turns each Java message into a JSON message, sends the list, and turns the JSON response back into an AssistantMessage. It uses the field names of Project 11.
This example runs the adapter against a fake server. The fake replays two real responses captured from llama-server at the pinned llama.cpp commit. The model behind them is the course's tiny random-weight model, called with the grammar-constrained tool-call request from Function Calling from First Principles. Fields that do not matter here (the envelope) were removed. Because the responses are real, the odd whitespace and the 32-character call ids are exactly what a real engine produces. In the output, look at the token counts and at the sent lines, which show how the second request was built.
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 com.sun.net.httpserver.HttpServer;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class ClientAdapter {
static final ObjectMapper JSON = new ObjectMapper();
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 {}
record ToolMessage(String callId, String name, String content, boolean isError) implements Message {}
record ToolSpec(String name, String description, JsonNode parameters) {}
record ModelResponse(AssistantMessage message, int promptTokens, int completionTokens) {}
interface ModelClient { ModelResponse chat(List<Message> messages, List<ToolSpec> tools) throws Exception; }
static final class OpenAiModel implements ModelClient {
private final HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
private final URI endpoint;
private final String model;
OpenAiModel(String baseUrl, String model) { this.endpoint = URI.create(baseUrl + "/chat/completions"); this.model = model; }
static ObjectNode toJson(Message m) {
ObjectNode n = JSON.createObjectNode();
switch (m) {
case SystemMessage s -> n.put("role", "system").put("content", s.text());
case UserMessage u -> n.put("role", "user").put("content", u.text());
case AssistantMessage a -> {
n.put("role", "assistant").put("content", a.text());
for (ToolCall c : a.toolCalls())
n.withArray("tool_calls").addObject().put("id", c.id()).put("type", "function")
.putObject("function").put("name", c.name()).put("arguments", c.arguments());
}
case ToolMessage t -> n.put("role", "tool").put("tool_call_id", t.callId()).put("content", t.content());
}
return n;
}
public ModelResponse chat(List<Message> messages, List<ToolSpec> tools) throws Exception {
ObjectNode body = JSON.createObjectNode().put("model", model).put("temperature", 0.0);
ArrayNode ms = body.putArray("messages");
messages.forEach(m -> ms.add(toJson(m)));
for (ToolSpec t : tools)
body.withArray("tools").addObject().put("type", "function").putObject("function")
.put("name", t.name()).put("description", t.description()).set("parameters", t.parameters());
HttpResponse<String> res = http.send(HttpRequest.newBuilder(endpoint).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
JsonNode root = JSON.readTree(res.body()), msg = root.at("/choices/0/message");
List<ToolCall> calls = new ArrayList<>();
for (JsonNode c : msg.path("tool_calls"))
calls.add(new ToolCall(c.path("id").asText(), c.at("/function/name").asText(), c.at("/function/arguments").asText()));
return new ModelResponse(new AssistantMessage(msg.path("content").asText(""), calls),
root.at("/usage/prompt_tokens").asInt(), root.at("/usage/completion_tokens").asInt());
}
}
public static void main(String[] args) throws Exception {
List<String> captured = List.of( // real llama-server responses, tiny random-weight model
"{\"choices\":[{\"finish_reason\":\"tool_calls\",\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\n \\t \\t \\\"path\\\":\\\"WordCount.java\\\"\\n\\n \\t \\t }\"},\"id\":\"U8mRpb5tHLA0IfbgXWkHI36e0l7Up0wf\"}]}}],\"usage\":{\"completion_tokens\":73,\"prompt_tokens\":273,\"total_tokens\":346}}",
"{\"choices\":[{\"finish_reason\":\"tool_calls\",\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\n \\t\\t\\t\\t\\t \\\"path\\\":\\\"WordCount.java\\\"\\n\\n \\t\\t\\t\\t\\t \\t\\t\\t\\t\\t}\"},\"id\":\"ZxnLeZpu0SGurA6H1wFzd5S3neLPflEu\"}]}}],\"usage\":{\"completion_tokens\":96,\"prompt_tokens\":339,\"total_tokens\":435}}");
List<String> requests = new ArrayList<>();
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/v1/chat/completions", ex -> {
requests.add(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
byte[] b = captured.get(requests.size() - 1).getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(200, b.length);
ex.getResponseBody().write(b);
ex.close();
});
server.start();
try {
ModelClient client = new OpenAiModel("http://127.0.0.1:" + server.getAddress().getPort() + "/v1", "qwen-coder");
List<ToolSpec> tools = List.of(new ToolSpec("read_file", "Read a file in the workspace.",
JSON.readTree("{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}")));
List<Message> history = new ArrayList<>(List.of(new SystemMessage("You are loop, a coding agent."), new UserMessage("Make WordCountTest pass.")));
ModelResponse r1 = client.chat(history, tools);
ToolCall call = r1.message().toolCalls().get(0);
System.out.println("1: " + call.name() + " id=" + call.id() + " args=" + JSON.readTree(call.arguments())
+ " tokens=" + r1.promptTokens() + "+" + r1.completionTokens());
history.add(r1.message());
history.add(new ToolMessage(call.id(), call.name(), "public class WordCount { }", false));
ModelResponse r2 = client.chat(history, tools);
System.out.println("2: " + r2.message().toolCalls().get(0).name() + " tokens=" + r2.promptTokens() + "+" + r2.completionTokens());
JsonNode second = JSON.readTree(requests.get(1));
for (JsonNode m : second.get("messages"))
System.out.println(" sent " + m.get("role").asText() + (m.has("tool_calls") ? " with tool_calls[0].id=" + m.at("/tool_calls/0/id").asText() : "")
+ (m.has("tool_call_id") ? " tool_call_id=" + m.get("tool_call_id").asText() : ""));
System.out.println(" tools: " + second.at("/tools/0/function/name").asText() + ", model: " + second.get("model").asText());
} finally {
server.stop(0);
}
}
}1: read_file id=U8mRpb5tHLA0IfbgXWkHI36e0l7Up0wf args={"path":"WordCount.java"} tokens=273+73
2: read_file tokens=339+96
sent system
sent user
sent assistant with tool_calls[0].id=U8mRpb5tHLA0IfbgXWkHI36e0l7Up0wf
sent tool tool_call_id=U8mRpb5tHLA0IfbgXWkHI36e0l7Up0wf
tools: read_file, model: qwen-coderThe output shows both directions of the mapping. A short reminder of two terms first. A token is a small piece of text (a word or part of a word) that the model reads and writes, and the server counts tokens in usage. The KV cache (key-value cache) is memory in which the engine keeps the work it already did for earlier tokens, so it does not redo that work for a prompt that starts the same way.
- Line
1:is the first real response. The adapter read the call'sname, its 32-characterid(generated by llama-server), and itsargumentsstring. The random model put tabs and newlines between the JSON tokens, which the grammar allows;JSON.readTreeparses them, and printing the tree shows the compact form{"path":"WordCount.java"}. The token counts are the realusagevalues: 273 prompt tokens and 73 completion tokens. - Line
2:is the second real response, after the history grew by the assistant message and its tool result. The prompt grew from 273 to 339 tokens. When this capture was taken, llama-server reportedcache_n273 for this request in itstimingsobject: the whole previous prompt was reused from the KV cache, because the history only grew at the end (Context Engineering for Coding Agents explains why the layout keeps this true). - The
sentlines list the four messages of the second request. The assistant message carriestool_callswith the id from the first response, and the tool message carries the same id astool_call_id. This pairing is the rule from Function Calling: every call id in an assistant message must be answered by exactly one tool message. tools:andmodel:confirm the rest of the request body.
The adapter uses Project 11's non-streaming chat mapping because it is shorter: it waits for the whole reply and parses it once. In interactive mode, loop agent uses stream with a listener instead, so the user sees text as it is generated. When streaming, the tool calls arrive in small fragments, and the client collects them by their index field exactly as Project 11 does. Both paths return the same AssistantMessage, so the agent loop does not know which one ran.
Example 3: the loop agent command and its options
A command-line program needs a clear way for the user to say what to do. Project 8 left agent as an empty stub subcommand of loop. This example fills it in with picocli, a Java library that turns annotated fields into command-line options, parsing, error messages, and a help page.
Each option is a field with an @Option annotation. The options cover the task, the workspace, the server and model, the approval mode, limits, the output mode, and session resume. The program prints the generated help page and then parses two command lines: one valid, and one with a mistyped approval mode. Look at how the help page mirrors the annotations, and at the exit code of each run.
import java.io.PrintWriter;
import java.nio.file.Path;
import java.util.concurrent.Callable;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
@Command(name = "agent", mixinStandardHelpOptions = true, sortOptions = false,
description = "Run the coding agent on a task in a workspace.")
public class AgentCommand implements Callable<Integer> {
enum Approval { ASK, EDITS, ALL }
@Parameters(arity = "0..1", paramLabel = "TASK", description = "The task. Without it, an interactive session starts.") String task;
@Option(names = {"-C", "--workspace"}, defaultValue = ".", paramLabel = "DIR", description = "Workspace root (default: ${DEFAULT-VALUE}).") Path workspace;
@Option(names = "--base-url", defaultValue = "http://127.0.0.1:8080/v1", paramLabel = "URL", description = "OpenAI-compatible endpoint.") String baseUrl;
@Option(names = {"-m", "--model"}, defaultValue = "default", description = "Model name sent to the server.") String model;
@Option(names = "--approval", defaultValue = "EDITS", paramLabel = "MODE", description = "ask, edits, or all (default: ${DEFAULT-VALUE}).") Approval approval;
@Option(names = "--max-steps", defaultValue = "30", paramLabel = "N", description = "Model calls before the run stops.") int maxSteps;
@Option(names = "--ctx-size", defaultValue = "16384", paramLabel = "TOKENS", description = "Context window of the server.") int ctxSize;
@Option(names = "--json", description = "Headless: print events as JSON lines, never ask.") boolean json;
@Option(names = "--resume", paramLabel = "SESSION", description = "Continue a saved session.") String resume;
@Option(names = "--no-checkpoints", negatable = true, description = "Snapshot the workspace before changes (default: on).") boolean checkpoints = true;
@Override public Integer call() {
System.out.printf("task=%s workspace=%s base-url=%s model=%s approval=%s max-steps=%d ctx-size=%d json=%s resume=%s checkpoints=%s%n",
task, workspace, baseUrl, model, approval, maxSteps, ctxSize, json, resume, checkpoints);
return 0;
}
public static void main(String[] args) {
String[][] runs = {
{"--help"},
{"-C", "demo", "-m", "qwen2.5-coder-7b", "--approval", "ask", "--json", "Fix the failing test"},
{"--approval", "yolo", "Fix it"}};
for (String[] a : runs) {
CommandLine cl = new CommandLine(new AgentCommand()).setCaseInsensitiveEnumValuesAllowed(true);
cl.setErr(new PrintWriter(System.out, true));
System.out.println("$ loop agent " + String.join(" ", a));
System.out.println("exit code " + cl.execute(a));
}
}
}$ loop agent --help
Usage: agent [-hV] [--json] [--[no-]checkpoints] [--approval=MODE]
[--base-url=URL] [-C=DIR] [--ctx-size=TOKENS] [-m=<model>]
[--max-steps=N] [--resume=SESSION] [TASK]
Run the coding agent on a task in a workspace.
[TASK] The task. Without it, an interactive session starts.
-C, --workspace=DIR Workspace root (default: .).
--base-url=URL OpenAI-compatible endpoint.
-m, --model=<model> Model name sent to the server.
--approval=MODE ask, edits, or all (default: EDITS).
--max-steps=N Model calls before the run stops.
--ctx-size=TOKENS Context window of the server.
--json Headless: print events as JSON lines, never ask.
--resume=SESSION Continue a saved session.
--[no-]checkpoints Snapshot the workspace before changes (default: on).
-h, --help Show this help message and exit.
-V, --version Print version information and exit.
exit code 0
$ loop agent -C demo -m qwen2.5-coder-7b --approval ask --json Fix the failing test
task=Fix the failing test workspace=demo base-url=http://127.0.0.1:8080/v1 model=qwen2.5-coder-7b approval=ASK max-steps=30 ctx-size=16384 json=true resume=null checkpoints=true
exit code 0
$ loop agent --approval yolo Fix it
Invalid value for option '--approval': expected one of [ASK, EDITS, ALL] (case-insensitive) but was 'yolo'
Usage: agent [-hV] [--json] [--[no-]checkpoints] [--approval=MODE]
[--base-url=URL] [-C=DIR] [--ctx-size=TOKENS] [-m=<model>]
[--max-steps=N] [--resume=SESSION] [TASK]
Run the coding agent on a task in a workspace.
[TASK] The task. Without it, an interactive session starts.
-C, --workspace=DIR Workspace root (default: .).
--base-url=URL OpenAI-compatible endpoint.
-m, --model=<model> Model name sent to the server.
--approval=MODE ask, edits, or all (default: EDITS).
--max-steps=N Model calls before the run stops.
--ctx-size=TOKENS Context window of the server.
--json Headless: print events as JSON lines, never ask.
--resume=SESSION Continue a saved session.
--[no-]checkpoints Snapshot the workspace before changes (default: on).
-h, --help Show this help message and exit.
-V, --version Print version information and exit.
exit code 2No help text was written by hand. picocli generated it from the annotations, in declaration order because of sortOptions = false. Here is what each option means:
TASKis optional (arity = "0..1"). With a task, the agent runs it and exits; without one,loop agentstarts an interactive session that reads tasks from the terminal, likeloop chatin Workshop: loop serve, run, and chat.--workspacedefaults to the current directory. Every file tool confines paths to it, and the shell tool starts there.--base-urland--modelchoose the engine. The default URL isllama-server's default port;loop servefrom Project 12 prints the URL of the server it starts.--approvalselects one of three modes. This is the main safety control, so the details matter:askasks before every change and command.edits(the default) applies file edits inside the workspace without asking and asks before shell commands, because a shell command can do almost anything.allasks for nothing, which is meant for a sandbox or a throw-away container.- Deny rules apply in every mode, so even
allrefuses the commands the policy forbids.
--max-stepsand--ctx-sizeare the limits of Project 21 and the context manager. A step is one call to the model, so--max-stepsstops a model that loops forever. The context window is the maximum number of tokens the model can read in one request.--ctx-sizemust match the server's-cvalue divided by its slot count (a slot is one conversation the server can hold at a time), because the context manager plans how much history to keep against this number.--jsonswitches to headless mode (Example 9),--resumecontinues a saved session (Example 7), and--[no-]checkpointsturns the git snapshots of Example 8 on or off. picocli generated the--[no-]form fromnegatable = true.
The first run printed the help page and returned exit code 0, which means success. The second run parsed every option. approval=ASK shows that setCaseInsensitiveEnumValuesAllowed(true) accepted the lowercase ask for the enum constant ASK. The relative workspace demo is kept as given; the command resolves it with toRealPath when it starts.
The third run used yolo, which is not a mode. picocli printed the error with the valid values and the usage, and returned exit code 2, its code for invalid input. A misspelled approval mode must fail, not fall back to a default, because the fallback could be more permissive than the user intended.
Example 4: terminal rendering of steps and tool calls
While the agent works, a person watches the terminal. Showing every raw message would be unreadable: tool arguments are JSON, and results can be hundreds of lines. So the agent does not print anything itself. It reports what happens through the listener interface of Project 21, which has four methods: onAssistant, onToolCall, onToolResult, and onStop. A listener is an object that gets called when something happens. The terminal renderer is one implementation of that interface, and a JSON printer (Example 9) is another.
The renderer prints one line per tool call with the argument a person needs to see (the path or the command), a short summary of each result, and the final answer. This example feeds a fixed sequence of events to the renderer, once without color and once with ANSI color codes made visible. Compare the two outputs: the text is the same, only the escape codes differ.
import java.util.List;
public class Renderer {
record ToolCall(String id, String name, String arguments) {}
record AssistantMessage(String text, List<ToolCall> toolCalls) {}
record ToolMessage(String callId, String name, String content, boolean isError) {}
record AgentResult(String stopReason, String finalText, int steps, int tokens) {}
interface AgentListener {
default void onAssistant(AssistantMessage m) {}
default void onToolCall(ToolCall c) {}
default void onToolResult(ToolMessage r) {}
default void onStop(AgentResult r) {}
}
static final class TerminalRenderer implements AgentListener {
private final boolean color;
private int step;
TerminalRenderer(boolean color) { this.color = color; }
private String paint(String code, String s) { return color ? "\u001b[" + code + "m" + s + "\u001b[0m" : s; }
static String subject(ToolCall c) { // the one argument a person needs to see
String a = c.arguments();
for (String key : List.of("\"path\":\"", "\"command\":\"", "\"pattern\":\"")) {
int i = a.indexOf(key);
if (i >= 0) return a.substring(i + key.length(), a.indexOf('"', i + key.length()));
}
return a;
}
public void onAssistant(AssistantMessage m) {
step++;
if (!m.text().isBlank() && !m.toolCalls().isEmpty()) System.out.println(paint("2", " " + m.text()));
}
public void onToolCall(ToolCall c) { System.out.println(paint("1", "[" + step + "] " + c.name()) + " " + subject(c)); }
public void onToolResult(ToolMessage r) {
List<String> lines = r.content().lines().toList();
String summary = lines.size() == 1 ? lines.get(0) : lines.size() + " lines, last: " + lines.get(lines.size() - 1);
System.out.println(" " + (r.isError() ? paint("31", "error: " + summary) : paint("32", "ok: " + summary)));
}
public void onStop(AgentResult r) {
System.out.println(r.finalText());
System.out.println(paint("2", "(" + r.stopReason() + ", " + r.steps() + " steps, " + r.tokens() + " tokens)"));
}
}
static void replay(AgentListener l) {
l.onAssistant(new AssistantMessage("I will look at the test first.", List.of(new ToolCall("c1", "read_file", "{\"path\":\"WordCountTest.java\"}"))));
l.onToolCall(new ToolCall("c1", "read_file", "{\"path\":\"WordCountTest.java\"}"));
l.onToolResult(new ToolMessage("c1", "read_file", " 1 public class WordCountTest {\n ...\n 14 }", false));
l.onAssistant(new AssistantMessage("", List.of(new ToolCall("c2", "shell", "{\"command\":\"javac -d out *.java && java -cp out WordCountTest\"}"))));
l.onToolCall(new ToolCall("c2", "shell", "{\"command\":\"javac -d out *.java && java -cp out WordCountTest\"}"));
l.onToolResult(new ToolMessage("c2", "shell", "Exit code: 1\nFAIL count(\"\") = 1, expected 0\n1 of 3 checks failed", true));
l.onAssistant(new AssistantMessage("The empty string case is fixed.", List.of()));
l.onStop(new AgentResult("COMPLETED", "The empty string case is fixed.", 3, 2180));
}
public static void main(String[] args) {
replay(new TerminalRenderer(false));
System.out.println("--- with color, ESC shown as \\e:");
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
java.io.PrintStream old = System.out;
System.setOut(new java.io.PrintStream(buf, true));
replay(new TerminalRenderer(true));
System.setOut(old);
buf.toString().lines().limit(3).forEach(l -> System.out.println(l.replace("\u001b", "\\e")));
}
} I will look at the test first.
[1] read_file WordCountTest.java
ok: 3 lines, last: 14 }
[2] shell javac -d out *.java && java -cp out WordCountTest
error: 3 lines, last: 1 of 3 checks failed
The empty string case is fixed.
(COMPLETED, 3 steps, 2180 tokens)
--- with color, ESC shown as \e:
\e[2m I will look at the test first.\e[0m
\e[1m[1] read_file\e[0m WordCountTest.java
\e[32mok: 3 lines, last: 14 }\e[0mThe plain rendering is what a terminal without color, a log file, or NO_COLOR=1 shows:
I will look at the test first.is text the model wrote together with a tool call. Many models explain their next action this way; the renderer shows it dimmed and indented, because it is commentary, not the answer.[1] read_file WordCountTest.javanames the step, the tool, and only the path. Printing the raw arguments JSON would bury the path in quotes and braces; forwrite_fileit would print the whole new file.ok: 3 lines, last: ...summarizes a multi-line result. The full text went to the model; the person sees enough to follow.[2] shell javac ...shows the whole command, because a command is what a person must be able to check. Its result is an error (exit code 1), so the summary starts witherror:and shows the last line, which for a test run is usually the verdict.- The last two lines come from
onStop: the final answer and the stop reason with the step and token counts.
The colored lines use ANSI escape sequences, codes that start with the escape character (shown as \e) and tell a terminal to change color: [1m is bold, [2m is dim, [31m red, [32m green, and [0m resets. Subcommands, Configuration, and Packaging showed how to decide when to use them (a terminal on standard output, no NO_COLOR variable). Errors are red and successes green, but the words error: and ok: stay, so the output keeps its meaning without color.
Example 5: rendering a diff for each file change
When the agent edits a file, a person who approves or reviews the edit needs to see what changed, not the whole new file. Imagine a 400-line file where one line changed: printing 400 lines hides the one line that matters.
A unified diff is the standard format for this. It shows removed lines with -, added lines with +, and a few unchanged lines around them for context, under a @@ header that gives the line ranges. Developers know it from git diff. File Tools built a diff for the user. This example restates a compact version of it and renders the change the agent makes in Example 12.
The compact version finds the lines two texts share by filling a longest-common-subsequence table (LCS): a grid where cell [i][j] holds how many lines the tails of the old and new text have in common. Walking the grid from the top left, a line is kept when both sides match, and otherwise it is marked removed or added. The program then groups nearby changes into hunks, which are blocks of changed lines plus their context. Look at the header, the context lines, and the summary line in the output.
import java.util.ArrayList;
import java.util.List;
public class DiffRender {
/** A unified diff of two texts with `context` unchanged lines around each change. */
static List<String> diff(String path, String before, String after, int context) {
String[] a = before.split("\n", -1), b = after.split("\n", -1);
int[][] lcs = new int[a.length + 1][b.length + 1];
for (int i = a.length - 1; i >= 0; i--)
for (int j = b.length - 1; j >= 0; j--)
lcs[i][j] = a[i].equals(b[j]) ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
List<String[]> ops = new ArrayList<>(); // {kind, text, lineA, lineB}
int i = 0, j = 0;
while (i < a.length || j < b.length) {
if (i < a.length && j < b.length && a[i].equals(b[j])) { ops.add(new String[] {" ", a[i], "" + i, "" + j}); i++; j++; }
else if (i < a.length && (j == b.length || lcs[i + 1][j] >= lcs[i][j + 1])) { ops.add(new String[] {"-", a[i], "" + i, "" + j}); i++; }
else { ops.add(new String[] {"+", b[j], "" + i, "" + j}); j++; }
}
List<String> out = new ArrayList<>(List.of("--- a/" + path, "+++ b/" + path));
for (int k = 0; k < ops.size(); ) {
if (ops.get(k)[0].equals(" ")) { k++; continue; }
int start = Math.max(0, k - context), end = k;
while (end < ops.size()) { // extend while changes are within 2*context lines
int next = end;
while (next < ops.size() && ops.get(next)[0].equals(" ")) next++;
if (next == ops.size() || next - end > 2 * context) break;
end = next + 1;
}
end = Math.min(ops.size(), end + context);
int lenA = 0, lenB = 0;
for (int m = start; m < end; m++) { if (!ops.get(m)[0].equals("+")) lenA++; if (!ops.get(m)[0].equals("-")) lenB++; }
out.add("@@ -" + (Integer.parseInt(ops.get(start)[2]) + 1) + "," + lenA + " +" + (Integer.parseInt(ops.get(start)[3]) + 1) + "," + lenB + " @@");
for (int m = start; m < end; m++) out.add(ops.get(m)[0] + ops.get(m)[1]);
k = end;
}
return out;
}
public static void main(String[] args) {
String before = """
public class WordCount {
static int count(String text) {
return text.split(" ").length;
}
}""";
String after = """
public class WordCount {
static int count(String text) {
String t = text.strip();
return t.isEmpty() ? 0 : t.split("\\\\s+").length;
}
}""";
diff("WordCount.java", before, after, 1).forEach(System.out::println);
long added = after.lines().count() - before.lines().count();
System.out.println("summary: WordCount.java, 1 line removed, 2 added (" + (added >= 0 ? "+" : "") + added + " net)");
}
}--- a/WordCount.java
+++ b/WordCount.java
@@ -2,3 +2,4 @@
static int count(String text) {
- return text.split(" ").length;
+ String t = text.strip();
+ return t.isEmpty() ? 0 : t.split("\\s+").length;
}
summary: WordCount.java, 1 line removed, 2 added (+1 net)The diff is what loop agent prints under an edit_file or write_file call:
--- a/WordCount.javaand+++ b/WordCount.javaname the file before and after, in the formatgit diffuses, so the output can be saved and applied withgit apply.@@ -2,3 +2,4 @@says the hunk covers 3 lines starting at line 2 of the old file and 4 lines starting at line 2 of the new file. One line was replaced by two, so the new range is one line longer.- The lines starting with a space are context: the method signature and the closing brace, one line on each side, because the example asked for
context = 1.git diffuses 3 lines of context by default. -marks the removedreturnline and+marks the two added lines. The regular expression appears as"\\s+"because that is the Java source text in the file.
The summary line is what an approval prompt shows for a change too long to print. In loop, the renderer prints the diff after a successful edit in edits and all modes, and before the edit, together with the question, in ask mode, so the person approves the exact change.
Example 6: sessions as JSON Lines
An agent run can take minutes, and the process can be interrupted by a crash, a closed laptop, or Ctrl+C. If the history lived only in memory, all of it would be lost. A session is the saved history of one agent conversation, kept on disk.
loop agent writes it as JSON Lines: a text file in which every line is one complete JSON object. Each message becomes one line the moment it is added, with a type field that names the record. Writing line by line, instead of saving the whole history at the end, means a crash or a Ctrl+C loses at most the line being written. This example writes a short session of five messages and prints the file. Look at the type field on each line and at the escaped quotes inside the tool call.
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;
public class SessionLog {
static final ObjectMapper JSON = new ObjectMapper();
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 {}
record ToolMessage(String callId, String name, String content, boolean isError) implements Message {}
static final class SessionWriter implements AutoCloseable {
private final BufferedWriter out;
SessionWriter(Path file) throws IOException {
out = Files.newBufferedWriter(file, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
void append(Message m) throws IOException {
ObjectNode line = JSON.createObjectNode().put("type", m.getClass().getSimpleName());
line.setAll((ObjectNode) JSON.valueToTree(m));
out.write(line.toString());
out.write('\n');
out.flush(); // one complete line on disk per message
}
@Override public void close() throws IOException { out.close(); }
}
public static void main(String[] args) throws Exception {
Path dir = Files.createTempDirectory("sessions");
Path file = dir.resolve("2026-10-03T10-15-00-wordcount.jsonl");
try (SessionWriter w = new SessionWriter(file)) {
w.append(new SystemMessage("You are loop, a coding agent."));
w.append(new UserMessage("Make WordCountTest pass."));
w.append(new AssistantMessage("", List.of(new ToolCall("call_1", "read_file", "{\"path\":\"WordCountTest.java\"}"))));
w.append(new ToolMessage("call_1", "read_file", " 1 public class WordCountTest {", false));
w.append(new AssistantMessage("The test checks three inputs.", List.of()));
}
List<String> lines = Files.readAllLines(file);
lines.forEach(System.out::println);
System.out.println(lines.size() + " lines, " + Files.size(file) + " bytes, file name " + file.getFileName());
Files.delete(file);
Files.delete(dir);
}
}{"type":"SystemMessage","text":"You are loop, a coding agent."}
{"type":"UserMessage","text":"Make WordCountTest pass."}
{"type":"AssistantMessage","text":"","toolCalls":[{"id":"call_1","name":"read_file","arguments":"{\"path\":\"WordCountTest.java\"}"}]}
{"type":"ToolMessage","callId":"call_1","name":"read_file","content":" 1 public class WordCountTest {","isError":false}
{"type":"AssistantMessage","text":"The test checks three inputs.","toolCalls":[]}
5 lines, 461 bytes, file name 2026-10-03T10-15-00-wordcount.jsonlEach line is one message, in the order the agent added it:
- The
typefield comes first and holds the record's simple name. Jackson wrote the record components after it in declaration order, soSystemMessagehastext,AssistantMessagehastextandtoolCalls, andToolMessagehascallId,name,content, andisError. - The assistant's tool call keeps its
argumentsas a JSON string inside JSON, so the quotes appear escaped ({\"path\":...}). That is the exact text the model produced; storing it unparsed keeps malformed arguments visible for debugging. - The tool result holds the full text the model saw, not the renderer's summary. A session must be able to reproduce the next request byte for byte.
The file name starts with the date and time, so ls lists sessions in order, and ends with a short label from the task. loop keeps sessions under ~/.loop/sessions/ in a directory per workspace, outside the workspace itself, so they are never committed by accident and never read by the agent's own grep tool. The format is the same idea as the transcripts of Evaluating Models and Agents: one event per line, appended, readable with jq.
Example 7: resuming a session by replay
To resume a session, loop agent --resume reads the saved file, rebuilds the message list from it, and continues with a new user message. Because the model has no memory of its own, replaying the old messages is how it "remembers" the earlier work.
A file written by a process that was killed can be damaged in two ways, and both must be repaired before the history is sent to the model:
- A crash during a write leaves a last line that is not complete JSON.
- A crash between a tool call and its result leaves an assistant message whose call has no tool message. Every chat API rejects such a history.
This example resumes a file with both problems. The last line of the file is cut off in the middle of a string, and call_2 has no result. Read the two repair: lines first, then the rebuilt history.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public class ResumeSession {
static final ObjectMapper JSON = new ObjectMapper();
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 {}
record ToolMessage(String callId, String name, String content, boolean isError) implements Message {}
static Message parse(JsonNode n) throws Exception {
return switch (n.get("type").asText()) {
case "SystemMessage" -> JSON.treeToValue(n, SystemMessage.class);
case "UserMessage" -> JSON.treeToValue(n, UserMessage.class);
case "AssistantMessage" -> JSON.treeToValue(n, AssistantMessage.class);
case "ToolMessage" -> JSON.treeToValue(n, ToolMessage.class);
default -> throw new IllegalArgumentException("unknown type " + n.get("type"));
};
}
static List<Message> load(List<String> lines) throws Exception {
List<Message> history = new ArrayList<>();
for (int i = 0; i < lines.size(); i++) {
JsonNode n;
try { n = JSON.readTree(lines.get(i)); } catch (Exception e) {
if (i == lines.size() - 1) { System.out.println("repair: dropped incomplete last line"); break; }
throw new IllegalStateException("corrupt session at line " + (i + 1));
}
history.add(parse(n));
}
Set<String> open = new LinkedHashSet<>(); // calls without results
List<Message> repaired = new ArrayList<>();
for (Message m : history) {
if (m instanceof AssistantMessage a) a.toolCalls().forEach(c -> open.add(c.id()));
if (m instanceof ToolMessage t) open.remove(t.callId());
repaired.add(m);
}
for (Message m : List.copyOf(repaired))
if (m instanceof AssistantMessage a)
for (ToolCall c : a.toolCalls())
if (open.contains(c.id())) {
repaired.add(new ToolMessage(c.id(), c.name(), "Interrupted: the session ended before this call finished. Check the workspace before repeating it.", true));
System.out.println("repair: added an error result for " + c.id());
}
return repaired;
}
public static void main(String[] args) throws Exception {
JSON.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
List<String> file = List.of(
"{\"type\":\"SystemMessage\",\"text\":\"You are loop, a coding agent.\"}",
"{\"type\":\"UserMessage\",\"text\":\"Make WordCountTest pass.\"}",
"{\"type\":\"AssistantMessage\",\"text\":\"\",\"toolCalls\":[{\"id\":\"call_1\",\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"WordCountTest.java\\\"}\"}]}",
"{\"type\":\"ToolMessage\",\"callId\":\"call_1\",\"name\":\"read_file\",\"content\":\" 1 public class WordCountTest {\",\"isError\":false}",
"{\"type\":\"AssistantMessage\",\"text\":\"\",\"toolCalls\":[{\"id\":\"call_2\",\"name\":\"shell\",\"arguments\":\"{\\\"command\\\":\\\"javac *.java\\\"}\"}]}",
"{\"type\":\"ToolMessage\",\"callId\":\"call_2\",\"name\":\"shell\",\"cont");
List<Message> history = load(file);
history.add(new UserMessage("Continue. The build machine was restarted."));
for (Message m : history) {
String desc = switch (m) {
case SystemMessage s -> "system: " + s.text();
case UserMessage u -> "user: " + u.text();
case AssistantMessage a -> "assistant: calls " + a.toolCalls().stream().map(c -> c.id() + " " + c.name()).toList();
case ToolMessage t -> "tool " + t.callId() + (t.isError() ? " (error): " : ": ") + t.content();
};
System.out.println(desc);
}
}
}repair: dropped incomplete last line
repair: added an error result for call_2
system: You are loop, a coding agent.
user: Make WordCountTest pass.
assistant: calls [call_1 read_file]
tool call_1: 1 public class WordCountTest {
assistant: calls [call_2 shell]
tool call_2 (error): Interrupted: the session ended before this call finished. Check the workspace before repeating it.
user: Continue. The build machine was restarted.The two repair: lines come first, because load prints them while it reads:
- The sixth line of the file stops in the middle of a string (
"cont). It is the last line, so the reader treats it as a write that a crash interrupted and drops it. A broken line in the middle of the file would be real corruption, andloadrefuses to continue in that case instead of guessing. - After dropping that line, call
call_2(thejavaccommand) has no result. The loader added a tool message withisError: trueand a text that tells the model what happened and to check the workspace before repeating the call. A shell command that was interrupted may have run partly; the model must not assume either outcome.
The rebuilt history follows: the system and user messages, the first call and its result, the second call with the repaired error result, and the new user message that the person typed when resuming. This list is valid for any chat API: every call id has exactly one result, and results follow their calls.
Resuming by replay means the model sees the same messages it saw before, so its behavior continues from where it stopped. It does not restore the state of the world: files may have changed since the crash. The checkpoints of the next example record the workspace state that belongs to each point of the session, which is what --resume with an undo needs.
Example 8: git checkpoints and undo
A model can make a bad edit. The person should be able to say "go back to how it was". Resuming a session (Example 7) restores the conversation but not the files, so something else must remember the files.
A checkpoint is a snapshot of the workspace taken before the agent changes it. loop agent stores checkpoints as commits in a shadow repository. That is a second git directory kept outside the workspace and used with the git options --git-dir (where the repository data lives) and --work-tree (which folder to snapshot). The user's own repository, branches, and staging area are never touched.
This example creates two checkpoints with real git commands, lets the "agent" change and add files, and then undoes the last change. Follow the contents of Main.java through the output, and watch the checkpoint list grow.
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
public class Checkpoints {
final Path gitDir, workTree;
Checkpoints(Path gitDir, Path workTree) { this.gitDir = gitDir; this.workTree = workTree; }
String git(String... args) throws Exception {
List<String> cmd = new ArrayList<>(List.of("git", "--git-dir=" + gitDir, "--work-tree=" + workTree,
"-c", "user.name=loop", "-c", "user.email=loop@localhost", "-c", "commit.gpgsign=false"));
cmd.addAll(List.of(args));
Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
if (p.waitFor() != 0) throw new IllegalStateException("git " + String.join(" ", args) + " failed: " + out);
return out.strip();
}
void save(String label) throws Exception { git("add", "-A"); git("commit", "-q", "--allow-empty", "-m", label); }
/** Restores the workspace to the state saved by checkpoint `rev`, after saving the current state. */
void restore(String rev) throws Exception {
String target = git("rev-parse", rev); // resolve before HEAD moves
save("before undo to " + git("log", "-1", "--format=%s", target));
for (String added : git("diff", "--name-only", "--diff-filter=A", target, "HEAD").lines().toList())
Files.deleteIfExists(workTree.resolve(added)); // files created after the checkpoint
git("checkout", target, "--", ".");
}
static String show(Path ws) throws Exception {
try (Stream<Path> s = Files.walk(ws)) {
List<String> parts = new ArrayList<>();
for (Path p : s.filter(Files::isRegularFile).sorted().toList())
parts.add(ws.relativize(p) + "=\"" + Files.readString(p).strip() + "\"");
return String.join(", ", parts);
}
}
public static void main(String[] args) throws Exception {
Path root = Files.createTempDirectory("ckpt"), ws = Files.createDirectories(root.resolve("project"));
Checkpoints cp = new Checkpoints(Files.createDirectories(root.resolve("state")).resolve("checkpoints.git"), ws);
cp.git("init", "-q");
Files.writeString(ws.resolve("Main.java"), "class Main { int v = 1; }");
System.out.println("start: " + show(ws));
cp.save("1 before edit_file Main.java"); // the agent's first change
Files.writeString(ws.resolve("Main.java"), "class Main { int v = 2; }");
System.out.println("after edit 1: " + show(ws));
cp.save("2 before write_file Util.java and edit_file Main.java"); // the agent's second change
Files.writeString(ws.resolve("Util.java"), "class Util { }");
Files.writeString(ws.resolve("Main.java"), "class Main { int v = 3; }");
System.out.println("after edit 2: " + show(ws));
System.out.println("checkpoints:\n" + cp.git("log", "--format=%s").indent(2).stripTrailing());
System.out.println("diff since checkpoint 2:\n" + cp.git("diff", "--stat", "HEAD").indent(2).stripTrailing());
cp.restore("HEAD"); // /undo: back to checkpoint 2
System.out.println("after undo: " + show(ws));
System.out.println("checkpoints:\n" + cp.git("log", "--format=%s").indent(2).stripTrailing());
System.out.println("shadow repository inside the workspace: " + Files.exists(ws.resolve(".git")));
try (Stream<Path> s = Files.walk(root)) { s.sorted(Comparator.reverseOrder()).forEach(f -> f.toFile().delete()); }
}
}start: Main.java="class Main { int v = 1; }"
after edit 1: Main.java="class Main { int v = 2; }"
after edit 2: Main.java="class Main { int v = 3; }", Util.java="class Util { }"
checkpoints:
2 before write_file Util.java and edit_file Main.java
1 before edit_file Main.java
diff since checkpoint 2:
Main.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
after undo: Main.java="class Main { int v = 2; }"
checkpoints:
before undo to 2 before write_file Util.java and edit_file Main.java
2 before write_file Util.java and edit_file Main.java
1 before edit_file Main.java
shadow repository inside the workspace: falseThe output follows the workspace through two agent changes and one undo:
starthas one file. Checkpoint 1 is taken before the first change, so it recordsv = 1.- After edit 1,
Main.javaholdsv = 2. Checkpoint 2 is taken before the second change and records that state. - After edit 2,
Main.javaholdsv = 3and a new fileUtil.javaexists. - The first
checkpointslist shows the two commits, newest first (git's log order). Each message names the tool calls that followed it, which is how/undocan describe what it will reverse. diff since checkpoint 2isgit diff --statagainst the last checkpoint:Main.javachanged by one line, andUtil.javadoes not appear because it is not yet in the shadow repository (--statwithout staging shows only tracked files). The fullrestorehandles new files separately for this reason.restore("HEAD")first resolvesHEADto a commit hash withgit rev-parse(saving a checkpoint movesHEAD, so the name alone would then point at the new commit), saves the current state as a third checkpoint (so the undo can itself be undone), then deletes files that were added after checkpoint 2 (git diff --diff-filter=AlistsUtil.java), and checks out checkpoint 2's version of every tracked file.after undoshowsv = 2and noUtil.java.- The second
checkpointslist has the new commit on top; its message names the checkpoint that was restored. - The last line confirms that the workspace has no
.gitof its own: the shadow repository lives in the state directory.
Checkpoints are cheap because git stores each unchanged file once. They cover only files inside the workspace: a command that changed a database, pushed to a remote, or deleted files outside the workspace cannot be undone this way, which is one reason the permission policy asks before shell commands. Aider takes a different approach and commits the agent's changes into the user's own repository with descriptive messages; the shadow repository keeps the user's history clean at the cost of a second git directory.
Example 9: headless mode with JSON output and exit codes
So far a person has been at the terminal, reading output and answering questions. Headless mode runs the agent with nobody there: in CI (an automated build server), in a script, or under loop eval. Three things change:
- Output becomes one JSON object per line, so another program can parse it.
- Nothing can be asked, so every
ASKdecision becomes a denial with a message that explains why. - The exit code reports how the run ended. An exit code is the number a program returns to the shell when it finishes. By convention 0 means success and any other number means something else happened.
This example runs a small agent loop headless three times with different scripted models: one that finishes, one that never stops, and one that needs approval. Compare the last result event and the exit code of each run.
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
public class Headless {
static final ObjectMapper JSON = new ObjectMapper();
record ToolCall(String id, String name, String command) {}
record Reply(String text, List<ToolCall> calls) {}
enum Status { COMPLETED, STEP_LIMIT, APPROVAL_REQUIRED }
static final int[] EXIT = {0, 3, 4}; // exit code per Status, in declaration order
static void emit(ObjectNode event) { System.out.println(event); }
static int run(String name, List<Reply> script, int maxSteps) {
System.out.println("== " + name);
Deque<Reply> replies = new ArrayDeque<>(script);
boolean denied = false;
int steps = 0;
Status status = Status.STEP_LIMIT;
while (steps < maxSteps) {
Reply r = replies.poll();
steps++;
if (r.calls().isEmpty()) {
emit(JSON.createObjectNode().put("type", "assistant").put("text", r.text()));
status = denied ? Status.APPROVAL_REQUIRED : Status.COMPLETED;
break;
}
for (ToolCall c : r.calls()) {
emit(JSON.createObjectNode().put("type", "tool_call").put("id", c.id()).put("name", c.name()).put("command", c.command()));
boolean needsApproval = c.command().startsWith("git push"); // the policy said ASK
if (needsApproval) denied = true;
emit(JSON.createObjectNode().put("type", "tool_result").put("id", c.id()).put("is_error", needsApproval)
.put("content", needsApproval ? "Denied: this command needs approval, and no one can approve in headless mode." : "Exit code: 0"));
}
}
emit(JSON.createObjectNode().put("type", "result").put("status", status.name().toLowerCase()).put("steps", steps));
return EXIT[status.ordinal()];
}
public static void main(String[] args) {
ToolCall test = new ToolCall("c1", "shell", "mvn -q test");
int a = run("completes", List.of(new Reply("", List.of(test)), new Reply("Tests pass.", List.of())), 5);
System.out.println("exit code " + a);
int b = run("loops", List.of(new Reply("", List.of(test)), new Reply("", List.of(test)), new Reply("", List.of(test))), 2);
System.out.println("exit code " + b);
int c = run("needs approval", List.of(new Reply("", List.of(new ToolCall("c1", "shell", "git push origin main"))),
new Reply("I could not push; approval is required.", List.of())), 5);
System.out.println("exit code " + c);
}
}== completes
{"type":"tool_call","id":"c1","name":"shell","command":"mvn -q test"}
{"type":"tool_result","id":"c1","is_error":false,"content":"Exit code: 0"}
{"type":"assistant","text":"Tests pass."}
{"type":"result","status":"completed","steps":2}
exit code 0
== loops
{"type":"tool_call","id":"c1","name":"shell","command":"mvn -q test"}
{"type":"tool_result","id":"c1","is_error":false,"content":"Exit code: 0"}
{"type":"tool_call","id":"c1","name":"shell","command":"mvn -q test"}
{"type":"tool_result","id":"c1","is_error":false,"content":"Exit code: 0"}
{"type":"result","status":"step_limit","steps":2}
exit code 3
== needs approval
{"type":"tool_call","id":"c1","name":"shell","command":"git push origin main"}
{"type":"tool_result","id":"c1","is_error":true,"content":"Denied: this command needs approval, and no one can approve in headless mode."}
{"type":"assistant","text":"I could not push; approval is required."}
{"type":"result","status":"approval_required","steps":2}
exit code 4Each run prints its events as JSON lines and ends with a result event:
- completes: one tool call, its result, the final assistant text, and
"status":"completed"after 2 steps. Exit code 0. - loops: the scripted model calls the same test command again and again. The step limit of 2 stopped it, the result says
step_limit, and the exit code is 3. A CI job can treat this differently from a failure of the task itself. - needs approval: the model asked for
git push, which the policy marks as ASK. In headless mode no one can answer, so the call was denied with a message saying why, and the model ended the run. The status isapproval_requiredand the exit code is 4, so a script can rerun with a more permissive--approvalif that is intended, or route the task to a person.
The exit codes of loop agent:
| Code | Meaning |
|---|---|
| 0 | The model finished with a final answer |
| 1 | An error outside the model's control: the server was unreachable, the session file could not be written |
| 2 | Invalid command-line input (picocli's code, Example 3) |
| 3 | The step or token limit stopped the run |
| 4 | A tool call needed approval in headless mode and was denied |
| 130 | The user pressed Ctrl+C (128 plus signal number 2, the shell convention) |
Exit code 0 means the agent finished, not that the task is done; the model can finish with "I could not fix it". Scripts that need a verdict run their own check after the agent, as loop eval runs hidden tests in Evaluating Models and Agents.
Example 10: configuration files and precedence
Users set the same options every time: their server URL, their model, their preferred approval mode. Typing them on every run is tedious, so loop reads them from configuration files, and the command line holds only what changes.
The values come from five places, and a later source overrides an earlier one:
1. built-in defaults weakest
2. user file ~/.loop/config.json
3. project file .loop/config.json in the workspace
4. environment variables (LOOP_*)
5. command line strongestOne exception protects the user. A project file comes with the repository, and a repository may come from a stranger. If the project file could say "approval: all", cloning a hostile repository would switch off every question the agent asks. So a project file may tighten permissions but never loosen them.
This example merges the five sources and prints where each value came from. The setup is built so that each source wins for at least one key, and so that the project file tries to loosen approval and gets caught.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class ConfigPrecedence {
static final ObjectMapper JSON = new ObjectMapper();
static final List<String> APPROVAL_ORDER = List.of("ask", "edits", "all"); // from strictest to loosest
record Value(String value, String source) {}
static void apply(Map<String, Value> config, String source, Map<String, String> values, boolean mayLoosen) {
for (Map.Entry<String, String> e : values.entrySet()) {
String key = e.getKey(), v = e.getValue();
if (key.equals("approval") && !mayLoosen
&& APPROVAL_ORDER.indexOf(v) > APPROVAL_ORDER.indexOf(config.get("approval").value())) {
System.out.println("ignored: " + source + " sets approval=" + v + ", project files may only tighten it");
continue;
}
config.put(key, new Value(v, source));
}
}
static Map<String, String> fromJson(String json) throws Exception {
Map<String, String> out = new LinkedHashMap<>();
for (Map.Entry<String, JsonNode> e : JSON.readTree(json).properties()) out.put(e.getKey(), e.getValue().asText());
return out;
}
public static void main(String[] args) throws Exception {
Map<String, Value> config = new LinkedHashMap<>();
apply(config, "default", Map.of("base_url", "http://127.0.0.1:8080/v1", "model", "default", "approval", "edits",
"max_steps", "30", "ctx_size", "16384"), true);
apply(config, "~/.loop/config.json", fromJson("{\"model\":\"qwen2.5-coder-7b-q4_k_m\",\"ctx_size\":\"32768\"}"), true);
apply(config, "project .loop/config.json", fromJson("{\"approval\":\"all\",\"max_steps\":\"50\"}"), false);
Map<String, String> env = Map.of("LOOP_BASE_URL", "http://10.0.0.5:8000/v1", "HOME", "/home/dev");
Map<String, String> fromEnv = new LinkedHashMap<>();
env.keySet().stream().sorted().filter(k -> k.startsWith("LOOP_"))
.forEach(k -> fromEnv.put(k.substring(5).toLowerCase(), env.get(k)));
apply(config, "environment", fromEnv, true);
apply(config, "command line", Map.of("approval", "ask"), true);
new java.util.TreeMap<>(config).forEach((k, v) -> System.out.printf("%-9s = %-30s (%s)%n", k, v.value(), v.source()));
}
}ignored: project .loop/config.json sets approval=all, project files may only tighten it
approval = ask (command line)
base_url = http://10.0.0.5:8000/v1 (environment)
ctx_size = 32768 (~/.loop/config.json)
max_steps = 50 (project .loop/config.json)
model = qwen2.5-coder-7b-q4_k_m (~/.loop/config.json)The first line is the security rule at work: the project file tried to set approval=all, which is looser than the current edits, so it was ignored with a message. A repository that ships .loop/config.json with "approval": "all" would otherwise make the next loop agent run in it execute every command the model writes without asking, including commands that a prompt injection in the repository's files suggests. The project file's max_steps of 50 was accepted, because a step limit is not a permission.
The table shows the effective configuration, sorted by key, and each value's source:
base_urlcame from the environment variableLOOP_BASE_URL. The prefixLOOP_is removed and the rest lowercased;HOMEhas no prefix and was ignored. Environment variables suit values that differ per machine, such as the address of a GPU server.modelandctx_sizecame from the user file, which overrode the defaults.approvalcame from the command line (ask), the last and strongest source. The command line may loosen or tighten anything, because the person typing it is the user.max_stepscame from the project file.
The same precedence order is common in command-line tools (git uses system, global, and repository files, then environment and flags). In loop, picocli's default-value provider from Subcommands, Configuration, and Packaging supplies the merged values as option defaults, so an explicit flag always wins.
Example 11: instruction files and the system prompt
The system prompt is the first message of every conversation. It tells the model who it is and how to behave. A coding agent also needs to know facts about the project it works in, such as "build with ./mvnw verify" or "never edit the generated/ folder". The model cannot guess these, and the user should not have to retype them for every task.
Repositories solve this with instruction files: Markdown files named AGENTS.md that tell agents the build commands and rules. The system prompt of loop agent is assembled at startup from three parts: fixed text, facts about the environment, and the instruction files. Context Engineering for Coding Agents showed how to collect them from the repository root down to the working directory.
This example builds the complete system prompt from a small directory tree with three AGENTS.md files and one README.md. It then builds the prompt a second time with a tiny size limit to show what happens when the instructions are too long.
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
public class SystemPrompt {
static List<Path> instructionFiles(Path root, Path cwd) {
List<Path> dirs = new ArrayList<>();
for (Path d = cwd; d != null && d.startsWith(root); d = d.getParent()) dirs.add(0, d); // root first
List<Path> files = new ArrayList<>();
for (Path d : dirs) if (Files.isRegularFile(d.resolve("AGENTS.md"))) files.add(d.resolve("AGENTS.md"));
return files;
}
static String build(Path root, Path cwd, List<String> toolNames, int maxInstructionChars) throws Exception {
StringBuilder p = new StringBuilder();
p.append("You are loop, a coding agent. You work in a repository through tools.\n");
p.append("Read files before you edit them. Make the smallest change that solves the task. ");
p.append("Run the tests after changes. When you are done, reply with a short summary and no tool call.\n\n");
p.append("# Environment\n");
p.append("Workspace: the current directory is ").append(root.relativize(cwd).toString().isEmpty() ? "." : root.relativize(cwd)).append(" inside the workspace\n");
p.append("Tools: ").append(String.join(", ", toolNames)).append("\n\n");
p.append("# Instructions from the repository\n");
int used = 0;
for (Path f : instructionFiles(root, cwd)) {
String text = Files.readString(f).strip();
String label = "## " + (root.relativize(f).toString().replace('\\', '/')) + "\n";
if (used + text.length() > maxInstructionChars) {
p.append(label).append("[skipped: the instruction files exceed ").append(maxInstructionChars).append(" characters]\n");
continue;
}
p.append(label).append(text).append("\n");
used += text.length();
}
return p.toString();
}
public static void main(String[] args) throws Exception {
Path root = Files.createTempDirectory("repo");
Path module = Files.createDirectories(root.resolve("services/billing"));
Files.writeString(root.resolve("AGENTS.md"), "Build with ./mvnw -q verify. Java 21. Do not edit generated/ directories.");
Files.writeString(root.resolve("services/AGENTS.md"), "Each service has its own README.");
Files.writeString(module.resolve("AGENTS.md"), "Billing amounts are long cents, never double.");
Files.writeString(root.resolve("README.md"), "not an instruction file");
String prompt = build(root, module, List.of("read_file", "write_file", "edit_file", "shell"), 200);
System.out.print(prompt);
System.out.println("(" + prompt.length() + " characters, about " + (prompt.length() + 3) / 4 + " tokens)");
System.out.println("--- with a 60-character limit:");
build(root, module, List.of("read_file"), 60).lines().filter(l -> l.startsWith("## ") || l.startsWith("[skipped")).forEach(System.out::println);
try (Stream<Path> s = Files.walk(root)) { s.sorted(Comparator.reverseOrder()).forEach(f -> f.toFile().delete()); }
}
}You are loop, a coding agent. You work in a repository through tools.
Read files before you edit them. Make the smallest change that solves the task. Run the tests after changes. When you are done, reply with a short summary and no tool call.
# Environment
Workspace: the current directory is services/billing inside the workspace
Tools: read_file, write_file, edit_file, shell
# Instructions from the repository
<h2 id="agents-md">AGENTS.md</h2>
Build with ./mvnw -q verify. Java 21. Do not edit generated/ directories.
<h2 id="services-agents-md">services/AGENTS.md</h2>
Each service has its own README.
<h2 id="services-billing-agents-md">services/billing/AGENTS.md</h2>
Billing amounts are long cents, never double.
(633 characters, about 159 tokens)
--- with a 60-character limit:
<h2 id="agents-md">AGENTS.md</h2>
[skipped: the instruction files exceed 60 characters]
<h2 id="services-agents-md">services/AGENTS.md</h2>
<h2 id="services-billing-agents-md">services/billing/AGENTS.md</h2>
[skipped: the instruction files exceed 60 characters]The prompt has three parts:
- Fixed text: who the agent is and four working rules (read before editing, smallest change, run the tests, end with a summary and no tool call). The last rule matters to the loop: a reply without tool calls is the stop condition of Project 21, so the prompt tells the model how to stop.
- Environment: the working directory relative to the workspace (
services/billing) and the tool names. Absolute paths and the user name stay out, because they add tokens and can leak information into logs and hosted APIs. - Instructions: the three
AGENTS.mdfiles from the root down to the working directory, each under a heading with its relative path. Root first means the general rules come before the specific ones, so a more specific file reads as a refinement.README.mdwas not included: only files with the agreed name are instructions.
The prompt is 633 characters, about 159 tokens by the four-characters-per-token estimate (the program computes (633 + 3) / 4 = 159). It is the same for every request of a session, so the engine's cache of earlier work (the KV cache from Example 2) can reuse it each time.
The second part repeats the build with a 60-character limit, to show the size rule with small numbers. The root file (74 characters) does not fit and is skipped with a visible marker. services/AGENTS.md (32 characters) fits. The billing file (45 characters) would bring the total to 77, so it is skipped too. The marker tells the model that instructions exist that it did not receive, which is better than silently dropping them. Instruction files are written by whoever controls the repository, so they are treated like any repository content: they can change the agent's behavior but not its permissions (Example 10).
Example 12: a scripted end-to-end run that fixes a failing test
This is Project 26 assembled, and the largest program of the lesson. Read it in the order the earlier examples introduced the parts. The picocli agent command builds the client, the four tools, the permission policy, the checkpoints, and the session log, and then runs the loop against an OpenAI-compatible server.
The server is a fake that returns six scripted assistant messages, in the exact JSON a real engine returns. The task is the cycle every coding agent must handle:
- Write a program.
- Run its tests.
- Read the failure.
- Edit the code.
- Run the tests again until they pass.
The tools, javac, java, and git are real; only the model is scripted. Its fourth message is written to fit the test failure the third step produces. Because the files are really written and the tests really run, the failure messages in the output come from real Java code. In the output, follow the [N] step numbers, the two approval questions, and the final lines the demo prints after the command returns.
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 com.sun.net.httpserver.HttpServer;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
@Command(name = "agent", mixinStandardHelpOptions = true, description = "Run the coding agent on a task in a workspace.")
public class LoopAgent implements Callable<Integer> {
static final ObjectMapper JSON = new ObjectMapper();
// ---------------- messages and model interface (Project 21) ----------------
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 {}
record ToolMessage(String callId, String name, String content, boolean isError) implements Message {}
record ToolSpec(String name, String description, JsonNode parameters) {}
record ModelResponse(AssistantMessage message, int promptTokens, int completionTokens) {}
interface ModelClient { ModelResponse chat(List<Message> messages, List<ToolSpec> tools) throws Exception; }
// ---------------- client: chat completions over HTTP (Project 11 mapping, Example 2) ----------------
static final class OpenAiModel implements ModelClient {
private final HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
private final URI endpoint;
private final String model;
OpenAiModel(String baseUrl, String model) { this.endpoint = URI.create(baseUrl + "/chat/completions"); this.model = model; }
static ObjectNode toJson(Message m) {
ObjectNode n = JSON.createObjectNode();
switch (m) {
case SystemMessage s -> n.put("role", "system").put("content", s.text());
case UserMessage u -> n.put("role", "user").put("content", u.text());
case AssistantMessage a -> {
n.put("role", "assistant").put("content", a.text());
if (!a.toolCalls().isEmpty()) {
ArrayNode calls = n.putArray("tool_calls");
for (ToolCall c : a.toolCalls())
calls.addObject().put("id", c.id()).put("type", "function").putObject("function")
.put("name", c.name()).put("arguments", c.arguments());
}
}
case ToolMessage t -> n.put("role", "tool").put("tool_call_id", t.callId()).put("content", t.content());
}
return n;
}
public ModelResponse chat(List<Message> messages, List<ToolSpec> tools) throws Exception {
ObjectNode body = JSON.createObjectNode().put("model", model).put("temperature", 0.0);
ArrayNode ms = body.putArray("messages");
messages.forEach(m -> ms.add(toJson(m)));
ArrayNode ts = body.putArray("tools");
for (ToolSpec t : tools)
ts.addObject().put("type", "function").putObject("function").put("name", t.name())
.put("description", t.description()).set("parameters", t.parameters());
HttpResponse<String> res = http.send(HttpRequest.newBuilder(endpoint).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new IOException("HTTP " + res.statusCode() + ": " + res.body());
JsonNode root = JSON.readTree(res.body()), msg = root.at("/choices/0/message");
List<ToolCall> calls = new ArrayList<>();
for (JsonNode c : msg.path("tool_calls"))
calls.add(new ToolCall(c.path("id").asText(), c.at("/function/name").asText(), c.at("/function/arguments").asText()));
return new ModelResponse(new AssistantMessage(msg.path("content").asText(""), calls),
root.at("/usage/prompt_tokens").asInt(), root.at("/usage/completion_tokens").asInt());
}
}
// ---------------- tools (Projects 22 and 23, compact) ----------------
interface Tool {
String name();
String description();
JsonNode schema();
String run(JsonNode args) throws Exception;
default boolean readOnly() { return false; }
}
static JsonNode stringParams(String... names) {
ObjectNode s = JSON.createObjectNode().put("type", "object");
ArrayNode required = JSON.createArrayNode();
for (String n : names) { s.withObjectProperty("properties").putObject(n).put("type", "string"); required.add(n); }
return s.set("required", required);
}
static Path inside(Path ws, String path) {
Path p = ws.resolve(path).normalize();
if (!p.startsWith(ws)) throw new SecurityException("path escapes the workspace: " + path);
return p;
}
record ReadFile(Path ws) implements Tool {
public String name() { return "read_file"; }
public String description() { return "Read a text file. Lines are numbered."; }
public JsonNode schema() { return stringParams("path"); }
public boolean readOnly() { return true; }
public String run(JsonNode a) throws Exception {
List<String> lines = Files.readAllLines(inside(ws, a.get("path").asText()));
StringBuilder out = new StringBuilder();
for (int i = 0; i < lines.size(); i++) out.append(String.format("%4d %s%n", i + 1, lines.get(i)));
return out.toString().stripTrailing();
}
}
record WriteFile(Path ws) implements Tool {
public String name() { return "write_file"; }
public String description() { return "Create or overwrite a file with the given content."; }
public JsonNode schema() { return stringParams("path", "content"); }
public String run(JsonNode a) throws Exception {
Path p = inside(ws, a.get("path").asText());
Files.createDirectories(p.getParent());
Files.writeString(p, a.get("content").asText());
return "Wrote " + a.get("content").asText().lines().count() + " lines to " + a.get("path").asText();
}
}
record EditFile(Path ws) implements Tool {
public String name() { return "edit_file"; }
public String description() { return "Replace oldString with newString in a file. oldString must occur exactly once."; }
public JsonNode schema() { return stringParams("path", "oldString", "newString"); }
public String run(JsonNode a) throws Exception {
Path p = inside(ws, a.get("path").asText());
String text = Files.readString(p), old = a.get("oldString").asText();
int first = text.indexOf(old);
if (first < 0) throw new IllegalArgumentException("oldString not found in " + a.get("path").asText());
if (text.indexOf(old, first + 1) >= 0) throw new IllegalArgumentException("oldString occurs more than once");
Files.writeString(p, text.substring(0, first) + a.get("newString").asText() + text.substring(first + old.length()));
return "Edited " + a.get("path").asText();
}
}
record Shell(Path ws) implements Tool {
public String name() { return "shell"; }
public String description() { return "Run a bash command in the workspace. No input is available."; }
public JsonNode schema() { return stringParams("command"); }
public String run(JsonNode a) throws Exception {
ProcessBuilder pb = new ProcessBuilder("bash", "-c", a.get("command").asText()).directory(ws.toFile())
.redirectErrorStream(true).redirectInput(ProcessBuilder.Redirect.from(new java.io.File("/dev/null")));
pb.environment().keySet().removeIf(k -> k.matches("(?i).*(TOKEN|SECRET|PASSWORD|API_KEY).*|JAVA_TOOL_OPTIONS|_JAVA_OPTIONS"));
Process p = pb.start();
StringBuilder out = new StringBuilder();
Thread reader = Thread.ofVirtual().start(() -> {
try { out.append(new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); } catch (IOException ignored) { }
});
if (!p.waitFor(60, TimeUnit.SECONDS)) { p.descendants().forEach(ProcessHandle::destroyForcibly); p.destroyForcibly(); }
reader.join();
String text = out.toString().strip();
if (text.length() > 4000) text = "[... cut ...]\n" + text.substring(text.length() - 4000);
if (p.exitValue() != 0) throw new IllegalStateException("Exit code: " + p.exitValue() + "\n" + text);
return "Exit code: 0\n" + (text.isEmpty() ? "(no output)" : text);
}
}
// ---------------- permissions (Project 25, compact) ----------------
enum Approval { ASK, EDITS, ALL }
enum Decision { ALLOW, ASK, DENY }
static Decision decide(Approval mode, Tool tool, JsonNode args) {
if (tool.readOnly()) return Decision.ALLOW;
if (tool.name().equals("shell") && args.path("command").asText().matches("(?s).*\\b(sudo|rm\\s+-rf\\s+/)\\b.*")) return Decision.DENY;
return switch (mode) {
case ALL -> Decision.ALLOW;
case EDITS -> tool.name().equals("shell") ? Decision.ASK : Decision.ALLOW;
case ASK -> Decision.ASK;
};
}
// ---------------- checkpoints in a shadow git repository (Example 8) ----------------
record Checkpoints(Path gitDir, Path workTree) {
String git(String... args) throws Exception {
List<String> cmd = new ArrayList<>(List.of("git", "--git-dir=" + gitDir, "--work-tree=" + workTree,
"-c", "user.name=loop", "-c", "user.email=loop@localhost", "-c", "commit.gpgsign=false"));
cmd.addAll(List.of(args));
Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
if (p.waitFor() != 0) throw new IOException("git " + String.join(" ", args) + ": " + out);
return out;
}
void save(String label) throws Exception { git("add", "-A"); git("commit", "-q", "--allow-empty", "-m", label); }
}
// ---------------- options ----------------
@Parameters(paramLabel = "TASK", description = "The task for the agent.") String task;
@Option(names = {"-C", "--workspace"}, defaultValue = ".", paramLabel = "DIR", description = "Workspace root.") Path workspace;
@Option(names = "--base-url", defaultValue = "http://127.0.0.1:8080/v1", description = "OpenAI-compatible endpoint.") String baseUrl;
@Option(names = {"-m", "--model"}, defaultValue = "default", description = "Model name sent to the server.") String model;
@Option(names = "--approval", defaultValue = "EDITS", description = "ask, edits, or all (default: ${DEFAULT-VALUE}).") Approval approval;
@Option(names = "--max-steps", defaultValue = "30", description = "Model calls before the run stops.") int maxSteps;
@Option(names = "--state-dir", paramLabel = "DIR", description = "Where sessions and checkpoints are kept.") Path stateDir;
BufferedReader input = new BufferedReader(new StringReader("")); // the terminal; tests replace it
@Override public Integer call() throws Exception {
Path ws = workspace.toRealPath();
Files.createDirectories(stateDir);
Path session = stateDir.resolve("session.jsonl");
Checkpoints checkpoints = new Checkpoints(stateDir.resolve("checkpoints.git"), ws);
checkpoints.git("init", "-q");
Map<String, Tool> tools = new LinkedHashMap<>();
for (Tool t : List.of(new ReadFile(ws), new WriteFile(ws), new EditFile(ws), new Shell(ws))) tools.put(t.name(), t);
List<ToolSpec> specs = tools.values().stream().map(t -> new ToolSpec(t.name(), t.description(), t.schema())).toList();
ModelClient client = new OpenAiModel(baseUrl, model);
List<Message> history = new ArrayList<>(List.of(
new SystemMessage("You are loop, a coding agent. Work in the workspace with the tools. Run the tests before you finish."),
new UserMessage(task)));
history.forEach(m -> log(session, m));
System.out.println("task: " + task);
int tokens = 0;
for (int step = 1; step <= maxSteps; step++) {
ModelResponse r = client.chat(List.copyOf(history), specs);
tokens += r.promptTokens() + r.completionTokens();
AssistantMessage reply = r.message();
history.add(reply);
log(session, reply);
if (reply.toolCalls().isEmpty()) {
System.out.println("[" + step + "] " + reply.text());
System.out.println("done: " + step + " steps, " + tokens + " tokens, "
+ (checkpoints.git("rev-list", "--count", "HEAD").strip()) + " checkpoints");
return 0;
}
for (ToolCall c : reply.toolCalls()) {
ToolMessage result = execute(c, tools, checkpoints, step);
history.add(result);
log(session, result);
}
}
System.out.println("stopped: step limit " + maxSteps);
return 3;
}
ToolMessage execute(ToolCall c, Map<String, Tool> tools, Checkpoints checkpoints, int step) throws Exception {
Tool tool = tools.get(c.name());
if (tool == null) return new ToolMessage(c.id(), c.name(), "Unknown tool " + c.name(), true);
JsonNode args = JSON.readTree(c.arguments());
String summary = args.has("path") ? args.get("path").asText() : args.path("command").asText();
System.out.println("[" + step + "] " + c.name() + " " + summary);
if (c.name().equals("edit_file")) {
args.get("oldString").asText().lines().forEach(l -> System.out.println(" - " + l.strip()));
args.get("newString").asText().lines().forEach(l -> System.out.println(" + " + l.strip()));
}
Decision d = decide(approval, tool, args);
if (d == Decision.ASK) {
String answer = input.readLine();
System.out.println(" allow? [y/N] " + answer);
if (!"y".equalsIgnoreCase(answer)) d = Decision.DENY;
}
if (d == Decision.DENY) return new ToolMessage(c.id(), c.name(), "Denied by the user or by policy.", true);
if (!tool.readOnly()) checkpoints.save("before " + c.name() + " " + summary);
try {
String out = tool.run(args);
System.out.println(" ok: " + firstAndLast(out));
return new ToolMessage(c.id(), c.name(), out, false);
} catch (Exception e) {
String out = e.getMessage();
System.out.println(" error: " + firstAndLast(out));
return new ToolMessage(c.id(), c.name(), out, true);
}
}
static String firstAndLast(String text) {
List<String> lines = text.lines().toList();
if (lines.size() <= 4) return String.join(" | ", lines);
return lines.get(0) + " | ... | " + String.join(" | ", lines.subList(lines.size() - 2, lines.size()));
}
static void log(Path session, Message m) {
try {
ObjectNode line = JSON.valueToTree(m);
line.put("type", m.getClass().getSimpleName());
Files.writeString(session, line + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
// ---------------- demo: a fake OpenAI-compatible server with a scripted model ----------------
static AssistantMessage call(String id, String name, ObjectNode args) { return new AssistantMessage("", List.of(new ToolCall(id, name, args.toString()))); }
static ObjectNode args() { return JSON.createObjectNode(); }
public static void main(String[] argv) throws Exception {
Path root = Files.createTempDirectory("loop-e2e"), ws = Files.createDirectories(root.resolve("project"));
Files.writeString(ws.resolve("WordCountTest.java"), """
public class WordCountTest {
static int failures;
static void check(String input, int expected) {
int got = WordCount.count(input);
if (got != expected) { failures++; System.out.println("FAIL count(\\"" + input + "\\") = " + got + ", expected " + expected); }
}
public static void main(String[] args) {
check("hello world", 2);
check("", 0);
check(" two spaces ", 2);
System.out.println(failures == 0 ? "PASS 3 checks" : failures + " of 3 checks failed");
System.exit(failures == 0 ? 0 : 1);
}
}
""");
String test = "javac -d out *.java && java -cp out WordCountTest";
List<AssistantMessage> script = List.of(
call("call_1", "read_file", args().put("path", "WordCountTest.java")),
call("call_2", "write_file", args().put("path", "WordCount.java").put("content",
"public class WordCount {\n static int count(String text) {\n return text.split(\" \").length;\n }\n}\n")),
call("call_3", "shell", args().put("command", test)),
call("call_4", "edit_file", args().put("path", "WordCount.java").put("oldString", "return text.split(\" \").length;")
.put("newString", "String t = text.strip();\n return t.isEmpty() ? 0 : t.split(\"\\\\s+\").length;")),
call("call_5", "shell", args().put("command", test)),
new AssistantMessage("WordCount.count now strips the text, returns 0 for empty input, and splits on runs of whitespace. WordCountTest passes.", List.of()));
int[] next = {0};
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/v1/chat/completions", ex -> {
JsonNode req = JSON.readTree(ex.getRequestBody().readAllBytes());
ObjectNode res = JSON.createObjectNode();
AssistantMessage reply = script.get(next[0]++);
ObjectNode choice = res.putArray("choices").addObject().put("index", 0)
.put("finish_reason", reply.toolCalls().isEmpty() ? "stop" : "tool_calls");
choice.set("message", OpenAiModel.toJson(reply));
res.putObject("usage").put("prompt_tokens", req.toString().length() / 4).put("completion_tokens", 40 + next[0] * 5);
byte[] b = res.toString().getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json");
ex.sendResponseHeaders(200, b.length);
ex.getResponseBody().write(b);
ex.close();
});
server.start();
try {
LoopAgent agent = new LoopAgent();
agent.input = new BufferedReader(new StringReader("y\ny\n")); // the user approves both shell commands
int code = new CommandLine(agent).setCaseInsensitiveEnumValuesAllowed(true).execute(
"--workspace", ws.toString(), "--base-url", "http://127.0.0.1:" + server.getAddress().getPort() + "/v1",
"--model", "scripted-coder", "--approval", "edits", "--state-dir", root.resolve("state").toString(),
"Make WordCountTest pass. Write WordCount.java.");
System.out.println("exit code " + code);
System.out.println("session: " + Files.readAllLines(root.resolve("state/session.jsonl")).size() + " JSON lines");
System.out.print("checkpoints:\n" + new Checkpoints(root.resolve("state/checkpoints.git"), ws).git("log", "--format= %s"));
System.out.print("final WordCount.java:\n" + Files.readString(ws.resolve("WordCount.java")));
} finally {
server.stop(0);
try (Stream<Path> s = Files.walk(root)) { s.sorted(Comparator.reverseOrder()).forEach(f -> f.toFile().delete()); }
}
}
}task: Make WordCountTest pass. Write WordCount.java.
[1] read_file WordCountTest.java
ok: 1 public class WordCountTest { | ... | 13 } | 14 }
[2] write_file WordCount.java
ok: Wrote 5 lines to WordCount.java
[3] shell javac -d out *.java && java -cp out WordCountTest
allow? [y/N] y
error: Exit code: 1 | FAIL count("") = 1, expected 0 | FAIL count(" two spaces ") = 6, expected 2 | 2 of 3 checks failed
[4] edit_file WordCount.java
- return text.split(" ").length;
+ String t = text.strip();
+ return t.isEmpty() ? 0 : t.split("\\s+").length;
ok: Edited WordCount.java
[5] shell javac -d out *.java && java -cp out WordCountTest
allow? [y/N] y
ok: Exit code: 0 | PASS 3 checks
[6] WordCount.count now strips the text, returns 0 for empty input, and splits on runs of whitespace. WordCountTest passes.
done: 6 steps, 4293 tokens, 4 checkpoints
exit code 0
session: 13 JSON lines
checkpoints:
before shell javac -d out *.java && java -cp out WordCountTest
before edit_file WordCount.java
before shell javac -d out *.java && java -cp out WordCountTest
before write_file WordCount.java
final WordCount.java:
public class WordCount {
static int count(String text) {
String t = text.strip();
return t.isEmpty() ? 0 : t.split("\\s+").length;
}
}The output is the terminal rendering of the run, followed by facts the demo prints after the command returned. Each step below is one trip around the loop of Example 1:
[1] read_file WordCountTest.java: the model starts by reading the test. The renderer summarizes the 14 numbered lines by their first and last two lines. Reading is allowed in every approval mode and needs no checkpoint.[2] write_file WordCount.java: the model writes a first version that splits on single spaces. Ineditsmode, the edit runs without a question. A checkpoint is taken first.[3] shell javac ...: the model runs the tests. Shell commands need approval ineditsmode, and the scripted terminal answeredy. The command compiled both files and ran the test, which failed two of three checks:count("")returned 1 because"".split(" ")is an array with one empty string, andcount(" two spaces ")returned 6 because every space is a separator. The tool returned an error result with the exit code and the full output.[4] edit_file WordCount.java: the model's fix, shown as removed and added lines.edit_filefoundoldStringexactly once and replaced it.[5] shell ...: the tests ran again, after a second approval, and passed:Exit code: 0 | PASS 3 checks.[6]is the final answer with no tool call, which ends the loop.done:reports 6 model calls, the tokens the fake server counted (a quarter of each request's length, as a stand-in for realusage), and 4 checkpoints.exit code 0is the command's return value.session: 13 JSON linescounts the session file: the system and user messages, 6 assistant messages, and 5 tool results.- The checkpoint list, newest first, names the change each snapshot preceded.
/undoafter this run would restore the state before the second test run, which is the same as the state after the edit, so a person would undo twice to see the first version. - The final
WordCount.javaholds the fixed method.
The program applies the earlier lessons' rules in its execute method. Each rule protects against one failure:
- An unknown tool name becomes an error result, not a crash (Project 21).
- Every path goes through
inside, which rejects paths that leave the workspace, such as../../etc/passwd(Project 22). - The shell tool removes secret-looking environment variables and the agent's own JVM options, so a command cannot read your tokens. It reads output on a virtual thread (a lightweight Java thread) so a chatty command cannot block, and it kills the whole process tree on timeout (Project 23).
decidereturns ALLOW, ASK, or DENY before anything runs, and a deny rule forsudoandrm -rf /applies in every mode (Project 25).
The history is sent whole; the ContextManager of Context Engineering for Coding Agents slots in where history is passed to client.chat, as context.request(), and history.add becomes context.add. The compact tools here differ from the full versions in the earlier lessons in the ways their lessons describe (no read-before-edit tracking, no fuzzy matching, no truncation markers); the full versions plug into the same Tool interface.
Example 13: a run against llama-server
The scripted runs prove that the program works. With a real engine, the only change is the base URL. The first command below starts llama-server with a model file, a context size of 32768 tokens (-c), one slot (-np 1), and --jinja, which makes the server use the model's own chat template, the text format that tells the model where messages and tool definitions go. The second command points loop agent at that server. The --ctx-size 32768 value matches -c 32768 divided by one slot.
llama-server -m qwen2.5-coder-7b-instruct-q4_k_m.gguf -c 32768 -np 1 --jinja --port 8080
loop agent -C ~/src/wordcount --base-url http://127.0.0.1:8080/v1 --ctx-size 32768 "Make WordCountTest pass."A 7-billion-parameter model file of this kind needs a few gigabytes of memory, so the course does not run it in its examples. The course's tiny model has random weights, which means it was never trained, so it cannot solve a task. It does produce real protocol traffic, and that is enough to see the format. With the grammar-constrained request from Function Calling from First Principles (tool_choice: "required", a prefilled <tool_call> and logit bias), the pinned llama-server running the tiny model with the Qwen2.5 chat template returned this first step of the WordCount task. Real output, timings and envelope fields removed:
{
"choices": [
{
"finish_reason": "tool_calls",
"index": 0,
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\n \t \t \"path\":\"WordCount.java\"\n\n \t \t }"
},
"id": "U8mRpb5tHLA0IfbgXWkHI36e0l7Up0wf"
}
]
}
}
],
"usage": {
"completion_tokens": 73,
"prompt_tokens": 273,
"total_tokens": 346
}
}This is the response Example 2's adapter parsed. The template rendered the tool definitions into the system message inside <tools> tags and the tool result of the next step as a user turn inside <tool_response> tags, which /apply-template shows; the client never sees these tags, because the server renders and parses them.
A run with a trained coding model prints the same kind of lines as Example 12. The following transcript is an illustration of the format, not a captured run: it is assembled from the message shapes above and the renderer of Example 4, with the steps a trained model typically takes on this task. Step counts, token counts, and wording differ by model, quantization, and run.
task: Make WordCountTest pass.
[1] read_file WordCountTest.java
ok: 1 public class WordCountTest { | ... | 13 } | 14 }
[2] read_file WordCount.java
error: NoSuchFileException: WordCount.java
[3] write_file WordCount.java
ok: Wrote 6 lines to WordCount.java
[4] shell javac -d out *.java && java -cp out WordCountTest
allow? [y/N] y
ok: Exit code: 0 | PASS 3 checks
[5] I created WordCount.java. count strips the input, returns 0 for an empty string, and splits on \s+. All 3 checks pass.
done: 5 steps, 9840 tokens, 2 checkpointsThe differences from the scripted run are typical of real models: a trained model often reads a file that does not exist yet and recovers from the error result, and it may get the solution right on the first write. With a small quantized model, the common failures are the ones Making Small Local Models Reliable catalogs: malformed tool arguments, repeated identical calls, and a final answer before the tests run. The step limit and the loop detection from that lesson bound the cost of each.
Example 14: the source map and extension points
loop agent is a few hundred lines on top of the earlier projects. When you want to change its behavior, the first question is which class to open. The table below maps each class of Project 26 to the lesson that built it.
The program after the table shows the most common change, adding a tool. A new tool is one class that implements Tool. Registering it adds it to the definitions the model receives, and the permission policy and renderer handle it without changes. The example tool counts lines per file extension, so the model can learn the size of a project without reading every file into its context.
| Class or file | Lesson and project | Responsibility |
|---|---|---|
Loop, AgentCommand |
picocli Basics (Project 8), this lesson | Command line, options, exit codes |
ChatClient, OpenAiModel |
Chat Completions Protocol (Project 11), Example 2 | HTTP, streaming, message mapping |
Agent, ToolRegistry, Message records |
Agent Loop (Project 21) | The loop, validation, timeouts, truncation, listeners |
read_file, write_file, edit_file, list_files, glob, grep tools |
File Tools (Project 22) | Workspace-confined file access |
ShellTool |
Shell Tool (Project 23) | Commands, timeouts, process trees, environment hygiene |
web_search, web_fetch tools |
Web Search and Fetch Tools (Project 24) | Optional; off unless configured |
PermissionPolicy, AuditLog |
Permissions (Project 25) | Allow, ask, deny; sandbox wrapper; audit |
ContextManager |
Context Engineering | Truncation, compaction, instruction files |
TerminalRenderer, DiffRender |
Examples 4 and 5 | What the person sees |
SessionWriter, SessionLoader |
Examples 6 and 7 | ~/.loop/sessions/*.jsonl, resume |
Checkpoints |
Example 8 | Shadow git repository, /undo |
Config |
Examples 10 and 11 | Five configuration sources, system prompt |
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class AddATool {
static final ObjectMapper JSON = new ObjectMapper();
interface Tool {
String name();
String description();
JsonNode schema();
String run(JsonNode args) throws Exception;
default boolean readOnly() { return false; }
}
/** A new tool: counts lines of code per file extension, without reading file contents into the context. */
record CountLines(Path ws) implements Tool {
public String name() { return "count_lines"; }
public String description() { return "Count lines per file extension under a directory of the workspace."; }
public JsonNode schema() {
try { return JSON.readTree("{\"type\":\"object\",\"properties\":{\"dir\":{\"type\":\"string\"}},\"required\":[\"dir\"]}"); }
catch (Exception e) { throw new IllegalStateException(e); }
}
public boolean readOnly() { return true; }
public String run(JsonNode a) throws Exception {
Map<String, Long> perExt = new java.util.TreeMap<>();
try (var files = Files.walk(ws.resolve(a.get("dir").asText()).normalize())) {
for (Path f : files.filter(Files::isRegularFile).toList()) {
String n = f.getFileName().toString();
perExt.merge(n.contains(".") ? n.substring(n.lastIndexOf('.')) : "(none)", (long) Files.readAllLines(f).size(), Long::sum);
}
}
return perExt.toString();
}
}
public static void main(String[] args) throws Exception {
Path ws = Files.createTempDirectory("ws");
Files.createDirectories(ws.resolve("src"));
Files.writeString(ws.resolve("src/A.java"), "class A {\n}\n");
Files.writeString(ws.resolve("src/B.java"), "class B {\n int x;\n}\n");
Files.writeString(ws.resolve("src/notes.md"), "# Notes\n");
Map<String, Tool> registry = new LinkedHashMap<>();
for (Tool t : List.of(new CountLines(ws))) registry.put(t.name(), t);
Tool t = registry.get("count_lines");
System.out.println("definition: " + JSON.createObjectNode().put("name", t.name()).put("description", t.description()).set("parameters", t.schema()));
System.out.println("read-only, so the policy allows it in every mode: " + t.readOnly());
System.out.println("result: " + t.run(JSON.readTree("{\"dir\":\"src\"}")));
for (Path p : List.of(ws.resolve("src/A.java"), ws.resolve("src/B.java"), ws.resolve("src/notes.md"), ws.resolve("src"), ws)) Files.delete(p);
}
}definition: {"name":"count_lines","description":"Count lines per file extension under a directory of the workspace.","parameters":{"type":"object","properties":{"dir":{"type":"string"}},"required":["dir"]}}
read-only, so the policy allows it in every mode: true
result: {.java=5, .md=1}The output shows the three things a tool author decides:
- The definition: a name, a description that tells the model when to use the tool, and a JSON Schema for the arguments. The model sees only this, so the description carries the tool's purpose ("without reading file contents" is the reason to prefer it over
read_file). - The read-only flag:
count_linesonly reads, so the permission policy allows it without asking in every mode, likeread_file. - The result text:
{.java=5, .md=1}, a compact summary. A tool result is model input, so it should be as short as the task allows.
Extension tasks, in order of size:
- Add
list_files,glob, andgrepfrom File Tools, and the web tools from Web Search and Fetch Tools, behind a configuration flag. - Stream assistant text through Project 11's
streamand the renderer'sonAssistant, so long answers appear as they are generated. - Replace the history list with
ContextManagerand add a/compactslash command. - Add slash commands to the interactive mode:
/undo(Example 8),/diff(all changes since the session started),/tokens,/model. - Wrap the shell tool in the sandbox command from the permissions lesson and add the JSONL audit log.
- Connect MCP servers, so third-party tools join the registry under namespaced names. The next lesson, The Model Context Protocol, builds the client for this as Project 27.
- Measure every change with
loop eval, Project 28 in Evaluating Models and Agents, on a task suite from your own repository.
Interview points
- A coding agent is a loop around a chat API: send the history and tool definitions, run the requested tools, append one result per call id, and stop when the model answers without tool calls or a limit is reached.
- The permission decision sits between the model's request and the tool; denied calls still produce a result message so the model learns why and the history stays valid.
- Rendering is a listener on agent events: one line per tool call showing the path or command, a short result summary, and diffs for file changes; the full text goes only to the model.
- Sessions are appended as JSON Lines one message at a time; resume drops an incomplete last line and adds error results for calls that never finished.
- Checkpoints in a shadow git repository (
--git-diroutside the workspace) give undo without touching the user's repository; they cannot undo effects outside the workspace. - Headless mode prints JSON events, converts every ASK into a denial with an explanation, and reports the outcome with distinct exit codes.
- Configuration merges defaults, user file, project file, environment, and flags in that order; a project file may tighten but never loosen permissions.
- The system prompt combines fixed rules, environment facts, and
AGENTS.mdfiles from the root down to the working directory, with a size limit and a visible marker when files are skipped. - An end-to-end test of an agent uses a fake OpenAI-compatible server with scripted replies and real tools, so the whole stack runs deterministically.
- With a real engine, only the base URL changes; reliability depends on the model, the template's tool format, and the limits that bound failures.
Summary
- Project 26 assembles Projects 8, 11, 21, 22, 23, and 25 and the context manager into
loop agent. OpenAiModelmapsSystemMessage,UserMessage,AssistantMessage, andToolMessageto chat completions JSON and parsestool_callsandusageback.- A real llama-server response with the tiny model has a 32-character call id, arguments as a JSON string, and
finish_reasontool_calls; the second request reused 273 cached tokens. loop agentoptions:TASK,-C/--workspace,--base-url,-m/--model,--approval ask|edits|all,--max-steps,--ctx-size,--json,--resume,--[no-]checkpoints.- picocli returns exit code 2 for an invalid option value such as an unknown approval mode.
- The renderer shows
[step] tool subject,ok:orerror:summaries, and ANSI colors only when allowed. - A unified diff hunk header
@@ -2,3 +2,4 @@gives start lines and line counts in the old and new file. - Each session line has a
typefield and the record's components; the full tool output is stored. - Resume repairs an incomplete last line and unanswered tool calls before sending the history.
- Checkpoints are commits in a shadow repository taken before each change; undo saves the current state first, deletes files added since the checkpoint, and checks out the checkpoint.
- Headless exit codes: 0 finished, 1 infrastructure error, 2 usage error, 3 limit reached, 4 approval required, 130 interrupted.
- Configuration precedence: default,
~/.loop/config.json, project.loop/config.json,LOOP_*environment variables, command line. - The scripted end-to-end run takes 6 model calls: read the test, write code, run tests (2 of 3 fail), edit, run tests (pass), final answer.
- A new tool is one class implementing
Tool; its definition, read-only flag, and result text are the author's decisions.
