Guide contents

Lesson 55 of 73

The Agent Loop and a Tool Framework

An agent is a program that talks to a language model in a loop. The program sends the conversation and a list of tools to the model. If the model asks for tool calls, the program runs them, adds the results to the conversation, and asks the model again. When the model finally answers with plain text and no tool call, the loop ends. Every coding agent, however large, is built around this loop.

This lesson builds the loop in Java, one piece at a time. It starts with a loop of about thirty lines and replaces each simplification with a proper part: typed messages, a Tool interface, schemas generated from records, a registry that handles bad calls, stop conditions, timeouts, output truncation, and parallel calls. It ends with Project 21, an Agent class with a small tool framework and tests. The file, shell, web, permission, context, and MCP lessons that follow plug into the types defined here. After this lesson, you can write an agent loop, add a tool to it, and test it without a model.

Every example runs against a scripted fake model. This is a ModelClient implementation (a Java interface for "something that answers a conversation") that returns canned assistant messages in a fixed order. A fake model is useful for two reasons. First, it makes every run identical, which is what tests need. Second, it costs nothing and needs no server. Function Calling from First Principles showed that a real server turns the model's tool-call text into the same structure (a call id, a tool name, and a JSON string of arguments). So code that works with the fake works with a real server.

Idea or API What it is for
The agent loop Model call, tool calls, results, repeat
Sealed Message records A history that the compiler checks
Tool interface, ToolSpec What a tool is, and what the model sees of it
JSON Schema from records Argument schemas without hand-written JSON
ToolRegistry Dispatch by name, validation, errors as results
Stop conditions Final answer, step limit, token limit, cancellation
FutureTask on a virtual thread Per-tool timeouts
Head and tail truncation Long tool output that still fits the context
newVirtualThreadPerTaskExecutor Parallel read-only calls, results in call order
AgentListener Events for a terminal or a log
Project 21 Agent, Tool, messages, registry, tests

Example 1: the smallest loop with a scripted fake model

What is the smallest agent loop? This program uses plain strings for messages and a fake model that replays three replies: a call to read_file, a call to count_lines, and a text answer. The model is a Function that receives the history and returns a Reply. A Reply holds either text or one Call. Read the for loop in main: it is the whole agent. Then look at how many messages the model receives on each step.

Java
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

public class SmallestLoop {
    record Call(String tool, String argument) {}
    record Reply(String text, Call call) {}                     // call is null when the model answers in text

    public static void main(String[] args) {
        Deque<Reply> script = new ArrayDeque<>(List.of(
            new Reply("", new Call("read_file", "pom.xml")),
            new Reply("", new Call("count_lines", "pom.xml")),
            new Reply("pom.xml has 3 lines and names the project demo.", null)));
        Function<List<String>, Reply> model = history -> {      // a fake model: ignores the history, replays the script
            System.out.println("  model receives " + history.size() + " messages");
            return script.poll();
        };
        Map<String, String> files = Map.of("pom.xml", "<project>\n  <name>demo</name>\n</project>");
        Map<String, Function<String, String>> tools = Map.of(
            "read_file", path -> files.getOrDefault(path, "no such file: " + path),
            "count_lines", path -> String.valueOf(files.get(path).lines().count()));

        List<String> history = new ArrayList<>(List.of("user: Describe pom.xml"));
        for (int step = 1; ; step++) {
            System.out.println("step " + step);
            Reply reply = model.apply(List.copyOf(history));
            if (reply.call() == null) {                              // no tool call: the loop ends
                history.add("assistant: " + reply.text());
                break;
            }
            String result = tools.get(reply.call().tool()).apply(reply.call().argument());
            history.add("assistant: call " + reply.call().tool() + "(" + reply.call().argument() + ")");
            history.add("tool: " + result.replace("\n", "\\n"));
        }
        System.out.println("history:");
        history.forEach(m -> System.out.println("  " + m));
    }
}
Output
step 1
  model receives 1 messages
step 2
  model receives 3 messages
step 3
  model receives 5 messages
history:
  user: Describe pom.xml
  assistant: call read_file(pom.xml)
  tool: <project>\n  <name>demo</name>\n</project>
  assistant: call count_lines(pom.xml)
  tool: 3
  assistant: pom.xml has 3 lines and names the project demo.

The loop ran three steps. A step is one model call plus the tool calls that answer it.

  • In step 1 the model received one message (the user's request) and asked for read_file(pom.xml). The program ran the tool and appended two messages: the assistant's call and the tool's result.
  • In step 2 the model received three messages and asked for count_lines.
  • In step 3 the model received five messages and answered in text, which ended the loop.

The growing message counts (1, 3, 5) show the main property of an agent loop. Every model call receives the whole history, because the model keeps no memory between requests. The program must send everything again each time. A real model reads the tool results in that history to decide what to do next. The fake model ignores its input and replays the script. That is enough to test the loop itself: that tools run, that results go back in order, and that a text answer stops the loop.

The final history has six entries. A trained model would base its answer on the tool output (3 lines, the name demo). The rest of this lesson replaces each simplification of this first version with a proper part. The simplifications are strings as messages, a fixed map of tools, no limits, and no error handling.

Example 2: message history as sealed record types

Plain strings cannot say what kind of message they hold, and a typo in a prefix such as "tool: " goes unnoticed. How can the history be represented so that the compiler catches mistakes? The answer is a sealed interface, which lists every allowed kind of message. A switch over a sealed type must handle each kind, or the code does not compile. Sealed types and record patterns are covered in Records and Sealed Types and Pattern Matching.

The program defines four message records, a describe method that prints each kind differently, and a problems method that checks whether a history follows the rules that servers expect. It then checks a good history and a broken one.

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

public class MessageTypes {
    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 String describe(Message m) {
        return switch (m) {                                     // no default: the compiler checks every case
            case SystemMessage s -> "system     " + s.text();
            case UserMessage u -> "user       " + u.text();
            case AssistantMessage a when a.toolCalls().isEmpty() -> "assistant  " + a.text();
            case AssistantMessage a -> "assistant  calls " + a.toolCalls().stream().map(c -> c.id() + ":" + c.name() + c.arguments()).toList();
            case ToolMessage t -> "tool       " + t.callId() + (t.isError() ? " (error) " : " ") + t.content();
        };
    }

    /** The rules a server enforces: results answer open calls, and every call is answered before the next turn. */
    static List<String> problems(List<Message> history) {
        List<String> problems = new ArrayList<>();
        Set<String> open = new HashSet<>();
        for (int i = 0; i < history.size(); i++) {
            Message m = history.get(i);
            if (m instanceof ToolMessage t && !open.remove(t.callId())) problems.add("message " + i + ": result for unknown call " + t.callId());
            if (!(m instanceof ToolMessage) && !open.isEmpty()) problems.add("message " + i + ": calls without results " + open);
            if (!(m instanceof ToolMessage)) open.clear();
            if (m instanceof AssistantMessage a) a.toolCalls().forEach(c -> open.add(c.id()));
        }
        return problems;
    }

    public static void main(String[] args) {
        List<Message> good = List.of(
            new SystemMessage("You are a coding agent."),
            new UserMessage("Compare pom.xml and README.md"),
            new AssistantMessage("", List.of(new ToolCall("c1", "read_file", "{\"path\":\"pom.xml\"}"),
                                             new ToolCall("c2", "read_file", "{\"path\":\"README.md\"}"))),
            new ToolMessage("c1", "read_file", "<project>demo</project>", false),
            new ToolMessage("c2", "read_file", "NoSuchFileException: README.md", true),
            new AssistantMessage("pom.xml names the project demo; README.md is missing.", List.of()));
        good.forEach(m -> System.out.println(describe(m)));
        System.out.println("problems: " + problems(good));

        List<Message> broken = new ArrayList<>(good.subList(0, 4));     // the result for c2 is missing
        broken.add(new ToolMessage("c9", "read_file", "?", false));
        broken.add(new UserMessage("Go on"));
        System.out.println("problems: " + problems(broken));
    }
}
Output
system     You are a coding agent.
user       Compare pom.xml and README.md
assistant  calls [c1:read_file{"path":"pom.xml"}, c2:read_file{"path":"README.md"}]
tool       c1 <project>demo</project>
tool       c2 (error) NoSuchFileException: README.md
assistant  pom.xml names the project demo; README.md is missing.
problems: []
problems: [message 4: result for unknown call c9, message 5: calls without results [c2]]

The first six lines show the four message kinds:

  • A SystemMessage holds the instructions for the model.
  • A UserMessage holds what the person typed.
  • An AssistantMessage holds text and zero or more tool calls.
  • A ToolMessage holds the result of one call.

A ToolCall has an id, a tool name, and arguments as a JSON string. These are the same three fields that the chat completions API uses. arguments stays a string because a model can write broken JSON, and the agent must still be able to store and report it.

The describe method has no default branch. Because Message is sealed, the compiler knows all four implementations. If you add a fifth kind later (for example a summary message in Context Engineering for Coding Agents), every incomplete switch becomes a compile error, so none can be forgotten. The guarded case AssistantMessage a when a.toolCalls().isEmpty() comes before the plain AssistantMessage a case. That order makes text replies and tool-call replies print differently.

The problems method checks a rule that OpenAI-compatible servers and chat templates expect: every tool call in an assistant message is answered by a ToolMessage with the same id before the next user or assistant message. The good history has no problems. The broken history fails twice:

  • Message 4 answers call c9, which no assistant message made.
  • Message 5 is a user message while call c2 is still open.

A history with an unanswered call is a common agent bug. It appears when a loop stops between the model's reply and the tool results (Example 7 avoids it). A model that reads such a history sees a call whose outcome it never learns.

The isError flag on ToolMessage records whether the tool failed. The chat completions API has no such field, so Example 15 writes the failure into the text. Other protocols, such as MCP, have a real flag (The Model Context Protocol covers isError).

Example 3: the Tool interface

From the agent's point of view, what is a tool? A tool has four things: a name, a description, a JSON Schema that describes its arguments, and a run method that takes the parsed arguments and returns text. The model only ever sees the first three, packaged as a ToolSpec. The fourth is Java code that the agent runs. The program defines the Tool interface and a small tool, WordCount, and prints the spec that would be sent to the model.

Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

public class ToolInterface {
    static final ObjectMapper JSON = new ObjectMapper();

    record ToolSpec(String name, String description, JsonNode parameters) {}

    interface Tool {
        String name();
        String description();
        JsonNode schema();                                      // JSON Schema of the arguments object
        String run(JsonNode args) throws Exception;
        default boolean readOnly() { return false; }
        default ToolSpec spec() { return new ToolSpec(name(), description(), schema()); }
    }

    static final class WordCount implements Tool {
        public String name() { return "word_count"; }
        public String description() { return "Count the words in a text."; }
        public JsonNode schema() {
            return JSON.createObjectNode().put("type", "object")
                .<com.fasterxml.jackson.databind.node.ObjectNode>set("properties", JSON.createObjectNode()
                    .set("text", JSON.createObjectNode().put("type", "string")))
                .set("required", JSON.createArrayNode().add("text"));
        }
        public String run(JsonNode args) {
            String text = args.get("text").asText().strip();
            return String.valueOf(text.isEmpty() ? 0 : text.split("\\s+").length);
        }
        @Override public boolean readOnly() { return true; }
    }

    public static void main(String[] args) throws Exception {
        Tool tool = new WordCount();
        System.out.println("spec sent to the model: " + JSON.writeValueAsString(tool.spec()));
        System.out.println("read-only: " + tool.readOnly());
        for (String arguments : new String[] {"{\"text\": \"the agent loop calls tools\"}", "{\"text\": \"   \"}"}) {
            JsonNode parsed = JSON.readTree(arguments);              // the model sends arguments as a JSON string
            System.out.println("run(" + arguments + ") = " + tool.run(parsed));
        }
    }
}
Output
spec sent to the model: {"name":"word_count","description":"Count the words in a text.","parameters":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}}
read-only: true
run({"text": "the agent loop calls tools"}) = 5
run({"text": "   "}) = 0

The first output line is the ToolSpec, the part of a tool that the model sees, serialized by Jackson from the record. Its three fields match the function object of a tool definition in a chat completions request (name, description, parameters). The model never sees the Java class, only this JSON, which a chat template renders into the prompt. The description is therefore the tool's documentation for the model, and a vague description produces wrong calls.

readOnly() is true because counting words changes nothing. The agent uses this flag in Example 11 and Project 21 to decide which calls may run in parallel. Permissions, Sandboxing, and Prompt Injection uses it to skip approval prompts for tools that cannot change files.

The two run lines show the call path:

  1. The model sends arguments as a JSON string.
  2. The agent parses the string once into a JsonNode.
  3. The tool reads fields from the tree.

"the agent loop calls tools" has 5 words. The blank text gives 0 because the tool strips it first and checks for the empty string. Without that check, "".split("\\s+") returns one empty element, and the tool would report 1 word.

run returns a String because everything a tool produces ends up as text in the model's context. A tool that lists files or reports an exit code formats its result as text. The format matters, because the model reads it token by token.

Example 4: JSON Schema generated from records by reflection

Writing JSON Schema by hand for every tool is slow, and the schema can drift away from the code. A Java record already declares the argument names and types, so the schema can be generated from the record. The tool is reflection, the Java feature that lets a program inspect classes at run time (here, the components of a record). An annotation, @Param, adds descriptions and marks optional arguments. Annotations and reflection are covered in Enums and Annotations and Packages, Modules, and Reflection.

The program generates schemas for three records and then parses JSON back into one of them. Compare each schema with the record that produced it.

Java
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.RecordComponent;
import java.lang.reflect.Type;
import java.util.List;

public class RecordSchemas {
    static final ObjectMapper JSON = new ObjectMapper();

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.RECORD_COMPONENT)
    @interface Param {
        String value() default "";                              // the description the model reads
        boolean optional() default false;
    }

    enum Mode { OVERWRITE, APPEND }
    record ReadArgs(@Param("Path relative to the workspace root") String path,
                    @Param(value = "First line to return, starting at 1", optional = true) Integer offset,
                    @Param(value = "Maximum number of lines", optional = true) Integer limit) {}
    record WriteArgs(String path, String content, Mode mode) {}
    record GrepArgs(String pattern, List<String> paths, boolean ignoreCase) {}

    static ObjectNode forRecord(Class<? extends Record> type) {
        ObjectNode schema = JSON.createObjectNode().put("type", "object");
        ObjectNode properties = schema.putObject("properties");
        ArrayNode required = JSON.createArrayNode();
        for (RecordComponent c : type.getRecordComponents()) {
            ObjectNode p = forType(c.getGenericType());
            Param param = c.getAnnotation(Param.class);
            if (param != null && !param.value().isEmpty()) p.put("description", param.value());
            properties.set(c.getName(), p);
            if (param == null || !param.optional()) required.add(c.getName());
        }
        schema.set("required", required);
        schema.put("additionalProperties", false);
        return schema;
    }

    @SuppressWarnings("unchecked")
    static ObjectNode forType(Type t) {
        ObjectNode n = JSON.createObjectNode();
        if (t instanceof ParameterizedType p && p.getRawType() == List.class)
            return n.put("type", "array").set("items", forType(p.getActualTypeArguments()[0]));
        Class<?> c = (Class<?>) t;
        if (c == String.class) n.put("type", "string");
        else if (c == int.class || c == Integer.class || c == long.class || c == Long.class) n.put("type", "integer");
        else if (c == double.class || c == Double.class) n.put("type", "number");
        else if (c == boolean.class || c == Boolean.class) n.put("type", "boolean");
        else if (c.isEnum()) {
            ArrayNode values = n.put("type", "string").putArray("enum");
            for (Object k : c.getEnumConstants()) values.add(k.toString());
        } else if (c.isRecord()) return forRecord((Class<? extends Record>) c);
        else throw new IllegalArgumentException("no schema for " + c);
        return n;
    }

    public static void main(String[] args) throws Exception {
        System.out.println(JSON.writerWithDefaultPrettyPrinter().writeValueAsString(forRecord(ReadArgs.class)));
        System.out.println(JSON.writeValueAsString(forRecord(WriteArgs.class)));
        System.out.println(JSON.writeValueAsString(forRecord(GrepArgs.class)));
        ReadArgs parsed = JSON.readValue("{\"path\": \"src/Main.java\", \"limit\": 20}", ReadArgs.class);
        System.out.println("parsed: " + parsed);
    }
}
Output
{
  "type" : "object",
  "properties" : {
    "path" : {
      "type" : "string",
      "description" : "Path relative to the workspace root"
    },
    "offset" : {
      "type" : "integer",
      "description" : "First line to return, starting at 1"
    },
    "limit" : {
      "type" : "integer",
      "description" : "Maximum number of lines"
    }
  },
  "required" : [ "path" ],
  "additionalProperties" : false
}
{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"},"mode":{"type":"string","enum":["OVERWRITE","APPEND"]}},"required":["path","content","mode"],"additionalProperties":false}
{"type":"object","properties":{"pattern":{"type":"string"},"paths":{"type":"array","items":{"type":"string"}},"ignoreCase":{"type":"boolean"}},"required":["pattern","paths","ignoreCase"],"additionalProperties":false}
parsed: ReadArgs[path=src/Main.java, offset=null, limit=20]

forRecord walks the record components in declaration order. For ReadArgs, each component became a property with a type: String maps to "string", and Integer maps to "integer". The @Param text became each property's description. offset and limit are marked optional = true, so required lists only path. additionalProperties: false tells the model (and the validator of Example 6) that no other keys are allowed.

The annotation has @Target(ElementType.RECORD_COMPONENT), so it stays on the record component, where RecordComponent.getAnnotation finds it. @Retention(RetentionPolicy.RUNTIME) keeps it in the class file, so reflection can read it at run time.

The second output line shows an enum. Mode became a string with "enum": ["OVERWRITE","APPEND"], so the model sees the exact allowed values. A grammar built from the schema (as llama-server builds one when tool calls are constrained) can only produce one of them.

The third line shows List<String>. The generic type comes from getGenericType(), a ParameterizedType whose type argument gives the items schema. boolean ignoreCase became a required boolean. A primitive has no "absent" value, so it is required unless annotated otherwise.

The last line shows the other direction. Jackson builds a ReadArgs from the model's JSON through the record's canonical constructor. The missing offset became null. This is why optional arguments use boxed types such as Integer: with a primitive int, a missing value would silently become 0. Generating the schema and parsing into the same record keeps the two in agreement, because renaming a component changes both.

Example 5: registry and dispatch, with unknown tools reported back

The loop needs one place that finds a tool by name and runs it. That place is a registry, a map from tool names to tools. The registry also has to decide what happens with a call to a tool that does not exist, or with arguments that are not valid JSON. Its execute method turns every outcome into a ToolMessage, so the loop never crashes on a bad call. The program sends four calls through a registry: one good call and three bad ones.

Java
import com.fasterxml.jackson.core.JsonProcessingException;
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 RegistryDispatch {
    static final ObjectMapper JSON = new ObjectMapper();
    record ToolCall(String id, String name, String arguments) {}
    record ToolMessage(String callId, String name, String content, boolean isError) {}

    interface Tool {
        String name();
        String run(JsonNode args) throws Exception;
    }
    record SimpleTool(String name, Map<String, String> files) implements Tool {
        public String run(JsonNode args) { return files.getOrDefault(args.path("path").asText(), "(no such file)"); }
    }

    static final class ToolRegistry {
        private final Map<String, Tool> tools = new LinkedHashMap<>();
        ToolRegistry register(Tool tool) { tools.put(tool.name(), tool); return this; }

        ToolMessage execute(ToolCall call) {
            Tool tool = tools.get(call.name());
            if (tool == null)
                return new ToolMessage(call.id(), call.name(), "unknown tool '" + call.name() + "'. Available tools: " + String.join(", ", tools.keySet()), true);
            try {
                JsonNode args = JSON.readTree(call.arguments().isBlank() ? "{}" : call.arguments());
                return new ToolMessage(call.id(), call.name(), tool.run(args), false);
            } catch (JsonProcessingException e) {
                return new ToolMessage(call.id(), call.name(), "arguments are not valid JSON: " + e.getOriginalMessage(), true);
            } catch (Exception e) {
                return new ToolMessage(call.id(), call.name(), e.getClass().getSimpleName() + ": " + e.getMessage(), true);
            }
        }
    }

    public static void main(String[] args) {
        Map<String, String> files = Map.of("pom.xml", "<project>demo</project>");
        ToolRegistry registry = new ToolRegistry()
            .register(new SimpleTool("read_file", files))
            .register(new SimpleTool("list_files", files));
        List<ToolCall> calls = List.of(
            new ToolCall("c1", "read_file", "{\"path\": \"pom.xml\"}"),
            new ToolCall("c2", "reed_file", "{\"path\": \"pom.xml\"}"),
            new ToolCall("c3", "read_file", "{\"path\": pom.xml}"),
            new ToolCall("c4", "read_file", ""));
        for (ToolCall call : calls) {
            ToolMessage r = registry.execute(call);
            System.out.println(r.callId() + (r.isError() ? " error: " : " ok:    ") + r.content());
        }
    }
}
Output
c1 ok:    <project>demo</project>
c2 error: unknown tool 'reed_file'. Available tools: read_file, list_files
c3 error: arguments are not valid JSON: Unrecognized token 'pom': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')
c4 ok:    (no such file)

Call c1 is valid and returns the file. Call c2 names reed_file, a misspelling that small models produce. The registry does not throw an exception. It returns an error result that names the unknown tool and lists the available ones. The model reads this in its next step and can retry with a correct name. It could not do that if the agent stopped or skipped the call.

Call c3 has {"path": pom.xml}. The value is not quoted, so it is not JSON. Jackson's error message (Unrecognized token 'pom') goes back to the model. getOriginalMessage() returns the message without the location suffix (at [Source: ...; line: 1, column: ...]) that Jackson adds to getMessage(). The suffix describes Jackson's input, not the model's call, so it would only confuse the model.

Call c4 has empty arguments. Some servers and models send "" for a tool with no parameters, so the registry treats a blank string as {}. The tool then looked up the path "" and returned (no such file). That result says ok because the tool itself decided how to report a missing file. Example 6 adds validation, so that a missing required argument is caught before the tool runs.

The rule behind all four cases: every tool call gets exactly one result with the call's id, whatever happens. Example 2 showed why. An unanswered call breaks the history.

Example 6: argument validation errors fed back to the model

How can the agent tell the model exactly what was wrong with its arguments? A small validator checks the arguments against the tool's schema before the tool runs. It produces messages that name the property and the problem, such as "expected integer, got string". The program defines the validator and runs it on eight attempts that a model might make.

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

public class ArgumentValidation {
    static final ObjectMapper JSON = new ObjectMapper();

    static List<String> validate(JsonNode schema, JsonNode value) {
        List<String> errors = new ArrayList<>();
        check(schema, value, "arguments", errors);
        return errors;
    }

    static void check(JsonNode s, JsonNode v, String path, List<String> errors) {
        String type = s.path("type").asText();
        boolean ok = switch (type) {
            case "object" -> v.isObject();
            case "array" -> v.isArray();
            case "string" -> v.isTextual();
            case "integer" -> v.isIntegralNumber();
            case "number" -> v.isNumber();
            case "boolean" -> v.isBoolean();
            default -> true;
        };
        if (!ok) { errors.add(path + ": expected " + type + ", got " + kind(v)); return; }
        if (s.has("enum")) {
            boolean found = false;
            for (JsonNode e : s.get("enum")) found |= e.equals(v);
            if (!found) errors.add(path + ": must be one of " + s.get("enum"));
        }
        if (v.isObject()) {
            for (JsonNode r : s.path("required"))
                if (!v.has(r.asText())) errors.add(path + ": missing required property '" + r.asText() + "'");
            for (Map.Entry<String, JsonNode> e : v.properties()) {
                JsonNode ps = s.path("properties").get(e.getKey());
                if (ps != null) check(ps, e.getValue(), path + "." + e.getKey(), errors);
                else if (!s.path("additionalProperties").asBoolean(true)) errors.add(path + ": unknown property '" + e.getKey() + "'");
            }
        }
        if (v.isArray() && s.has("items"))
            for (int i = 0; i < v.size(); i++) check(s.get("items"), v.get(i), path + "[" + i + "]", errors);
    }

    static String kind(JsonNode v) {
        return v.isNull() ? "null" : v.isTextual() ? "string" : v.isIntegralNumber() ? "integer"
                : v.isNumber() ? "number" : v.isBoolean() ? "boolean" : v.isArray() ? "array" : "object";
    }

    public static void main(String[] args) throws Exception {
        JsonNode schema = JSON.readTree("""
            {"type": "object", "properties": {
               "path": {"type": "string"}, "limit": {"type": "integer"},
               "mode": {"type": "string", "enum": ["OVERWRITE", "APPEND"]},
               "paths": {"type": "array", "items": {"type": "string"}}},
             "required": ["path"], "additionalProperties": false}""");
        String[] attempts = {
            "{\"path\": \"pom.xml\", \"limit\": 20}",
            "{\"limit\": 20}",
            "{\"path\": \"pom.xml\", \"limit\": \"20\"}",
            "{\"path\": \"pom.xml\", \"limit\": 2.5}",
            "{\"path\": \"pom.xml\", \"mode\": \"append\"}",
            "{\"path\": \"pom.xml\", \"paths\": [\"a\", 7]}",
            "{\"file\": \"pom.xml\"}",
            "[\"pom.xml\"]"};
        for (String a : attempts) {
            List<String> errors = validate(schema, JSON.readTree(a));
            System.out.println(a);
            System.out.println("  -> " + (errors.isEmpty() ? "valid" : "invalid arguments: " + String.join("; ", errors)));
        }
    }
}
Output
{"path": "pom.xml", "limit": 20}
  -> valid
{"limit": 20}
  -> invalid arguments: arguments: missing required property 'path'
{"path": "pom.xml", "limit": "20"}
  -> invalid arguments: arguments.limit: expected integer, got string
{"path": "pom.xml", "limit": 2.5}
  -> invalid arguments: arguments.limit: expected integer, got number
{"path": "pom.xml", "mode": "append"}
  -> invalid arguments: arguments.mode: must be one of ["OVERWRITE","APPEND"]
{"path": "pom.xml", "paths": ["a", 7]}
  -> invalid arguments: arguments.paths[1]: expected string, got integer
{"file": "pom.xml"}
  -> invalid arguments: arguments: missing required property 'path'; arguments: unknown property 'file'
["pom.xml"]
  -> invalid arguments: arguments: expected object, got array

The validator supports the subset of JSON Schema that Example 4 generates: type, properties, required, additionalProperties, enum, and items. Each pair of output lines is one attempt, followed by the message that the agent would return. Going through the attempts in order:

  1. The first attempt is valid.
  2. The second omits path, which required lists.
  3. The third sends "20" (a string) for an integer.
  4. The fourth sends 2.5. isIntegralNumber() is false for a decimal, so the message says got number.
  5. The fifth sends "append" in lower case. Enum comparison is exact, and the message lists the allowed values so the model can copy one.
  6. The sixth has a wrong element inside an array, and the path arguments.paths[1] points at it.
  7. The seventh uses file for path, a common mistake, and gets two messages: the missing property and the unknown one.
  8. The eighth is an array instead of an object.

Each message starts with a path (arguments.limit), states the expectation, and states what arrived. A model fixes its call more often when the error says what to change than when it only says "invalid". The validator runs before the tool, so the tool code can assume that its arguments have the right shape. The long NullPointerException text that Example 8 shows for a missing argument never reaches the model.

Constrained decoding (the course's lesson on constrained decoding and structured output covers it) can make invalid arguments impossible for servers that support it. An agent still validates, for two reasons. Not every server constrains tool calls. And a schema cannot express every rule, such as "the path must exist".

Example 7: stop conditions

A loop that never stops is a bug that costs money. When does the agent loop stop? It stops for one of four reasons:

  • The model answered without a tool call (the normal case).
  • The step limit was reached.
  • The token budget was used up.
  • The user cancelled.

The program runs the same loop four times, with different fake models and limits, once for each reason. The result record also reports historyValid, which is true when the history does not end with an unanswered call.

Java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;

public class StopConditions {
    sealed interface Message permits UserMessage, AssistantMessage, ToolMessage {}
    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 ModelResponse(AssistantMessage message, int promptTokens, int completionTokens) {}
    interface ModelClient { ModelResponse chat(List<Message> messages); }

    enum StopReason { COMPLETED, MAX_STEPS, MAX_TOKENS, CANCELLED }
    record AgentResult(StopReason stopReason, int steps, int tokens, boolean historyValid) {}

    static AgentResult run(ModelClient model, int maxSteps, int maxTokens, AtomicBoolean cancelled) {
        List<Message> messages = new ArrayList<>(List.of(new UserMessage("Fix the build")));
        int steps = 0, tokens = 0;
        while (true) {
            StopReason stop = cancelled.get() ? StopReason.CANCELLED
                    : steps >= maxSteps ? StopReason.MAX_STEPS
                    : tokens >= maxTokens ? StopReason.MAX_TOKENS : null;
            if (stop != null) return new AgentResult(stop, steps, tokens, messages.getLast() instanceof ToolMessage);
            ModelResponse r = model.chat(List.copyOf(messages));
            steps++;
            tokens += r.promptTokens() + r.completionTokens();
            messages.add(r.message());
            if (r.message().toolCalls().isEmpty()) return new AgentResult(StopReason.COMPLETED, steps, tokens, true);
            for (ToolCall c : r.message().toolCalls()) {
                messages.add(new ToolMessage(c.id(), c.name(), "BUILD FAILURE (line " + steps + ")", false));
                if (c.name().equals("ask_user_to_stop")) cancelled.set(true);   // stands in for Ctrl+C
            }
        }
    }

    /** A fake model that calls `tool` on every step, or answers in text on step `answerAt`. */
    static ModelClient fake(String tool, int answerAt) {
        return messages -> {
            int step = (int) messages.stream().filter(m -> m instanceof AssistantMessage).count() + 1;
            AssistantMessage reply = step == answerAt ? new AssistantMessage("The build passes.", List.of())
                    : new AssistantMessage("", List.of(new ToolCall("c" + step, tool, "{}")));
            return new ModelResponse(reply, 100 + 20 * messages.size(), 15);   // the prompt grows with the history
        };
    }

    public static void main(String[] args) {
        System.out.println(run(fake("run_build", 3), 10, 100_000, new AtomicBoolean()));
        System.out.println(run(fake("run_build", 99), 4, 100_000, new AtomicBoolean()));
        System.out.println(run(fake("run_build", 99), 50, 300, new AtomicBoolean()));
        System.out.println(run(fake("ask_user_to_stop", 99), 10, 100_000, new AtomicBoolean()));
    }
}
Output
AgentResult[stopReason=COMPLETED, steps=3, tokens=525, historyValid=true]
AgentResult[stopReason=MAX_STEPS, steps=4, tokens=780, historyValid=true]
AgentResult[stopReason=MAX_TOKENS, steps=2, tokens=310, historyValid=true]
AgentResult[stopReason=CANCELLED, steps=1, tokens=135, historyValid=true]

Each output line is one run. The fake model's prompt token count grows with the history (100 plus 20 per message), as a real prompt does, and every reply costs 15 completion tokens. The cost of each step is therefore:

Step Messages sent Prompt tokens Completion tokens Step total
1 1 100 + 20 = 120 15 135
2 3 100 + 60 = 160 15 175
3 5 100 + 100 = 200 15 215
4 7 100 + 140 = 240 15 255

The first run is the normal case. The model called run_build twice and answered in text in step 3, so the reason is COMPLETED after 135 + 175 + 215 = 525 tokens.

The second run uses a model that never stops calling tools, with maxSteps = 4. After four steps (135 + 175 + 215 + 255 = 780 tokens), the loop stops with MAX_STEPS. Without a step limit, this run would never end. This is the most common failure of agents with small models: they repeat a call that fails.

The third run has a budget of 300 tokens. After step 2 the total is 135 + 175 = 310, which is at or above the budget, so the loop stops with MAX_TOKENS before the third model call. The check runs before each model call, not in the middle of a step, so a run can exceed the budget by at most one step. Counting prompt tokens on every step matches how servers report usage. The whole history is sent each time, so a ten-step run pays for its first message ten times, unless the server reuses its prompt cache.

The fourth run simulates a user who presses Ctrl+C. The tool ask_user_to_stop sets the cancellation flag. The loop checks the flag before the next model call and stops with CANCELLED after one step.

historyValid is true in all four runs. Every limit is checked at the top of the loop, after all tool results of the previous step are in the history. So the history never ends with an unanswered call. A run stopped this way can be resumed later by adding a user message and calling the loop again.

Example 8: tool exceptions returned as results, and the model retries

Tools fail all the time: files are missing, paths are directories, arguments are absent. What should the agent do when a tool throws? It catches the exception and returns the exception type and message as an error result, so the model can see what went wrong and try something else. The program uses real files in a temporary directory. The fake model first guesses a wrong path. Then it reads the error and asks for the path that the error suggests.

Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class ToolErrors {
    static final ObjectMapper JSON = new ObjectMapper();
    record ToolCall(String id, String name, String arguments) {}
    record ToolMessage(String callId, String content, boolean isError) {}

    interface Tool { String run(JsonNode args) throws Exception; }

    static ToolMessage execute(Tool tool, ToolCall call) {
        try {
            return new ToolMessage(call.id(), tool.run(JSON.readTree(call.arguments())), false);
        } catch (Exception e) {                                  // the agent keeps running; the model reads the error
            return new ToolMessage(call.id(), e.getClass().getSimpleName() + ": " + e.getMessage(), true);
        }
    }

    public static void main(String[] args) throws Exception {
        Path root = Files.createTempDirectory("ws");
        Files.createDirectories(root.resolve("src"));
        Files.writeString(root.resolve("src/Main.java"), "class Main {}\n");
        Tool read = a -> Files.readString(root.resolve(a.get("path").asText()));

        // the fake model: tries a guess, and after an error asks for the path the error suggests
        List<ToolMessage> seen = new ArrayList<>();
        List<ToolCall> plan = new ArrayList<>(List.of(new ToolCall("c1", "read_file", "{\"path\": \"Main.java\"}")));
        for (int step = 1; !plan.isEmpty(); step++) {
            ToolCall call = plan.removeFirst();
            ToolMessage result = execute(read, call);
            seen.add(result);
            System.out.println("step " + step + " " + call.arguments() + " -> " + (result.isError() ? "error " : "ok ") + result.content().strip().replace(root.toString(), "<root>"));
            if (result.isError() && result.content().startsWith("NoSuchFileException"))
                plan.add(new ToolCall("c" + (step + 1), "read_file", "{\"path\": \"src/Main.java\"}"));
        }
        ToolMessage typeError = execute(read, new ToolCall("c3", "read_file", "{}"));
        System.out.println("missing argument -> " + typeError.content());
        ToolMessage directory = execute(read, new ToolCall("c4", "read_file", "{\"path\": \"src\"}"));
        System.out.println("directory -> " + directory.content().replace(root.toString(), "<root>"));
        System.out.println("errors seen by the model: " + seen.stream().filter(ToolMessage::isError).count());
    }
}
Output
step 1 {"path": "Main.java"} -> error NoSuchFileException: <root>/Main.java
step 2 {"path": "src/Main.java"} -> ok class Main {}
missing argument -> NullPointerException: Cannot invoke "com.fasterxml.jackson.databind.JsonNode.asText()" because the return value of "com.fasterxml.jackson.databind.JsonNode.get(String)" is null
directory -> IOException: Is a directory
errors seen by the model: 1

In step 1 the fake model guessed Main.java at the workspace root. Files.readString threw NoSuchFileException, whose message is the full path. The program replaces the random temporary directory with <root>, so that the output is the same on every run. The agent returned the text as an error result. In step 2 the model tried src/Main.java, and the read succeeded. This is the retry pattern that agents depend on: errors are information for the model, not reasons to stop.

The next line shows a tool bug, not a model mistake. With {} as arguments, a.get("path") returned null, and calling a method on it threw a NullPointerException. Its message is long and talks about JsonNode.get(String). The message is accurate, but it describes the tool's code, not what the model did wrong. Validation (Example 6) catches the missing argument first and returns missing required property 'path', which the model can act on.

Reading a directory threw IOException: Is a directory. The message comes from the operating system through the JDK, and it is clear enough for a model to switch to a listing tool.

The last line counts one error among the calls that the fake model made, because the second attempt succeeded. A real agent shows such errors to the user too (Example 13). A run with many tool errors often means that a prompt or a tool description needs work.

Example 9: per-tool timeouts

Some tools can hang, for example a command that waits for input or a network call that never answers. How does the agent stop a tool that takes too long? The registry runs each tool on its own virtual thread through a FutureTask and waits for the result with a timeout. A virtual thread is a lightweight Java thread, so starting one per tool call is cheap. Virtual threads are covered in Virtual Threads.

The program runs three tools: a fast one, a slow one that responds to interruption, and a stubborn one that ignores interruption. The point of the third is to show a limit of Java.

Java
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;

public class ToolTimeouts {
    static String runWithTimeout(String name, Callable<String> tool, long timeoutMillis) {
        FutureTask<String> task = new FutureTask<>(tool);
        Thread.ofVirtual().name("tool-" + name).start(task);
        try {
            return "ok: " + task.get(timeoutMillis, TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            task.cancel(true);                                   // interrupts the tool's thread
            return "error: " + name + " timed out after " + timeoutMillis + " ms";
        } catch (ExecutionException e) {
            return "error: " + e.getCause();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "error: interrupted";
        }
    }

    public static void main(String[] args) throws Exception {
        AtomicBoolean slowSawInterrupt = new AtomicBoolean();
        AtomicBoolean stubbornFinished = new AtomicBoolean();
        Thread[] threads = new Thread[2];                       // the tool threads, to wait for them at the end

        System.out.println(runWithTimeout("fast", () -> "42", 1000));
        System.out.println(runWithTimeout("slow", () -> {
            threads[0] = Thread.currentThread();
            try { Thread.sleep(10_000); } catch (InterruptedException e) { slowSawInterrupt.set(true); throw e; }
            return "never";
        }, 1000));
        System.out.println(runWithTimeout("stubborn", () -> {
            threads[1] = Thread.currentThread();
            for (int i = 0; i < 6; i++) {
                try { Thread.sleep(300); } catch (InterruptedException ignored) { }   // swallows the interrupt
            }
            stubbornFinished.set(true);
            return "late";
        }, 1000));
        threads[0].join();
        System.out.println("slow tool saw the interrupt: " + slowSawInterrupt.get());
        threads[1].join();
        System.out.println("stubborn tool ran to the end anyway: " + stubbornFinished.get());
    }
}
Output
ok: 42
error: slow timed out after 1000 ms
error: stubborn timed out after 1000 ms
slow tool saw the interrupt: true
stubborn tool ran to the end anyway: true

The fast tool returned within the 1,000 ms limit, so its result is ok: 42.

The slow tool sleeps for 10 seconds. task.get(1000, MILLISECONDS) threw TimeoutException, the agent returned an error result, and task.cancel(true) interrupted the tool's thread. Interrupting a thread asks it to stop: a blocking call such as Thread.sleep then throws InterruptedException. The fourth output line confirms that the slow tool's Thread.sleep received the interrupt and stopped.

The stubborn tool also timed out, but it catches InterruptedException and keeps going. The fifth line shows that it ran all six sleeps to the end, after the agent had already reported a timeout. Java cannot kill a thread. Interruption is a request that the code must honor. A tool that blocks in code which ignores interrupts (a busy loop, some native calls) keeps running. So tools that start external work must stop it themselves. The shell tool lesson does this for processes with destroy and destroyForcibly, because a child process does not stop when a Java thread is interrupted.

Per-tool timeouts differ in practice. A file read needs milliseconds, while a build or test run can need minutes. Project 21 takes the timeout as a registry setting. The shell tool in The Shell Tool takes its own timeout per command.

Example 10: output truncation with head and tail

Tool output goes into the model's context, and every character costs tokens on every later step. A build log or a large file can have thousands of lines. How can long output fit into the context? The truncation in this example keeps whole lines from the beginning (the head) and from the end (the tail), and replaces the middle with one marker line that says how much was removed. It also handles the special case of a single very long line, such as a minified file.

Java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class HeadAndTail {
    /** Keeps whole lines from the head and the tail, about maxChars / 2 characters each. */
    static String truncate(String text, int maxChars) {
        if (text.length() <= maxChars) return text;
        int budget = maxChars / 2;
        String[] lines = text.split("\n", -1);
        int head = 0, used = 0;
        while (head < lines.length && used + lines[head].length() + 1 <= budget) used += lines[head++].length() + 1;
        int tail = lines.length;
        used = 0;
        while (tail > head && used + lines[tail - 1].length() + 1 <= budget) used += lines[--tail].length() + 1;
        int omitted = tail - head;
        String marker = "[... " + omitted + (omitted == 1 ? " line" : " lines") + " omitted, " + text.length() + " characters in total ...]";
        if (head == 0 && tail == lines.length)                   // one huge line: cut characters instead
            return text.substring(0, budget) + "\n" + marker + "\n" + text.substring(text.length() - budget);
        List<String> out = new ArrayList<>(Arrays.asList(lines).subList(0, head));
        out.add(marker);
        out.addAll(Arrays.asList(lines).subList(tail, lines.length));
        return String.join("\n", out);
    }

    public static void main(String[] args) {
        String buildLog = IntStream.rangeClosed(1, 200).mapToObj(i -> "[INFO] Compiling file " + i + " of 200")
            .collect(Collectors.joining("\n"))
            + "\n[ERROR] Main.java:[12,5] cannot find symbol\n[INFO] BUILD FAILURE";
        System.out.println("log: " + buildLog.length() + " characters, " + buildLog.lines().count() + " lines");
        System.out.println(truncate(buildLog, 300));
        System.out.println();
        String minified = "x".repeat(5000);
        String cut = truncate(minified, 40);
        System.out.println(cut);
        System.out.println("short text unchanged: " + truncate("BUILD SUCCESS", 300).equals("BUILD SUCCESS"));
    }
}
Output
log: 6556 characters, 202 lines
[INFO] Compiling file 1 of 200
[INFO] Compiling file 2 of 200
[INFO] Compiling file 3 of 200
[INFO] Compiling file 4 of 200
[... 194 lines omitted, 6556 characters in total ...]
[INFO] Compiling file 199 of 200
[INFO] Compiling file 200 of 200
[ERROR] Main.java:[12,5] cannot find symbol
[INFO] BUILD FAILURE

xxxxxxxxxxxxxxxxxxxx
[... 1 line omitted, 5000 characters in total ...]
xxxxxxxxxxxxxxxxxxxx
short text unchanged: true

The build log has 6,556 characters in 202 lines. With a limit of 300 characters, the head budget is 150 and the tail budget is 150. Four head lines fit (31 characters each, counting the newline). Four tail lines fit (131 characters together). The marker says that 194 lines were omitted out of 6,556 characters.

The tail kept the [ERROR] line and BUILD FAILURE. This is why truncation keeps the tail. Compilers, test runners, and stack traces put the important part at the end, and a head-only cut would remove exactly the information that the model needs. The head keeps the first lines of the command's output, which often show what ran.

The marker states how much was removed. A model that sees the count can decide to read a specific part again (with an offset, as the read tool in File Tools allows) instead of assuming that it saw everything.

The second case is one line of 5,000 characters. No whole line fits in a budget of 20 characters, so the method cuts characters instead: 20 from the start, the marker, and 20 from the end. The last output line confirms that short text passes through unchanged.

A limit in characters is an estimate of a limit in tokens. The context engineering lesson compares such estimates with real token counts and sets the limits from a token budget.

Example 11: independent tool calls in parallel on virtual threads

Models can request several calls in one turn (parallel tool calls), for example three file reads. Running them one after another wastes time, but running them at the same time raises a question about result order. How can the agent run the calls together and still return the results in a fixed order?

The program runs three calls on virtual threads. It uses latches (a CountDownLatch makes threads wait until a count reaches zero) for two purposes. One latch proves that the calls overlap in time. The other forces c3 to finish before the others, so that the completion order differs from the call order.

Java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

public class ParallelCalls {
    record ToolCall(String id, String name, String path) {}
    record ToolMessage(String callId, String content) {}

    public static void main(String[] args) throws Exception {
        List<ToolCall> calls = List.of(new ToolCall("c1", "read_file", "pom.xml"),
            new ToolCall("c2", "read_file", "README.md"), new ToolCall("c3", "read_file", "src/Main.java"));
        CountDownLatch allStarted = new CountDownLatch(calls.size());
        CountDownLatch c3Done = new CountDownLatch(1);
        List<String> completionOrder = java.util.Collections.synchronizedList(new ArrayList<>());

        List<Future<ToolMessage>> futures = new ArrayList<>();
        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            for (ToolCall call : calls) {
                futures.add(pool.submit(() -> {
                    allStarted.countDown();
                    boolean together = allStarted.await(10, TimeUnit.SECONDS);   // true only if all three run at once
                    if (!call.id().equals("c3")) c3Done.await();                // force c3 to finish first
                    completionOrder.add(call.id());
                    if (call.id().equals("c3")) c3Done.countDown();
                    return new ToolMessage(call.id(), "contents of " + call.path() + (together ? "" : " (ran alone)"));
                }));
            }
        }                                                            // close() waits for all tasks
        System.out.println("first to finish: " + completionOrder.getFirst());
        for (Future<ToolMessage> f : futures) {                      // results in call order, not completion order
            ToolMessage m = f.resultNow();
            System.out.println(m.callId() + " -> " + m.content());
        }
    }
}
Output
first to finish: c3
c1 -> contents of pom.xml
c2 -> contents of README.md
c3 -> contents of src/Main.java

newVirtualThreadPerTaskExecutor started one virtual thread per call. Each task counted down allStarted and waited for it to reach zero. The wait succeeded within 10 seconds only because all three tasks were running at the same moment. A sequential execution would have left the first task waiting alone, and its result would say (ran alone). None of the results carries that suffix, so the three calls overlapped.

The second latch forced c3 to finish first, and the first output line confirms it. The results are still printed as c1, c2, c3. The program collects them by iterating over the futures in call order, not in the order in which they completed. The order matters for two reasons:

  • Some chat templates drop call ids and pair results with calls by position only. The Qwen 2.5 template does, as Chat Templates, Roles, and Special Tokens showed. Out-of-order results then give the model wrong information without any error.
  • A fixed order keeps transcripts and tests reproducible.

The try-with-resources block matters too. ExecutorService.close() waits until every task has finished, so after the block every future is complete and resultNow() cannot block.

Only independent calls may run in parallel. Two edits to the same file, or a write followed by a build, depend on order. Project 21 therefore runs a batch in parallel only when every call in it targets a tool marked readOnly(). It runs all other batches one call at a time, in the model's order.

Example 12: the system prompt of a coding agent

The system prompt is the first message of the conversation. It tells the model its role, its rules, how to use the tools, and facts about the environment. What goes into the system prompt of a coding agent? The program builds a minimal prompt and a sectioned prompt, prints the full one, and compares the sizes. Token counts here are estimates: the number of characters divided by 4, a common rule of thumb for English text.

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

public class SystemPrompts {
    static String build(Map<String, String> sections) {
        StringBuilder sb = new StringBuilder();
        sections.forEach((title, body) -> sb.append(title.isEmpty() ? "" : "# " + title + "\n").append(body.strip()).append("\n\n"));
        return sb.toString().strip();
    }

    public static void main(String[] args) {
        String minimal = build(Map.of("", "You are a coding agent. Use the tools to inspect and change files. "
            + "When the task is done, reply with a short summary and no tool call."));

        Map<String, String> sections = new LinkedHashMap<>();
        sections.put("", "You are loop, a coding agent working in the user's repository.");
        sections.put("Rules", """
            - Read a file before you edit it.
            - Make the smallest change that completes the task.
            - Run the tests after every change and read the failures.
            - Do not invent file contents; use read_file.
            - Stop and ask when the task is ambiguous.""");
        sections.put("Tools", """
            read_file reads a text file. write_file creates or replaces a file. edit_file replaces one exact
            string. run_command runs a shell command in the workspace and returns its exit code and output.""");
        sections.put("Output", "Reply in plain text. Keep answers under 10 lines unless the user asks for more.");
        sections.put("Environment", """
            Working directory: /work/demo
            Operating system: Linux
            Date: 2026-10-03
            Git repository: yes""");
        sections.put("Project instructions", "From AGENTS.md: build with `mvn -q package`; tests live in src/test/java.");
        String full = build(sections);

        for (var e : List.of(Map.entry("minimal", minimal), Map.entry("full", full)))
            System.out.printf(Locale.ROOT, "%-8s %4d characters, about %3d tokens%n", e.getKey(), e.getValue().length(), e.getValue().length() / 4);
        System.out.println("---");
        System.out.println(full);
    }
}
Output
minimal   134 characters, about  33 tokens
full      800 characters, about 200 tokens
---
You are loop, a coding agent working in the user's repository.

# Rules
- Read a file before you edit it.
- Make the smallest change that completes the task.
- Run the tests after every change and read the failures.
- Do not invent file contents; use read_file.
- Stop and ask when the task is ambiguous.

# Tools
read_file reads a text file. write_file creates or replaces a file. edit_file replaces one exact
string. run_command runs a shell command in the workspace and returns its exit code and output.

# Output
Reply in plain text. Keep answers under 10 lines unless the user asks for more.

# Environment
Working directory: /work/demo
Operating system: Linux
Date: 2026-10-03
Git repository: yes

# Project instructions
From AGENTS.md: build with `mvn -q package`; tests live in src/test/java.

The minimal prompt is 134 characters, about 33 tokens by the characters-divided-by-4 estimate. The real Qwen2 tokenizer, run with llama-tokenize on ggml-vocab-qwen2.gguf, gives 31. The prompt states the role, says to use tools, and defines when to stop: a reply with no tool call. That last sentence matters because the loop's normal exit (Example 7) depends on the model choosing to answer in text.

The full prompt is 800 characters, about 200 tokens by the estimate (193 with the real tokenizer). Its sections each answer one question for the model:

Section What it tells the model
Role Who it is and where it works
Rules Behavior: read before edit, small changes, run tests, do not invent content
Tools When to use each tool, in plain words, in addition to the schemas
Output Format and length of answers
Environment Working directory, OS, date, whether git is present
Project instructions Text from files such as AGENTS.md in the repository

The environment section exists because the model cannot see the machine. Without the working directory and the operating system, a model guesses paths and commands. The date is fixed here (2026-10-03) so that the output is stable. A real agent fills it in at start. The project instructions section is where repository-specific rules go. The context engineering lesson loads them from instruction files.

A longer prompt is not free. It is sent on every step, and with a context of 8,192 tokens a 2,000-token system prompt leaves much less room for files and output. The computation cost is paid once per session when the server's prompt cache keeps the prefix (the context engineering lesson measures this). The criteria for adding a line to the prompt: it changes a behavior that was observed to be wrong, and it does not repeat what the tool schemas already say.

Example 13: event listeners for the UI

A terminal, a log file, and a test may all need to watch the same agent run. If the loop printed to the terminal itself, it could not be reused. The solution is the listener pattern: the agent calls an interface at fixed points, and each interested party implements that interface. The points are after each model reply, before and after each tool call, and when the run stops.

The program feeds the events of a short run to two listeners. ConsoleView prints what a terminal user would see. Stats counts calls and errors.

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

public class Listeners {
    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) {}
    enum StopReason { COMPLETED, MAX_STEPS, MAX_TOKENS, CANCELLED }
    record AgentResult(StopReason stopReason, String finalText, int steps, int tokens) {}

    interface AgentListener {
        default void onAssistant(AssistantMessage message) {}
        default void onToolCall(ToolCall call) {}
        default void onToolResult(ToolMessage result) {}
        default void onStop(AgentResult result) {}
    }

    /** What a terminal UI prints. */
    static final class ConsoleView implements AgentListener {
        public void onAssistant(AssistantMessage m) { if (!m.text().isEmpty()) System.out.println("assistant: " + m.text()); }
        public void onToolCall(ToolCall c) { System.out.println("  > " + c.name() + " " + c.arguments()); }
        public void onToolResult(ToolMessage r) {
            String first = r.content().lines().findFirst().orElse("");
            System.out.println("  < " + (r.isError() ? "error: " : "") + first + " (" + r.content().lines().count() + " lines)");
        }
        public void onStop(AgentResult r) { System.out.println("[" + r.stopReason() + " after " + r.steps() + " steps, " + r.tokens() + " tokens]"); }
    }

    /** Counts calls per tool, for a status line or a log. */
    static final class Stats implements AgentListener {
        final Map<String, Integer> calls = new TreeMap<>();
        int errors;
        public void onToolCall(ToolCall c) { calls.merge(c.name(), 1, Integer::sum); }
        public void onToolResult(ToolMessage r) { if (r.isError()) errors++; }
    }

    public static void main(String[] args) {
        List<AgentListener> listeners = new CopyOnWriteArrayList<>();
        ConsoleView view = new ConsoleView();
        Stats stats = new Stats();
        listeners.add(view);
        listeners.add(stats);

        // the events an agent run emits, in order (Example 14 emits them from the real loop)
        ToolCall c1 = new ToolCall("c1", "read_file", "{\"path\":\"Main.java\"}");
        ToolCall c2 = new ToolCall("c2", "read_file", "{\"path\":\"src/Main.java\"}");
        listeners.forEach(l -> l.onAssistant(new AssistantMessage("I will read the main class.", List.of(c1))));
        listeners.forEach(l -> l.onToolCall(c1));
        listeners.forEach(l -> l.onToolResult(new ToolMessage("c1", "read_file", "NoSuchFileException: Main.java", true)));
        listeners.forEach(l -> l.onAssistant(new AssistantMessage("", List.of(c2))));
        listeners.forEach(l -> l.onToolCall(c2));
        listeners.forEach(l -> l.onToolResult(new ToolMessage("c2", "read_file", "class Main {\n}\n", false)));
        listeners.forEach(l -> l.onAssistant(new AssistantMessage("Main is an empty class.", List.of())));
        listeners.forEach(l -> l.onStop(new AgentResult(StopReason.COMPLETED, "Main is an empty class.", 3, 412)));
        System.out.println("stats: calls=" + stats.calls + " errors=" + stats.errors);
    }
}
Output
assistant: I will read the main class.
  > read_file {"path":"Main.java"}
  < error: NoSuchFileException: Main.java (1 lines)
  > read_file {"path":"src/Main.java"}
  < class Main { (2 lines)
assistant: Main is an empty class.
[COMPLETED after 3 steps, 412 tokens]
stats: calls={read_file=2} errors=1

ConsoleView printed one line per event. The assistant's text appears when there is text, and the empty text of the second reply, which only contained a call, printed nothing. Tool calls start with > and results with <. For results, the view prints only the first line and the line count, because a terminal that echoes whole files becomes unreadable. The model still receives the full result. The error result shows error:, so the user sees the failed attempt and the retry. The last line of the view reports the stop reason, the steps, and the tokens.

Stats saw the same events and counted two read_file calls and one error. It has no output of its own until the program asks for it. A TreeMap keeps the tool names sorted, so the printed map has a stable order.

The interface has default methods, so each listener overrides only the events it needs. The list is a CopyOnWriteArrayList (a list that is safe to modify while other threads iterate it) because Project 21 calls listeners from the agent's thread, while another thread, for example a key handler that cancels, may add a listener. Listeners keep UI code out of the loop. The same agent runs under a terminal renderer in Workshop: loop agent, under a JSON event writer in headless mode, and under a recording listener in tests.

Example 14: Project 21, an Agent class with tests against the fake model

Now the pieces come together. This is Project 21, one program that later lessons extend. It has four parts:

  1. The message types (Message and its four records).
  2. The model side: the ModelClient interface and a ScriptedModel that replays canned replies.
  3. The tool framework: Tool, Schemas, and ToolRegistry.
  4. The Agent class with its loop.

The main method is a set of ten tests that run against ScriptedModel. To read the code, start with the Agent.run method: it is the loop of Example 1 with the stop conditions of Example 7. Then look at ToolRegistry.execute, which combines Examples 5, 6, 9, and 10. Finally read the tests, which show how each behavior is checked.

Java
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.RecordComponent;
import java.lang.reflect.Type;
import java.nio.file.NoSuchFileException;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class AgentProject {
    static final ObjectMapper JSON = new ObjectMapper();

    // ---------------- Project 21: messages ----------------
    sealed interface Message permits SystemMessage, UserMessage, AssistantMessage, ToolMessage {}
    record SystemMessage(String text) implements Message {}
    record UserMessage(String text) implements Message {}
    record ToolCall(String id, String name, String arguments) {}
    record AssistantMessage(String text, List<ToolCall> toolCalls) implements Message {
        AssistantMessage {
            text = text == null ? "" : text;
            toolCalls = toolCalls == null ? List.of() : List.copyOf(toolCalls);
        }
        static AssistantMessage text(String text) { return new AssistantMessage(text, List.of()); }
        static AssistantMessage calls(ToolCall... calls) { return new AssistantMessage("", List.of(calls)); }
    }
    record ToolMessage(String callId, String name, String content, boolean isError) implements Message {}

    // ---------------- Project 21: the model side ----------------
    record ToolSpec(String name, String description, JsonNode parameters) {}
    record ModelResponse(AssistantMessage message, int promptTokens, int completionTokens) {}

    @FunctionalInterface
    interface ModelClient {
        ModelResponse chat(List<Message> messages, List<ToolSpec> tools) throws Exception;
    }

    /** Replays canned replies in order and estimates tokens as characters / 4. */
    static final class ScriptedModel implements ModelClient {
        private final Deque<AssistantMessage> replies;
        final List<List<Message>> requests = new ArrayList<>();
        ScriptedModel(AssistantMessage... replies) { this.replies = new ArrayDeque<>(List.of(replies)); }
        @Override public ModelResponse chat(List<Message> messages, List<ToolSpec> tools) {
            requests.add(List.copyOf(messages));
            AssistantMessage reply = replies.poll();
            if (reply == null) throw new IllegalStateException("script has no reply for request " + requests.size());
            int prompt = messages.stream().mapToInt(ScriptedModel::chars).sum() / 4;
            return new ModelResponse(reply, prompt, chars(reply) / 4);
        }

        static int chars(Message m) {
            return switch (m) {
                case SystemMessage s -> s.text().length();
                case UserMessage u -> u.text().length();
                case AssistantMessage a -> a.text().length()
                        + a.toolCalls().stream().mapToInt(c -> c.name().length() + c.arguments().length()).sum();
                case ToolMessage t -> t.content().length();
            };
        }
    }


    // ---------------- Project 21: tools ----------------
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.RECORD_COMPONENT)
    @interface Param {
        String value() default "";
        boolean optional() default false;
    }

    @FunctionalInterface
    interface ToolFunction<A> { String apply(A args) throws Exception; }

    interface Tool {
        String name();
        String description();
        JsonNode schema();
        String run(JsonNode args) throws Exception;
        default boolean readOnly() { return false; }
        default ToolSpec spec() { return new ToolSpec(name(), description(), schema()); }

        /** A tool whose arguments are a record; the schema comes from the record's components. */
        static <A extends Record> Tool of(String name, String description, boolean readOnly, Class<A> type, ToolFunction<A> fn) {
            JsonNode schema = Schemas.forRecord(type);
            return new Tool() {
                public String name() { return name; }
                public String description() { return description; }
                public JsonNode schema() { return schema; }
                public boolean readOnly() { return readOnly; }
                public String run(JsonNode args) throws Exception { return fn.apply(JSON.treeToValue(args, type)); }
            };
        }
    }

    static final class Schemas {
        static ObjectNode forRecord(Class<? extends Record> type) {
            ObjectNode schema = JSON.createObjectNode().put("type", "object");
            ObjectNode properties = schema.putObject("properties");
            ArrayNode required = JSON.createArrayNode();
            for (RecordComponent c : type.getRecordComponents()) {
                ObjectNode p = forType(c.getGenericType());
                Param param = c.getAnnotation(Param.class);
                if (param != null && !param.value().isEmpty()) p.put("description", param.value());
                properties.set(c.getName(), p);
                if (param == null || !param.optional()) required.add(c.getName());
            }
            schema.set("required", required);
            schema.put("additionalProperties", false);
            return schema;
        }

        @SuppressWarnings("unchecked")
        static ObjectNode forType(Type t) {
            ObjectNode n = JSON.createObjectNode();
            if (t instanceof ParameterizedType p && p.getRawType() == List.class)
                return n.put("type", "array").set("items", forType(p.getActualTypeArguments()[0]));
            Class<?> c = (Class<?>) t;
            if (c == String.class) n.put("type", "string");
            else if (c == int.class || c == Integer.class || c == long.class || c == Long.class) n.put("type", "integer");
            else if (c == double.class || c == Double.class) n.put("type", "number");
            else if (c == boolean.class || c == Boolean.class) n.put("type", "boolean");
            else if (c.isEnum()) {
                ArrayNode values = n.put("type", "string").putArray("enum");
                for (Object k : c.getEnumConstants()) values.add(k.toString());
            } else if (c.isRecord()) return forRecord((Class<? extends Record>) c);
            else throw new IllegalArgumentException("no schema for " + c);
            return n;
        }

        static List<String> validate(JsonNode schema, JsonNode value) {
            List<String> errors = new ArrayList<>();
            check(schema, value, "arguments", errors);
            return errors;
        }

        private static void check(JsonNode s, JsonNode v, String path, List<String> errors) {
            String type = s.path("type").asText();
            boolean ok = switch (type) {
                case "object" -> v.isObject();
                case "array" -> v.isArray();
                case "string" -> v.isTextual();
                case "integer" -> v.isIntegralNumber();
                case "number" -> v.isNumber();
                case "boolean" -> v.isBoolean();
                default -> true;
            };
            if (!ok) { errors.add(path + ": expected " + type + ", got " + kind(v)); return; }
            if (s.has("enum")) {
                boolean found = false;
                for (JsonNode e : s.get("enum")) found |= e.equals(v);
                if (!found) errors.add(path + ": must be one of " + s.get("enum"));
            }
            if (v.isObject()) {
                for (JsonNode r : s.path("required"))
                    if (!v.has(r.asText())) errors.add(path + ": missing required property '" + r.asText() + "'");
                for (Map.Entry<String, JsonNode> e : v.properties()) {
                    JsonNode ps = s.path("properties").get(e.getKey());
                    if (ps != null) check(ps, e.getValue(), path + "." + e.getKey(), errors);
                    else if (!s.path("additionalProperties").asBoolean(true)) errors.add(path + ": unknown property '" + e.getKey() + "'");
                }
            }
            if (v.isArray() && s.has("items"))
                for (int i = 0; i < v.size(); i++) check(s.get("items"), v.get(i), path + "[" + i + "]", errors);
        }

        static String kind(JsonNode v) {
            return v.isNull() ? "null" : v.isTextual() ? "string" : v.isIntegralNumber() ? "integer"
                    : v.isNumber() ? "number" : v.isBoolean() ? "boolean" : v.isArray() ? "array" : "object";
        }
    }

    static final class ToolRegistry {
        private final Map<String, Tool> tools = new LinkedHashMap<>();
        private final Duration timeout;
        private final int maxOutputChars;

        ToolRegistry(Duration timeout, int maxOutputChars) {
            this.timeout = timeout;
            this.maxOutputChars = maxOutputChars;
        }

        ToolRegistry register(Tool tool) { tools.put(tool.name(), tool); return this; }
        List<ToolSpec> specs() { return tools.values().stream().map(Tool::spec).toList(); }
        boolean isReadOnly(ToolCall call) { Tool t = tools.get(call.name()); return t != null && t.readOnly(); }

        /** Never throws: every problem becomes an error result that the model can read. */
        ToolMessage execute(ToolCall call) {
            Tool tool = tools.get(call.name());
            if (tool == null)
                return result(call, true, "unknown tool '" + call.name() + "'. Available tools: " + String.join(", ", tools.keySet()));
            JsonNode args;
            try {
                args = JSON.readTree(call.arguments() == null || call.arguments().isBlank() ? "{}" : call.arguments());
            } catch (JsonProcessingException e) {
                return result(call, true, "arguments are not valid JSON: " + e.getOriginalMessage());
            }
            List<String> problems = Schemas.validate(tool.schema(), args);
            if (!problems.isEmpty()) return result(call, true, "invalid arguments: " + String.join("; ", problems));
            FutureTask<String> task = new FutureTask<>(() -> tool.run(args));
            Thread.ofVirtual().name("tool-" + call.name()).start(task);
            try {
                return result(call, false, task.get(timeout.toMillis(), TimeUnit.MILLISECONDS));
            } catch (TimeoutException e) {
                task.cancel(true);
                return result(call, true, "timed out after " + timeout.toMillis() + " ms");
            } catch (ExecutionException e) {
                Throwable cause = e.getCause();
                return result(call, true, cause.getClass().getSimpleName() + ": " + cause.getMessage());
            } catch (InterruptedException e) {
                task.cancel(true);
                Thread.currentThread().interrupt();
                return result(call, true, "interrupted");
            }
        }

        private ToolMessage result(ToolCall call, boolean isError, String content) {
            return new ToolMessage(call.id(), call.name(), truncate(content == null ? "" : content, maxOutputChars), isError);
        }

        /** Keeps whole lines from the head and the tail, about maxChars / 2 characters each. */
        static String truncate(String text, int maxChars) {
            if (text.length() <= maxChars) return text;
            int budget = maxChars / 2;
            String[] lines = text.split("\n", -1);
            int head = 0, used = 0;
            while (head < lines.length && used + lines[head].length() + 1 <= budget) used += lines[head++].length() + 1;
            int tail = lines.length;
            used = 0;
            while (tail > head && used + lines[tail - 1].length() + 1 <= budget) used += lines[--tail].length() + 1;
            int omitted = tail - head;
        String marker = "[... " + omitted + (omitted == 1 ? " line" : " lines") + " omitted, " + text.length() + " characters in total ...]";
            if (head == 0 && tail == lines.length)              // one huge line: cut characters instead
                return text.substring(0, budget) + "\n" + marker + "\n" + text.substring(text.length() - budget);
            List<String> out = new ArrayList<>(Arrays.asList(lines).subList(0, head));
            out.add(marker);
            out.addAll(Arrays.asList(lines).subList(tail, lines.length));
            return String.join("\n", out);
        }
    }

    // ---------------- Project 21: the agent ----------------
    enum StopReason { COMPLETED, MAX_STEPS, MAX_TOKENS, CANCELLED }
    record AgentResult(StopReason stopReason, String finalText, int steps, int tokens) {}
    record AgentConfig(String systemPrompt, int maxSteps, int maxTokens) {}

    interface AgentListener {
        default void onAssistant(AssistantMessage message) {}
        default void onToolCall(ToolCall call) {}
        default void onToolResult(ToolMessage result) {}
        default void onStop(AgentResult result) {}
    }

    static final class Agent {
        private final ModelClient model;
        private final ToolRegistry tools;
        private final AgentConfig config;
        private final List<Message> messages = new ArrayList<>();
        private final List<AgentListener> listeners = new CopyOnWriteArrayList<>();
        private volatile boolean cancelled;

        Agent(ModelClient model, ToolRegistry tools, AgentConfig config) {
            this.model = model;
            this.tools = tools;
            this.config = config;
            messages.add(new SystemMessage(config.systemPrompt()));
        }

        Agent addListener(AgentListener listener) { listeners.add(listener); return this; }
        void cancel() { cancelled = true; }
        List<Message> messages() { return Collections.unmodifiableList(messages); }

        AgentResult run(String task) throws Exception {
            cancelled = false;
            messages.add(new UserMessage(task));
            int steps = 0, tokens = 0;
            while (true) {
                if (cancelled) return stop(StopReason.CANCELLED, "", steps, tokens);
                if (steps >= config.maxSteps()) return stop(StopReason.MAX_STEPS, "", steps, tokens);
                if (tokens >= config.maxTokens()) return stop(StopReason.MAX_TOKENS, "", steps, tokens);
                ModelResponse response = model.chat(List.copyOf(messages), tools.specs());
                steps++;
                tokens += response.promptTokens() + response.completionTokens();
                AssistantMessage reply = response.message();
                messages.add(reply);
                listeners.forEach(l -> l.onAssistant(reply));
                if (reply.toolCalls().isEmpty()) return stop(StopReason.COMPLETED, reply.text(), steps, tokens);
                for (ToolMessage result : runTools(reply.toolCalls())) messages.add(result);
            }
        }

        private List<ToolMessage> runTools(List<ToolCall> calls) {
            List<ToolMessage> results = new ArrayList<>();
            if (calls.size() > 1 && !cancelled && calls.stream().allMatch(tools::isReadOnly)) {
                calls.forEach(c -> listeners.forEach(l -> l.onToolCall(c)));
                List<Future<ToolMessage>> futures = new ArrayList<>();
                try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
                    for (ToolCall c : calls) futures.add(pool.submit(() -> tools.execute(c)));
                }                                                   // close() waits for every call
                for (Future<ToolMessage> f : futures) results.add(f.resultNow());
                results.forEach(r -> listeners.forEach(l -> l.onToolResult(r)));
                return results;
            }
            for (ToolCall c : calls) {                              // one at a time, in order
                listeners.forEach(l -> l.onToolCall(c));
                ToolMessage r = cancelled ? new ToolMessage(c.id(), c.name(), "cancelled before it ran", true) : tools.execute(c);
                results.add(r);
                listeners.forEach(l -> l.onToolResult(r));
            }
            return results;
        }

        private AgentResult stop(StopReason reason, String text, int steps, int tokens) {
            AgentResult result = new AgentResult(reason, text, steps, tokens);
            listeners.forEach(l -> l.onStop(result));
            return result;
        }
    }
    // ---------------- end of Project 21 ----------------

    record ReadArgs(@Param("Path relative to the workspace root") String path) {}
    record SleepArgs(int millis) {}

    static int passed, failed;
    static void check(String name, boolean ok) {
        System.out.println((ok ? "PASS " : "FAIL ") + name);
        if (ok) passed++; else failed++;
    }

    static ToolRegistry registry(Duration timeout) {
        Map<String, String> files = Map.of("pom.xml", "<project>demo</project>", "README.md", "# Demo");
        return new ToolRegistry(timeout, 2000)
            .register(Tool.of("read_file", "Read a text file", true, ReadArgs.class, a -> {
                String text = files.get(a.path());
                if (text == null) throw new NoSuchFileException(a.path());
                return text;
            }))
            .register(Tool.of("sleep", "Sleep for a while", false, SleepArgs.class, a -> {
                Thread.sleep(a.millis());
                return "slept";
            }));
    }

    static ToolCall call(String id, String name, String args) { return new ToolCall(id, name, args); }
    static final AgentConfig CONFIG = new AgentConfig("You are a coding agent.", 5, 10_000);
    static final Duration FIVE_SECONDS = Duration.ofSeconds(5);

    public static void main(String[] args) throws Exception {
        // 1. a plain answer ends the run
        ScriptedModel m1 = new ScriptedModel(AssistantMessage.text("Hello."));
        AgentResult r1 = new Agent(m1, registry(FIVE_SECONDS), CONFIG).run("Say hello.");
        check("text reply completes in one step", r1.stopReason() == StopReason.COMPLETED && r1.steps() == 1 && r1.finalText().equals("Hello."));

        // 2. a tool call, then the result goes back to the model
        ScriptedModel m2 = new ScriptedModel(AssistantMessage.calls(call("c1", "read_file", "{\"path\":\"pom.xml\"}")), AssistantMessage.text("It is a demo project."));
        new Agent(m2, registry(FIVE_SECONDS), CONFIG).run("What is in pom.xml?");
        Message last = m2.requests.get(1).getLast();
        check("second request ends with the tool result", last instanceof ToolMessage t && t.callId().equals("c1") && t.content().equals("<project>demo</project>"));

        // 3. unknown tool and 4. invalid arguments are reported, not thrown
        ScriptedModel m3 = new ScriptedModel(
            AssistantMessage.calls(call("c1", "reed_file", "{\"path\":\"pom.xml\"}")),
            AssistantMessage.calls(call("c2", "read_file", "{\"file\":\"pom.xml\"}")),
            AssistantMessage.text("done"));
        Agent a3 = new Agent(m3, registry(FIVE_SECONDS), CONFIG);
        a3.run("Read pom.xml");
        List<ToolMessage> errors = a3.messages().stream().filter(m -> m instanceof ToolMessage).map(m -> (ToolMessage) m).toList();
        check("unknown tool names the available tools", errors.get(0).isError() && errors.get(0).content().contains("Available tools: read_file, sleep"));
        check("invalid arguments are explained", errors.get(1).content().equals(
            "invalid arguments: arguments: missing required property 'path'; arguments: unknown property 'file'"));

        // 5. step limit keeps the history valid
        ScriptedModel m5 = new ScriptedModel(
            AssistantMessage.calls(call("c1", "read_file", "{\"path\":\"pom.xml\"}")),
            AssistantMessage.calls(call("c2", "read_file", "{\"path\":\"pom.xml\"}")));
        Agent a5 = new Agent(m5, registry(FIVE_SECONDS), new AgentConfig("s", 2, 10_000));
        AgentResult r5 = a5.run("loop");
        check("step limit stops after 2 steps", r5.stopReason() == StopReason.MAX_STEPS && r5.steps() == 2 && a5.messages().getLast() instanceof ToolMessage);

        // 6. cancel from a listener
        ScriptedModel m6 = new ScriptedModel(AssistantMessage.calls(call("c1", "read_file", "{\"path\":\"pom.xml\"}")));
        Agent a6 = new Agent(m6, registry(FIVE_SECONDS), CONFIG);
        a6.addListener(new AgentListener() { public void onToolResult(ToolMessage r) { a6.cancel(); } });
        check("cancel stops before the next model call", a6.run("x").stopReason() == StopReason.CANCELLED && m6.requests.size() == 1);

        // 7. exceptions and 8. timeouts become error results
        ToolMessage missing = registry(FIVE_SECONDS).execute(call("c1", "read_file", "{\"path\":\"nope.txt\"}"));
        check("exception becomes an error result", missing.isError() && missing.content().equals("NoSuchFileException: nope.txt"));
        ToolMessage slow = registry(Duration.ofMillis(300)).execute(call("c2", "sleep", "{\"millis\":5000}"));
        check("timeout becomes an error result", slow.isError() && slow.content().equals("timed out after 300 ms"));

        // 9. parallel read-only calls keep the call order
        ScriptedModel m9 = new ScriptedModel(AssistantMessage.calls(call("a", "read_file", "{\"path\":\"README.md\"}"),
            call("b", "read_file", "{\"path\":\"pom.xml\"}")), AssistantMessage.text("ok"));
        Agent a9 = new Agent(m9, registry(FIVE_SECONDS), CONFIG);
        a9.run("both");
        List<String> ids = a9.messages().stream().filter(m -> m instanceof ToolMessage).map(m -> ((ToolMessage) m).callId()).toList();
        check("results follow the call order", ids.equals(List.of("a", "b")));

        // 10. truncation keeps head and tail
        String big = String.join("\n", java.util.stream.IntStream.rangeClosed(1, 100).mapToObj(i -> "line " + i).toList());
        String cut = ToolRegistry.truncate(big, 60);
        check("truncation keeps first and last lines", cut.startsWith("line 1\n") && cut.endsWith("line 100") && cut.contains("lines omitted"));
        System.out.println(passed + " passed, " + failed + " failed");
    }
}
Output
PASS text reply completes in one step
PASS second request ends with the tool result
PASS unknown tool names the available tools
PASS invalid arguments are explained
PASS step limit stops after 2 steps
PASS cancel stops before the next model call
PASS exception becomes an error result
PASS timeout becomes an error result
PASS results follow the call order
PASS truncation keeps first and last lines
10 passed, 0 failed

All ten checks pass. Each one pins down a behavior from an earlier example:

  1. A text reply ends the run in one step with COMPLETED, and finalText holds the answer.
  2. After a tool call, the second request that the model receives ends with the ToolMessage for c1 and the file's content. ScriptedModel.requests records every request, which lets a test inspect what the model saw.
  3. A call to reed_file produces an error result that lists read_file, sleep, in registration order (LinkedHashMap keeps insertion order).
  4. {"file":"pom.xml"} produces the exact validation message from Example 6: a missing path and an unknown file.
  5. With maxSteps = 2, a model that keeps calling tools stops with MAX_STEPS after two steps. The last message is a tool result, so the history is valid.
  6. A listener that calls cancel() after the first tool result stops the run before a second model call. The scripted model received exactly one request.
  7. NoSuchFileException from the tool becomes the error result NoSuchFileException: nope.txt.
  8. A tool that sleeps 5 seconds under a registry with a 300 ms timeout returns timed out after 300 ms.
  9. Two read_file calls in one reply run in parallel (the tool is read-only), and their results come back in the order a, b.
  10. Truncation of 100 lines to 60 characters keeps line 1 and line 100 and inserts the marker.

The tests use a 5-second timeout for everything except the timeout test. A 200 ms limit can fail on a busy machine before the first tool call finishes, because the first call also loads and initializes classes (Jackson's record support among them). Timeouts in tests should be far from the expected durations.

The public surface that later lessons use is small:

Type Members
Message (sealed) SystemMessage(text), UserMessage(text), AssistantMessage(text, toolCalls), ToolMessage(callId, name, content, isError)
ToolCall id, name, arguments (JSON string)
ModelClient ModelResponse chat(List<Message>, List<ToolSpec>)
ModelResponse message, promptTokens, completionTokens
ScriptedModel replays AssistantMessages; requests records inputs
Tool name(), description(), schema(), run(JsonNode), readOnly(), spec(), Tool.of(name, description, readOnly, argsRecord, function)
ToolSpec name, description, parameters
Schemas forRecord(Class), validate(schema, value)
ToolRegistry ToolRegistry(timeout, maxOutputChars), register, specs, execute(ToolCall), truncate
Agent Agent(model, registry, config), run(task), cancel(), addListener, messages()
AgentConfig, AgentResult, StopReason, AgentListener limits, outcome, and events

Tool.of is the shortest way to define a tool. It takes an argument record, generates the schema with Schemas.forRecord, and converts the validated JSON into the record before calling the function. The file tools in File Tools are written this way.

Agent.run can be called again on the same agent. The history is kept, so a second task continues the conversation. Each run resets the cancellation flag and counts its own steps and tokens.

A real model plugs in by implementing ModelClient with the ChatClient from The Chat Completions Protocol and Streaming in Java. The implementation converts the messages and specs to a request (Example 15), calls chat, and converts the tool_calls of the response back into ToolCall records. The workshop lesson on loop agent does this.

Example 15: the history in the chat completions wire format

A real server does not understand Java records. It expects JSON in the chat completions format, which is called the wire format because it is what travels over the network. How does the Project 21 history map to that JSON? The conversion is mechanical, and seeing it once explains several details of the protocol. The program converts a four-message history and one tool spec into a request body. Look at how the assistant's call and the tool's result are written.

Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;

public class WireFormat {
    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) {}

    static ObjectNode toRequest(String model, List<Message> messages, List<ToolSpec> tools) {
        ObjectNode body = JSON.createObjectNode().put("model", model);
        ArrayNode out = body.putArray("messages");
        for (Message m : messages) {
            ObjectNode o = out.addObject();
            switch (m) {
                case SystemMessage s -> o.put("role", "system").put("content", s.text());
                case UserMessage u -> o.put("role", "user").put("content", u.text());
                case AssistantMessage a -> {
                    o.put("role", "assistant").put("content", a.text());
                    if (!a.toolCalls().isEmpty()) {
                        ArrayNode calls = o.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 -> o.put("role", "tool").put("tool_call_id", t.callId())
                        .put("content", t.isError() ? "Error: " + t.content() : t.content());
            }
        }
        ArrayNode toolArray = body.putArray("tools");
        for (ToolSpec t : tools)
            toolArray.addObject().put("type", "function").putObject("function")
                .put("name", t.name()).put("description", t.description()).set("parameters", t.parameters());
        return body;
    }

    public static void main(String[] args) throws Exception {
        JsonNode schema = JSON.readTree("{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}");
        List<Message> history = List.of(
            new SystemMessage("You are a coding agent."),
            new UserMessage("What is in pom.xml?"),
            new AssistantMessage("", List.of(new ToolCall("c1", "read_file", "{\"path\":\"pom.xml\"}"))),
            new ToolMessage("c1", "read_file", "NoSuchFileException: pom.xml", true));
        ObjectNode request = toRequest("qwen2.5-coder-7b", history, List.of(new ToolSpec("read_file", "Read a text file", schema)));
        System.out.println(JSON.writerWithDefaultPrettyPrinter().writeValueAsString(request));
    }
}
Output
{
  "model" : "qwen2.5-coder-7b",
  "messages" : [ {
    "role" : "system",
    "content" : "You are a coding agent."
  }, {
    "role" : "user",
    "content" : "What is in pom.xml?"
  }, {
    "role" : "assistant",
    "content" : "",
    "tool_calls" : [ {
      "id" : "c1",
      "type" : "function",
      "function" : {
        "name" : "read_file",
        "arguments" : "{\"path\":\"pom.xml\"}"
      }
    } ]
  }, {
    "role" : "tool",
    "tool_call_id" : "c1",
    "content" : "Error: NoSuchFileException: pom.xml"
  } ],
  "tools" : [ {
    "type" : "function",
    "function" : {
      "name" : "read_file",
      "description" : "Read a text file",
      "parameters" : {
        "type" : "object",
        "properties" : {
          "path" : {
            "type" : "string"
          }
        },
        "required" : [ "path" ]
      }
    }
  } ]
}

The four messages became four entries with role fields. The assistant message has "content": "" and a tool_calls array. Each call has id, type: "function", and a function object whose arguments is a string that contains JSON, not a nested object. The escaped quotes ("{\"path\":\"pom.xml\"}") show this. llama-server's parser accepts this form, and llama-server: Endpoints, Slots, and Prompt Cache showed the same shape in its responses.

The tool result has role: "tool" and tool_call_id: "c1", which pairs it with the call. The chat completions format has no error flag, so the conversion writes Error: in front of the content when isError is true. The model reads the word.

The tools array wraps each ToolSpec in {"type": "function", "function": {...}}. Example 3's spec omitted this wrapper. The parameters object is the JSON Schema itself.

The whole history is sent on every step, as Example 1 showed with message counts. A server with a prompt cache reuses the computed prefix. As long as the agent only appends to the history and never rewrites earlier messages, the cost of a long history is mostly memory, not repeated computation.

Interview points

  • An agent loop sends the history and the tool specs to the model, appends the assistant reply, runs its tool calls, appends one result per call, and repeats until a reply has no tool calls or a limit is hit.
  • The model is stateless between requests. The full history goes in every request, so the prompt grows with each step.
  • Every tool call must get exactly one result with the same id before the next turn. A loop that stops between a reply and its results leaves an invalid history.
  • Errors (unknown tool, bad JSON, failed validation, exceptions, timeouts) are returned to the model as results, so that it can retry. The loop itself does not crash on them.
  • Arguments are validated against the schema before a tool runs. Error messages state a path, the expectation, and what arrived.
  • The stop conditions are a final answer, a step limit, a token budget, and cancellation. Limits are checked at the top of the loop, so the history stays valid.
  • Java cannot kill a thread. A timeout interrupts it, and tools that start processes must stop them explicitly.
  • Long output is truncated by keeping the head and the tail, with a marker that says how much was removed. Errors are usually at the end.
  • Only read-only calls run in parallel, and results are returned in call order, because some templates pair results with calls by position.
  • A scripted fake model makes agent tests deterministic. Real models are tested with evaluation suites instead.

Summary

  • An agent sends the conversation and the tool specs to a model, runs the requested tools, appends the results, and repeats.
  • A step is one model call plus the tool calls that it requested.
  • Project 21 represents the history as a sealed Message interface with four records: system, user, assistant, and tool messages.
  • ToolCall holds id, name, and arguments as a JSON string, matching the chat completions API.
  • A Tool has a name, a description, a JSON Schema, run(JsonNode), and a readOnly flag. The model sees only the ToolSpec.
  • Schemas.forRecord builds a schema from record components. @Param adds descriptions and marks optional arguments, which use boxed types.
  • ToolRegistry.execute never throws. Unknown tools, invalid JSON, validation errors, exceptions, and timeouts become error results.
  • The loop stops with COMPLETED, MAX_STEPS, MAX_TOKENS, or CANCELLED, checked before each model call.
  • Timeouts use a FutureTask on a virtual thread and cancel(true), which interrupts the thread but cannot force it to stop.
  • Truncation keeps whole lines from the head and the tail and inserts a marker with the omitted line count.
  • Read-only calls from one reply run in parallel on virtual threads, and the results are collected in call order.
  • A coding agent's system prompt has sections for role, rules, tools, output, environment, and project instructions, and it is sent on every step.
  • AgentListener events (reply, tool call, tool result, stop) drive terminals, logs, and tests without changing the loop.
  • The chat completions wire format sends tool calls in tool_calls and results as role: "tool" messages with tool_call_id.