AI Agent Learning
L02

Tools

Tools & Execution

One Handler Per Tool

329 LOC4 toolsTool dispatch map
The loop stays the same; new tools register into the dispatch map

L00 > L01 > [ L02 ] L03 > L04 > L05 > L06 | L07 > L08 > L09 > L10 > L11 > L12 > L13

"The loop didn't change at all. I just added tools." -- scaling from one tool to many is a dispatch problem, not a loop problem.

Problem

In Lesson 1, the agent had a single tool: bash. But a real agent needs to read files, write files, edit files, and more. Adding each tool directly into the loop creates a growing if/else chain that becomes unmaintainable. We need a pattern that separates tool definition from tool dispatch.

The second problem: if the model can run any bash command, it can read /etc/passwd or write to /usr/bin. We need path sandboxing.

Solution

+--------+      +-------+      +-------------+
|  User  | ---> |  LLM  | ---> | tool_calls? |
| prompt |      |       |      +------+------+
+--------+      +---^---+             |
                    |           yes    |    no
                    |          +------+------+
                    |          |             |
                    |    +-----v-----------+ |
                    |    | TOOL_HANDLERS   | |
                    |    | .get(toolName)  | |
                    |    | .execute(args)  | |
                    |    +-----+-----------+ |
                    |          |             |
                    +----------+        +----v----+
                   tool_result          |  done   |
                   added to messages    |  (text) |
                                        +---------+

The loop is identical to L01. Only the dispatch map changed.

How It Works

  1. Define multiple tools as a list.
private List<ChatCompletionTool> createTools() {
    List<ChatCompletionTool> tools = new ArrayList<>();

    tools.add(ChatCompletionTool.builder()
            .function(FunctionDefinition.builder()
                    .name("bash")
                    .description("Run a shell command.")
                    .parameters(FunctionParameters.builder()
                            .putAdditionalProperty("type", JsonValue.from("object"))
                            .putAdditionalProperty("properties", JsonValue.from(Map.of(
                                    "command", Map.of("type", "string")
                            )))
                            .putAdditionalProperty("required", JsonValue.from(List.of("command")))
                            .build())
                    .build())
            .build());

    tools.add(ChatCompletionTool.builder()
            .function(FunctionDefinition.builder()
                    .name("read_file")
                    .description("Read file contents.")
                    .parameters(FunctionParameters.builder()
                            .putAdditionalProperty("type", JsonValue.from("object"))
                            .putAdditionalProperty("properties", JsonValue.from(Map.of(
                                    "path", Map.of("type", "string"),
                                    "limit", Map.of("type", "integer")
                            )))
                            .putAdditionalProperty("required", JsonValue.from(List.of("path")))
                            .build())
                    .build())
            .build());

    // ... write_file, edit_file follow the same pattern
    return tools;
}

Each tool is a ChatCompletionTool with a FunctionDefinition. The model sees all of them and picks which one to call. The list is passed to .tools(tools) on the params builder.

  1. Create a dispatch map instead of an if/else chain.
@FunctionalInterface
interface ToolHandler {
    String execute(Map<String, Object> args) throws Exception;
}

private Map<String, ToolHandler> createHandlers() {
    Map<String, ToolHandler> handlers = new HashMap<>();
    handlers.put("bash",      args -> runBash((String) args.get("command")));
    handlers.put("read_file", args -> runRead((String) args.get("path"), (Integer) args.get("limit")));
    handlers.put("write_file",args -> runWrite((String) args.get("path"), (String) args.get("content")));
    handlers.put("edit_file", args -> runEdit((String) args.get("path"), (String) args.get("old_text"),
                                              (String) args.get("new_text")));
    return handlers;
}

The ToolHandler functional interface is the key abstraction. Each handler is a lambda that takes parsed arguments and returns a string result. Adding a new tool means adding one entry to the map and one tool definition to the list -- the loop never changes.

  1. Dispatch by name inside the loop.
String output = executeTool(handlers, toolName, arguments);

// ...

private String executeTool(Map<String, ToolHandler> handlers, String toolName, String arguments) {
    ToolHandler handler = handlers.get(toolName);
    if (handler == null) {
        return "Unknown tool: " + toolName;
    }
    try {
        Map<String, Object> args = parseArguments(arguments);
        return handler.execute(args);
    } catch (Exception e) {
        return "Error: " + e.getMessage();
    }
}

Unknown tools return an error string rather than crashing. The model reads the error and adjusts.

  1. Path sandboxing: prevent filesystem escape.
private Path safePath(String p) {
    Path path = workDir.resolve(p).normalize();
    if (!path.startsWith(workDir)) {
        throw new IllegalArgumentException("Path escapes workspace: " + p);
    }
    return path;
}

Every file tool (read_file, write_file, edit_file) calls safePath() first. If the model tries ../../etc/passwd, the normalized path won't start with workDir and the operation is rejected. This is a critical security boundary.

  1. The edit tool uses exact string matching, not regex.
private String runEdit(String path, String oldText, String newText) {
    Path fp = safePath(path);
    String content = new String(Files.readAllBytes(fp), StandardCharsets.UTF_8);
    if (!content.contains(oldText)) {
        return "Error: Text not found in " + path;
    }
    content = content.replaceFirst(escapeRegex(oldText), newText);
    Files.write(fp, content.getBytes(StandardCharsets.UTF_8));
    return "Edited " + path;
}

The model sends the exact text to find and the exact text to replace it with. This is safer and more predictable than regex-based editing. The escapeRegex method ensures special characters in the old text don't break replaceFirst.

What Changed

ComponentLesson 1Lesson 2
Tools1 (bash)4 (bash, read_file, write_file, edit_file)
Dispatchif ("bash".equals(...))handlers.get(toolName) map
File accessUnrestricted via bashSandboxed via safePath()
LoopSameSame (unchanged)
ArchitectureMonolithicHandler pattern (open for extension)

Try It

mvn spring-boot:run -pl openai -Dspring-boot.run.arguments="--lesson=lesson2 --prompt='Read the pom.xml file and tell me what dependencies this project uses.'"

Source: Lesson2RunSimple.java