agents.Handle
Java · package io.github.muthuishere:toolnexus · SPEC §7D · agents/Handle.java
public final class Handle { public enum State { IDLE, RUNNING, SUSPENDED, CLOSED }
public volatile State state; public final String id; // deterministic, parent-scoped: "root/coordinator.1/explore.2" public final Deque<InboxItem> inbox; // agent STATE, never a runtime mailbox public final List<Handle> children; public final int depth; public final AgentDef def; public final Handle parent;}The live state of one spawned agent: {id, def, state, inbox, budget, children}. States:
idle → running → (idle | suspended | closed); suspended → running only via the Answer to
its pending Request. Ids are deterministic and parent-scoped — never random — so the same
sequence of spawn calls always produces the same tree shape, which is what makes the shared
examples/subagent-* fixtures byte-comparable across all six ports.
When to use it
Section titled “When to use it”You don’t construct a Handle — AgentRuntime.spawn returns one.
Read it to check state, inspect inbox.size() or children, or hold onto id for logging and
dashboards. Handles are capabilities: post/wake what you hold, waitOn only what you
spawned — a handle deliberately isn’t a global registry key.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — a fresh handle starts IDLE
Section titled “1. The smallest useful call — a fresh handle starts IDLE”import io.github.muthuishere.toolnexus.agents.*;import java.util.Map;
public class Example { public static void main(String[] args) { Map<String, AgentDef> registry = Map.of("worker", new AgentDef("worker", "does work", "", "inherit")); AgentRuntime rt = new AgentRuntime(new RuntimeOptions().registry(registry));
Handle h = rt.spawn(rt.root, "worker").handle();
if (h.state != Handle.State.IDLE) throw new AssertionError(h.state); if (!h.id.equals("root/worker.1")) throw new AssertionError(h.id); if (h.depth != 1) throw new AssertionError(h.depth); if (h.parent != rt.root) throw new AssertionError("parent should be the runtime root"); if (!h.inbox.isEmpty() || !h.children.isEmpty()) throw new AssertionError("fresh handle is empty");
// Spawning again produces a DIFFERENT, still-deterministic id (parent-scoped counter). Handle h2 = rt.spawn(rt.root, "worker").handle(); if (!h2.id.equals("root/worker.2")) throw new AssertionError(h2.id);
System.out.println("ok: " + h.id + ", " + h2.id); }}2. The realistic case — idle → running → idle across one wake
Section titled “2. The realistic case — idle → running → idle across one wake”import com.sun.net.httpserver.HttpServer;import io.github.muthuishere.toolnexus.agents.*;import java.io.OutputStream;import java.net.InetSocketAddress;import java.nio.charset.StandardCharsets;import java.util.Map;
public class Example { public static void main(String[] args) throws Exception { HttpServer llm = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); llm.createContext("/", ex -> { try { ex.getRequestBody().readAllBytes(); byte[] b = "{\"choices\":[{\"message\":{\"content\":\"done\"}}]}".getBytes(StandardCharsets.UTF_8); ex.getResponseHeaders().add("Content-Type", "application/json"); ex.sendResponseHeaders(200, b.length); try (OutputStream os = ex.getResponseBody()) { os.write(b); } } catch (Exception ignored) { } }); llm.start();
try { Map<String, AgentDef> registry = Map.of("worker", new AgentDef("worker", "does work", "", "test-model")); RuntimeOptions rtOpts = new RuntimeOptions() .baseUrl("http://127.0.0.1:" + llm.getAddress().getPort()) .apiKey("test-key") .registry(registry); AgentRuntime rt = new AgentRuntime(rtOpts); Handle h = rt.spawn(rt.root, "worker").handle();
var fut = rt.futureResult(h); rt.wake(h, "go"); // Between wake() returning and the turn landing, the handle is briefly RUNNING — // the trace records it even though we only observe the settled end state here. TaskResult r = fut.join();
if (h.state != Handle.State.IDLE) throw new AssertionError("settled done ⇒ back to idle: " + h.state); if (!rt.traceHas("idle→running")) throw new AssertionError(rt.trace()); if (!rt.traceHas("running→idle")) throw new AssertionError(rt.trace()); if (h.turnsTotal != 1) throw new AssertionError(h.turnsTotal);
System.out.println("ok: " + h.state + " after " + h.turnsTotal + " turn(s)"); } finally { llm.stop(0); } }}3. The full surface — SUSPENDED on a pending, then resumed
Section titled “3. The full surface — SUSPENDED on a pending, then resumed”With no waitFor interpreter anywhere on the chain, a tool’s pending result parks the handle
SUSPENDED — durably, burning zero tokens — until AgentRuntime.resume
delivers an Answer.
import com.sun.net.httpserver.HttpServer;import io.github.muthuishere.toolnexus.*;import io.github.muthuishere.toolnexus.agents.*;import java.io.OutputStream;import java.net.InetSocketAddress;import java.nio.charset.StandardCharsets;import java.util.List;import java.util.Map;
public class Example { public static void main(String[] args) throws Exception { HttpServer llm = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); llm.createContext("/", ex -> { try { String body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); Map<String, Object> req = Json.toMap(body); List<Object> msgs = (List<Object>) req.get("messages"); boolean answered = msgs.stream().anyMatch(m -> "tool".equals(((Map<?, ?>) m).get("role"))); String message = answered ? "{\"content\":\"secret retrieved\"}" : "{\"content\":null,\"tool_calls\":[{\"id\":\"c1\",\"type\":\"function\"," + "\"function\":{\"name\":\"reveal_secret\",\"arguments\":\"{}\"}}]}"; byte[] b = ("{\"choices\":[{\"message\":" + message + "}]}").getBytes(StandardCharsets.UTF_8); ex.getResponseHeaders().add("Content-Type", "application/json"); ex.sendResponseHeaders(200, b.length); try (OutputStream os = ex.getResponseBody()) { os.write(b); } } catch (Exception ignored) { } }); llm.start();
Tool revealSecret = new Tool() { @Override public String name() { return "reveal_secret"; } @Override public String description() { return "needs human approval"; } @Override public Map<String, Object> inputSchema() { return Map.of("type", "object", "properties", Map.of()); } @Override public String source() { return "custom"; } @Override public ToolResult execute(Map<String, Object> args, ToolContext ctx) { if (ctx != null && ctx.answer() != null && ctx.answer().ok()) return ToolResult.ok("classified data"); return ToolResult.pending(new Request(null, "approval", "approve access?")); } };
try { AgentDef def = new AgentDef("asker", "needs approvals", "", "test-model").tools(List.of(revealSecret)); RuntimeOptions rtOpts = new RuntimeOptions() .baseUrl("http://127.0.0.1:" + llm.getAddress().getPort()) .apiKey("test-key") .registry(Map.of("asker", def)); AgentRuntime rt = new AgentRuntime(rtOpts); Handle h = rt.spawn(rt.root, "asker").handle();
var fut = rt.futureResult(h); rt.wake(h, "get the secret"); // no waitFor anywhere ⇒ durable pending, not inline TaskResult r1 = fut.join();
if (!"pending".equals(r1.status())) throw new AssertionError(r1.status()); if (h.state != Handle.State.SUSPENDED) throw new AssertionError(h.state);
rt.resume(new Answer(r1.pending().id(), true));
if (h.state != Handle.State.IDLE) throw new AssertionError("resumed back to idle: " + h.state); if (h.lastResult == null || !h.lastResult.text().equals("secret retrieved")) { throw new AssertionError(h.lastResult); }
System.out.println("ok: suspended -> resumed -> " + h.lastResult.text()); } finally { llm.stop(0); } }}Fields and overloads
Section titled “Fields and overloads”| Member | Type | What it is |
|---|---|---|
state |
volatile State |
IDLE | RUNNING | SUSPENDED | CLOSED. Read any time without a lock. |
id |
String |
Deterministic, parent-scoped: root/coordinator.1/explore.2. Never random. |
inbox |
Deque<InboxItem> |
Agent STATE — persistable, observable — never a language-level mailbox. |
children |
List<Handle> |
This handle’s directly spawned children. |
depth |
int |
0 for root’s direct children; capped by the effective maxDepth. |
def |
AgentDef |
The registered definition this handle was spawned from. |
parent |
Handle |
null only for root itself. |
turnsTotal / usageTotal |
int / long |
Cumulative across this handle’s own turns (never a child’s). |
lastResult |
TaskResult |
What waitOn answers immediately on a settled (idle-with-result or closed) handle. |
See also
Section titled “See also”agents.Runtime— the six verbs that create and transition handles.agents.Agent— the declarative layer one handle instantiates.suspension/pending— the §10 contract behind theSUSPENDEDstate.