Skip to content

ToolResult

Java · package io.github.muthuishere:toolnexus · SPEC §1 · ToolResult.java

public final class ToolResult {
public ToolResult(String output, boolean isError, Map<String, Object> metadata);
public String output();
public boolean isError();
public Map<String, Object> metadata();
}

What every execute returns. Three fields, and the whole tool-calling loop is built on them: output is the text handed back to the model, isError says whether the call failed, and metadata is free-form — except for one reserved key that turns a result into a suspension.

Every time you write a tool. It is the return type of Tool.execute, so you construct one on every code path.

Why the static factories rather than the constructor

Section titled “Why the static factories rather than the constructor”

A tool that fails does not throw — it returns error(...). The loop feeds that text back to the model as the tool result, so the model can read “file not found”, pick another path, and carry on. Throwing escapes the loop and ends the run.

output is always a String, and the constructor normalises null to "" — it is what the model reads, so serialize structured data yourself.

import io.github.muthuishere.toolnexus.ToolResult;
import java.util.Map;
public class Example {
static final Map<String, String> CONFIG = Map.of("region", "eu-west-1");
static ToolResult readConfig(String key) {
String v = CONFIG.get(key);
if (v == null) {
// Recoverable: the model can read this and try another key.
return ToolResult.error("No such config key: " + key);
}
return ToolResult.ok(v);
}
public static void main(String[] args) {
ToolResult found = readConfig("region");
if (!found.output().equals("eu-west-1") || found.isError()) {
throw new AssertionError("unexpected: " + found.output());
}
ToolResult missing = readConfig("nope");
if (!missing.isError()) throw new AssertionError("expected isError");
System.out.println("ok: " + found.output() + " | " + missing.output());
}
}

output must be a string, so serialize deliberately. metadata rides alongside for your code — the model never sees it, which makes it the right place for bookkeeping.

import io.github.muthuishere.toolnexus.ToolResult;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Example {
record Hit(int id, String title) {}
static ToolResult search(String q) {
List<Hit> hits = List.of(new Hit(1, "Getting started"), new Hit(2, "Advanced usage"));
return ToolResult.ok(
// The model reads this. Make it legible, not just valid.
hits.stream().map(h -> "#" + h.id() + " " + h.title()).collect(Collectors.joining("\n")),
// Your code reads this. The model never sees it.
Map.of("title", "search: " + q,
"count", hits.size(),
"ids", hits.stream().map(Hit::id).toList())
);
}
public static void main(String[] args) {
ToolResult res = search("usage");
if (!res.metadata().get("count").equals(2)) throw new AssertionError("count");
if (!res.metadata().get("ids").equals(List.of(1, 2))) throw new AssertionError("ids");
if (!res.output().contains("Advanced usage")) throw new AssertionError("output");
System.out.println("ok: " + res.metadata().get("title"));
}
}

3. The reserved key — metadata.pending is a suspension

Section titled “3. The reserved key — metadata.pending is a suspension”

metadata is free-form with one exception. A pending key holding a Request means “this tool cannot finish until something out-of-band happens” — the loop parks the run instead of returning. The producer and reader helpers are static methods on ToolResult.

import io.github.muthuishere.toolnexus.Request;
import io.github.muthuishere.toolnexus.ToolResult;
public class Example {
public static void main(String[] args) {
// pending() returns a ToolResult carrying metadata.pending = Request.
// An empty id is filled in for you — it is the correlation key.
ToolResult res = ToolResult.pending(new Request("", "input", "Which environment?"));
if (!res.isError()) throw new AssertionError("a parked call is not a success");
Request req = ToolResult.pendingOf(res);
if (req == null) throw new AssertionError("pendingOf should read the suspension back");
if (!req.kind().equals("input")) throw new AssertionError("kind: " + req.kind());
if (!req.prompt().equals("Which environment?")) throw new AssertionError("prompt");
if (req.id() == null || req.id().isEmpty()) throw new AssertionError("an id is generated");
// authRequired is sugar for the login case.
ToolResult auth = ToolResult.authRequired("https://example.com/login");
Request authReq = ToolResult.pendingOf(auth);
if (!authReq.kind().equals("authorization")) throw new AssertionError("auth kind");
if (!authReq.url().equals("https://example.com/login")) throw new AssertionError("auth url");
// An ordinary result has no suspension.
if (ToolResult.pendingOf(ToolResult.ok("done")) != null) {
throw new AssertionError("expected no suspension on a plain result");
}
System.out.println("ok: " + req.kind() + " | " + authReq.kind());
}
}
Member Returns What it is
output() String The text handed to the model. Never null — null becomes "".
isError() boolean Whether the call failed. Fed back to the model, not thrown.
metadata() Map<String, Object> Free-form, may be null. Reserved: pending holds a §10 Request.
Method What it does
ok(output) / ok(output, metadata) A success result.
error(output) / error(output, metadata) A failure result the model can recover from.
pending(request) A §10 suspension. Generates an id when the request has none.
authRequired(url) / authRequired(url, prompt) Sugar for kind:"authorization" at a login URL.
pendingOf(result) Reads the Request back off a result, or null.