Skip to content

Loop

Java · package io.github.muthuishere:toolnexus · SPEC §7D · Loop.java

public final class Loop {
// Built from an AgentSpec via Agents.Agent#loop(options, toolkit) — the spec-aware
// constructor; a loose-field constructor also exists for hosts without an AgentSpec.
public Outcome run(String prompt);
public Outcome run(String prompt, RunOptions opts);
public Outcome run(List<ContentPart> prompt); // §1B multimodal entry
public Outcome run(List<ContentPart> prompt, RunOptions opts);
public String status(); // observed, never set by the caller
public int turns(); // model round trips this loop has spent
public interface Guardrail {
String check(LlmClient.BeforeToolEvent ev); // null/"allow" permits; any other string denies
}
public static final class Verdict {
public final boolean ok;
public final String reason;
public static Verdict pass();
public static Verdict fail(String reason);
}
public static final class Completion {
public final Function<LlmClient.RunResult, Verdict> verify;
public final int maxAttempts; // REQUIRED — an unbounded verify loop is a DoS on the bill
public Completion(Function<LlmClient.RunResult, Verdict> verify, int maxAttempts);
}
public static final class RunOptions {
public String model; // overrides the agent's model for THIS call only
public RunOptions model(String v);
}
public static final class Outcome {
public final String text;
public final String status; // done | incomplete | pending | error
public final String stoppedBy; // named whenever status is not "done" — never silent
public final int attempts;
public final int turns;
public final LlmClient.RunResult result;
}
public static List<String> loopUnsupported(Agents.AgentSpec spec); // "tools"/"team"/"waitFor"/"onMetric"
public static Verdict allTodosDone(LlmClient.RunResult result); // built-in completion verifier
public static LlmClient.Hooks guardedHooks(List<Guardrail> guardrails, LlmClient.Hooks hooks);
public static LlmClient.RunResult runGated(Ask ask, String prompt, Completion completion);
}

Agent.Loop(...).Run — spelled agent.loop(options, toolkit).run(prompt) in Java — drives the agent under a Guardrail policy that vets every tool call and a Completion check that decides when the task is done: the gated door beside the plain Agent.run. Loop takes no options of its own — it answers “DID it?” (status, turns), never “MAY it?” (that’s the AgentSpec, “the harness”) or “with WHAT?” (that’s RunOptions, per call). A Loop is built from a spec via Agents.Agent#loop(LlmClient.Options, Toolkit), which is the spec-aware constructor (ADR 0024): it applies the agent’s soul (unless the caller already set a systemPrompt), compiles its guardrails into beforeTool, and defaults model/budget.maxTurns from the spec — all of which a driver built from four loose fields would silently drop.

Reach for agent.loop(...) instead of agent.run(...) whenever the caller needs the live, turn-by-turn shape of one agent’s execution rather than the one-shot tree result: reading loop.status()/loop.turns() between calls, overriding the model for a single call via RunOptions.model(...), or gating completion with a Completion so the agent cannot claim done before its own declared plan (todowrite) is actually finished — Loop.allTodosDone is the built-in, domain-blind verifier for exactly that. Guardrails (Loop.Guardrail) are a policy check, “may it?” — never a correctness check; use Completion for “is it right?”.

1. The smallest useful call — drive an agent and read its Outcome

Section titled “1. The smallest useful call — drive an agent and read its Outcome”
import com.sun.net.httpserver.HttpServer;
import io.github.muthuishere.toolnexus.*;
import io.github.muthuishere.toolnexus.agents.Agents;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
public class Example {
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", ex -> {
byte[] b = "{\"choices\":[{\"message\":{\"content\":\"hello\"},\"finish_reason\":\"stop\"}]}"
.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().add("Content-Type", "application/json");
ex.sendResponseHeaders(200, b.length);
try (OutputStream os = ex.getResponseBody()) { os.write(b); }
});
server.start();
LlmClient.Options opts = new LlmClient.Options();
opts.baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
opts.style = "openai";
opts.model = "test-model";
opts.apiKey = "unused";
Toolkit.Options tkOpts = new Toolkit.Options();
tkOpts.builtins = false;
try (Toolkit tk = Toolkit.create(tkOpts)) {
Agents.Agent agent = Agents.agent("plain", new Agents.AgentSpec().does("answers"));
Loop loop = agent.loop(opts, tk);
Loop.Outcome out = loop.run("hi");
if (!"done".equals(out.status)) throw new AssertionError(out.status);
if (!"hello".equals(out.text)) throw new AssertionError(out.text);
if (out.stoppedBy != null) throw new AssertionError("a done run names no stop reason");
if (!"idle".equals(loop.status())) throw new AssertionError(loop.status());
System.out.println("ok: status=" + out.status + " attempts=" + out.attempts);
} finally {
server.stop(0);
}
}
}

2. The realistic case — a Completion gate retries an agent that left work open

Section titled “2. The realistic case — a Completion gate retries an agent that left work open”
import com.sun.net.httpserver.HttpServer;
import io.github.muthuishere.toolnexus.*;
import io.github.muthuishere.toolnexus.agents.Agents;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;
public class Example {
static String todoCall(String id, String text, boolean done) {
return "{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"t1\",\"type\":\"function\","
+ "\"function\":{\"name\":\"todowrite\",\"arguments\":\"{\\\"todos\\\":[{\\\"id\\\":\\\""
+ id + "\\\",\\\"text\\\":\\\"" + text + "\\\",\\\"completed\\\":" + done + "}]}\"}}]}";
}
static String say(String content) {
return "{\"role\":\"assistant\",\"content\":\"" + content + "\"}";
}
public static void main(String[] args) throws Exception {
// Attempt 1 leaves the todo open; the gate retries; attempt 2's todowrite closes it.
String[] messages = {
todoCall("1", "proofread", false), say("still working on it"),
todoCall("1", "proofread", true), say("done for real"),
};
AtomicInteger i = new AtomicInteger();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", ex -> {
String message = messages[Math.min(i.getAndIncrement(), messages.length - 1)];
String finish = message.contains("tool_calls") ? "tool_calls" : "stop";
byte[] b = ("{\"choices\":[{\"message\":" + message + ",\"finish_reason\":\""
+ finish + "\"}]}").getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().add("Content-Type", "application/json");
ex.sendResponseHeaders(200, b.length);
try (OutputStream os = ex.getResponseBody()) { os.write(b); }
});
server.start();
LlmClient.Options opts = new LlmClient.Options();
opts.baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
opts.style = "openai";
opts.model = "test-model";
opts.apiKey = "unused";
Toolkit.Options tkOpts = new Toolkit.Options();
tkOpts.builtins = java.util.Map.of("tools", java.util.Map.of("todowrite", true));
try (Toolkit tk = Toolkit.create(tkOpts)) {
Agents.Agent agent = Agents.agent("gated", new Agents.AgentSpec().does("plans")
.completion(new Loop.Completion(Loop::allTodosDone, 3)));
Loop.Outcome out = agent.loop(opts, tk).run("do the thing");
if (!"done".equals(out.status)) throw new AssertionError(out.status);
if (out.attempts < 2) throw new AssertionError("expected a retry, got " + out.attempts);
System.out.println("ok: verified after " + out.attempts + " attempt(s)");
} finally {
server.stop(0);
}
}
}

3. The full surface — a Guardrail denies a tool call, and loopUnsupported names the gap

Section titled “3. The full surface — a Guardrail denies a tool call, and loopUnsupported names the gap”
import io.github.muthuishere.toolnexus.*;
import io.github.muthuishere.toolnexus.agents.Agents;
import java.util.List;
import java.util.Map;
public class Example {
public static void main(String[] args) {
// guardedHooks compiles a first-deny-wins policy chain into a beforeTool hook.
LlmClient.Hooks hooks = Loop.guardedHooks(List.of(
ev -> "danger".equals(ev.name()) ? "policy: no" : "allow"), null);
LlmClient.ToolOverride denied = hooks.beforeTool.apply(
new LlmClient.BeforeToolEvent("danger", Map.of(), null, 1));
if (!denied.result().isError()) throw new AssertionError("expected a denial");
if (!denied.result().output().contains("policy: no")) throw new AssertionError(denied.result().output());
LlmClient.ToolOverride allowed = hooks.beforeTool.apply(
new LlmClient.BeforeToolEvent("safe", Map.of(), null, 1));
if (allowed != null) throw new AssertionError("an allowed call must not be overridden");
// loopUnsupported names, by field, exactly what a Loop-driven agent gives up.
Agents.AgentSpec teamSpec = new Agents.AgentSpec().does("x")
.team(Agents.agent("helper", new Agents.AgentSpec().does("helps")));
List<String> gaps = Loop.loopUnsupported(teamSpec);
if (!gaps.contains("team")) throw new AssertionError(gaps);
Agents.AgentSpec plainSpec = new Agents.AgentSpec().does("x");
if (!Loop.loopUnsupported(plainSpec).isEmpty()) {
throw new AssertionError("a plain spec has nothing unsupported");
}
System.out.println("ok: denied a tool call, unsupported=" + gaps);
}
}
  • Loop.Outcome.status reuses the SHIPPED TaskResult vocabulary rather than minting a new one — see TaskResult.STATUSES / TaskResult.LIMITS.
  • The narrative page at /harness/ covers the placement law (AgentSpec = capability, RunOptions = per-call, Loop = observed) across all seven ports; this page is the Java API surface for it.
  • TaskResult.STATUSES / TaskResult.LIMITS — The closed status/limit vocabularies Loop.Outcome.status and a gated run’s limit reuse.
  • agents.Agent — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent.
  • agents.Runtime — The six host verbs that drive sub-agents, plus the read-only list and inspect views.
  • agents.Handle — The state machine for one spawned agent: pending, running, suspended, done.
  • agents.Budget — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight.