Toolnexus.Agents.Handle
Elixir · package toolnexus · SPEC §7D · elixir/lib/toolnexus/agents/handle.ex
# states: idle → running → (idle | suspended | closed)# suspended → running ONLY via the Answer to its pending Request
@spec snapshot(pid()) :: map()def snapshot(pid)# -> %{id, state, def, parent, depth, inbox, pool_tokens, usage_total, turns_total,# tool_calls_total, children, pending_req, last_result, task_key, ...}
# The six verbs (Toolnexus.Agents.Runtime wraps these; see that page):def post(pid, item) # -> %{ok, is_error?}def wake(pid, prompt \\ nil) # -> %{ok, is_error?}def wait(pid, timeout_ms \\ nil, opts \\ []) # -> next-or-last resultdef interrupt(pid) # -> :ok, running→idle (never a kill)One GenServer per handle. The handle’s inbox is GenServer STATE — bounded, loud-reject,
transactionally drained, checkpointable — never the BEAM mailbox; the BEAM mailbox carries only the
six verbs. A Run (one client-loop invocation) is a separate monitored process: its crash or
kill crosses this boundary as an is_error result, never a GenServer exit — only the root may
throw to the host. Handle→handle blocking calls flow strictly rootward (child→ancestor); the
only parent→child interaction is a cast (the concurrency-slot transfer on dequeue). Downward
traversal — close cascade, list, resume — runs from outside the tree.
When to use it
Section titled “When to use it”- Reading one handle’s full state —
snapshot/1is the only way to seeinbox,children,pending_req, and the rolled-upusage_total/turns_totalfor a single handle;Runtime.list/1only gives the flat summary. - You already hold a
pidfromRuntime.spawn_agent/4and want to drive it directly — every verb is also exposed onRuntime, which is the everyday way to call them;Handleis the module they actually dispatch to. - Understanding the state machine itself —
idle → running → (idle | suspended | closed), andsuspended → runningonly via the Answer to its own pending Request; there is no other exit fromsuspended.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — spawn, then read the idle snapshot
Section titled “1. The smallest useful call — spawn, then read the idle snapshot”alias Toolnexus.{Agents, Agents.Runtime, Agents.Handle}
transport = fn _req -> {:ok, %{status: 200, headers: %{}, body: %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "ok"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}}}}end
peer = Agents.agent("peer", does: "does nothing yet", model: "m-peer")rt = Runtime.new(%{transport: transport, registry: Agents.registry(peer)})
{:ok, h} = Runtime.spawn_agent(rt, Runtime.root(rt), "peer")snap = Handle.snapshot(h)
true = snap.id == "root/peer.1"true = snap.state == :idletrue = snap.depth == 1true = snap.inbox == []true = snap.children == []
Runtime.shutdown(rt)IO.puts("ok: #{snap.id} spawned #{snap.state} at depth #{snap.depth}")2. The realistic case — idle→running→idle, observed via snapshot/1
Section titled “2. The realistic case — idle→running→idle, observed via snapshot/1”alias Toolnexus.{Agents, Agents.Runtime, Agents.Handle}
transport = fn _req -> {:ok, %{status: 200, headers: %{}, body: %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "done"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}}}}end
peer = Agents.agent("peer", does: "settles quickly", model: "m-peer")rt = Runtime.new(%{transport: transport, registry: Agents.registry(peer)}){:ok, h} = Runtime.spawn_agent(rt, Runtime.root(rt), "peer")
true = Handle.snapshot(h).state == :idle
Runtime.wake(rt, h, "go")r = Runtime.wait(rt, h)true = r.status == "done"
after_snap = Handle.snapshot(h)true = after_snap.state == :idletrue = after_snap.turns_total == 1true = after_snap.last_result.text == "done"
true = Handle.snapshot(h).id == "root/peer.1"trace = Runtime.trace(rt)true = Enum.any?(trace, &(&1 =~ "root/peer.1: idle→running"))
Runtime.shutdown(rt)IO.puts("ok: settled back to #{after_snap.state} after #{after_snap.turns_total} turn")3. The full surface — suspended, then Runtime.resume/2 back to done
Section titled “3. The full surface — suspended, then Runtime.resume/2 back to done”The Answer is the only exit from suspended — never a plain wake. A suspending tool result
(metadata.pending) surfaces as status: "pending" (§10); the handle itself flips to :suspended.
alias Toolnexus.{Agents, Agents.Runtime, Agents.Handle, Answer, Request, ToolResult}
transport = fn %{body: body} -> tool_msgs = Enum.filter(body["messages"] || [], &(&1["role"] == "tool"))
resp = if Enum.any?(tool_msgs, &String.contains?(to_string(&1["content"]), "secret-token")) do %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "asker-done: secret-token"}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3}} else %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "a1", "type" => "function", "function" => %{"name" => "check_secret", "arguments" => "{}"}} ]}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3} } end
{:ok, %{status: 200, headers: %{}, body: resp}}end
check_secret = Toolnexus.define_tool(%{ name: "check_secret", description: "needs human approval", input_schema: %{"type" => "object", "properties" => %{}}, execute: fn _args, ctx -> if ctx.answer && ctx.answer.ok do "secret-token" else %ToolResult{ output: "approve secret access?", is_error: false, metadata: %{pending: %Request{id: "req-1", kind: "approval", prompt: "approve secret access?"}} } end end })
asker = Agents.agent("asker", does: "needs approvals", uses: %{tools: [check_secret]}, model: "m-asker")rt = Runtime.new(%{transport: transport, registry: Agents.registry(asker)}){:ok, h} = Runtime.spawn_agent(rt, Runtime.root(rt), "asker")
Runtime.wake(rt, h, "get the secret")r = Runtime.wait(rt, h)
true = r.status == "pending"true = r.pending.kind == "approval"true = Handle.snapshot(h).state == :suspended
# resume/2 routes the Answer to this (deepest) suspended handle and blocks until it resettles.:ok = Runtime.resume(rt, %Answer{id: r.pending.id, ok: true})final = Runtime.wait(rt, h)
true = final.status == "done"true = final.text == "asker-done: secret-token"true = Handle.snapshot(h).state == :idle
Runtime.shutdown(rt)IO.puts("ok: suspended -> resume -> \"#{final.text}\"")snapshot/1 shape
Section titled “snapshot/1 shape”| Field | Type | What it is |
|---|---|---|
id |
String.t() |
Deterministic, parent-scoped (root/coordinator.1/explore.2) — never random. |
state |
:idle | :running | :suspended | :closed |
|
depth |
non_neg_integer() |
Distance from root. |
inbox |
[map()] |
Items posted but not yet drained into a turn. |
pool_tokens |
non_neg_integer() | :infinity |
Remaining token budget (carved from the parent). |
usage_total / turns_total / tool_calls_total |
integers | Cumulative across every turn this handle has run. |
children |
[%{pid, id, task_key}] |
Direct children only. |
pending_req |
Request.t() | nil |
Set only while state == :suspended. |
last_result |
map() | nil |
The most recent settled result (idle or suspended). |
See also
Section titled “See also”Toolnexus.Agents.agent— define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent.Toolnexus.Agents.Runtime— the six host verbs that drive sub-agents, plus the read-only list and inspect views.Toolnexus.Agents budget— cap tool calls and wall-clock per agent and per team, enforced while the run is in flight.