Handle — a running sub-agent
Rust · crate toolnexus · SPEC §7D · rust/src/agents/runtime.rs
pub struct Handle { pub id: String, /* … */ } // cheap Clone; a capabilitypub enum State { Idle, Running, Suspended, Closed }pub struct TaskResult { pub text: String, pub is_error: bool, pub status: String, pub pending: Option<Request>, pub turns: usize, pub total_tokens: i64, pub own_tokens: i64, pub limit: String,}pub struct HandleView { pub id: String, pub state: State, pub tokens: i64, pub turns: usize, pub inbox: usize, pub pending_kind: String }A handle is one live agent with a deterministic, parent-scoped id (root/coordinator.1/explore.2).
Its state machine is pinned: idle → running → idle | suspended | closed; suspended → running only
through the Answer to its pending Request. Every field past id is private and mutated under the
runtime’s own lock; you never poke a Handle directly. Instead, the Runtime that spawned it
exposes race-safe read views: state_of for the raw transition state, inspect/list for the
fuller HandleView (tokens, turns, inbox depth, the pending Request’s kind when suspended), plus
inbox_len, pool_tokens and usage_tokens. What crosses a handle boundary is always a
TaskResult, never a panic.
pub fn state_of(&self, h: &Handle) -> Statepub fn inspect(&self, h: &Handle) -> HandleViewpub fn inbox_len(&self, h: &Handle) -> usizepub fn pool_tokens(&self, h: &Handle) -> i64pub fn usage_tokens(&self, h: &Handle) -> i64When to use it
Section titled “When to use it”Read a handle’s state to build observability (a dashboard of running sub-agents), to poll for a
suspension before deciding whether to answer it, or to assert on transition traces in tests. You
obtain a Handle from spawn — never construct one yourself.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. idle right after spawn, before any wake
Section titled “1. idle right after spawn, before any wake”use std::collections::HashMap;use toolnexus::agents::{Def, Options, Runtime, State};
fn main() -> Result<(), Box<dyn std::error::Error>> { let registry = HashMap::from([( "peer".to_string(), Def { name: "peer".into(), does: "a worker".into(), model: "m-peer".into(), ..Default::default() }, )]); let rt = Runtime::new(Options { registry, ..Default::default() }); // no wake — no model needed
let h = rt.spawn(&rt.root(), "peer", None)?; assert_eq!(rt.state_of(&h), State::Idle); let v = rt.inspect(&h); assert!(v.state == State::Idle && v.turns == 0 && v.pending_kind.is_empty(), "{v:?}");
println!("ok: {} is {}", h.id, v.state); Ok(())}2. idle → running → idle (done), observed live
Section titled “2. idle → running → idle (done), observed live”use std::collections::HashMap;use std::sync::Arc;use std::time::Duration;use tokio::sync::Notify;use toolnexus::agents::{Def, Options, Runtime, State};use toolnexus::client::{BoxFuture, InProcessRequest, InProcessResponse, InProcessUsage};
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { // The model blocks until released, so we can OBSERVE `running` before the turn completes. let gate = Arc::new(Notify::new()); let g = gate.clone(); let model = Arc::new(move |_req: InProcessRequest| -> BoxFuture<Result<InProcessResponse, String>> { let g = g.clone(); Box::pin(async move { g.notified().await; Ok(InProcessResponse { content: "finished".into(), usage: Some(InProcessUsage { prompt_tokens: 3, completion_tokens: 3, total_tokens: 6 }), ..Default::default() }) }) }); let registry = HashMap::from([( "worker".to_string(), Def { name: "worker".into(), does: "a worker".into(), model: "m-worker".into(), ..Default::default() }, )]); let rt = Runtime::new(Options { in_process: Some(model), registry, ..Default::default() });
let h = rt.spawn(&rt.root(), "worker", None)?; rt.wake(&h, "go"); for _ in 0..200 { if rt.state_of(&h) == State::Running { break; } tokio::time::sleep(Duration::from_millis(1)).await; } assert_eq!(rt.state_of(&h), State::Running, "expected to observe running before completion");
gate.notify_one(); // let the turn finish let r = rt.wait(&h, None).await; assert_eq!(r.status, "done"); assert_eq!(rt.state_of(&h), State::Idle); let v = rt.inspect(&h); assert_eq!((v.turns, v.tokens), (1, 6), "{v:?}");
println!("ok: idle -> running -> idle, turns = {} tokens = {}", v.turns, v.tokens); Ok(())}3. The full surface — suspended, pending_kind, and durable resume
Section titled “3. The full surface — suspended, pending_kind, and durable resume”No wait_for anywhere in the chain ⇒ the suspension goes durable: the handle parks in
State::Suspended and Runtime::resume is the ONLY way back to running.
use std::collections::HashMap;use std::sync::Arc;use serde_json::json;use toolnexus::agents::{Def, Options, Runtime, State};use toolnexus::client::{BoxFuture, InProcessRequest, InProcessResponse, InProcessToolCall};use toolnexus::{Answer, Request, Tool, ToolResult, ToolSource, pending};
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { // check_secret needs approval the first time; once ctx.answer is satisfied, it succeeds. let check_secret = Tool { concurrency_safe: None, name: "check_secret".into(), description: "needs approval".into(), input_schema: json!({"type": "object", "properties": {}}), source: ToolSource::Custom, hints: None, execute: Arc::new(|_args, ctx| { Box::pin(async move { if ctx.answer.as_ref().is_some_and(|a| a.ok) { return ToolResult::ok("secret-token"); } pending(Request { kind: "approval".into(), prompt: "approve secret access?".into(), ..Default::default() }) }) }), }; let model = Arc::new(|req: InProcessRequest| -> BoxFuture<Result<InProcessResponse, String>> { let saw_tool_result = req.messages.iter().any(|m| m["role"] == "tool"); Box::pin(async move { Ok(if !saw_tool_result { InProcessResponse { tool_calls: vec![InProcessToolCall { id: "c1".into(), name: "check_secret".into(), arguments: json!({}) }], ..Default::default() } } else { InProcessResponse { content: "approved and done".into(), ..Default::default() } }) }) }); // No wait_for on this def, and none higher in the chain: the suspension goes durable. let registry = HashMap::from([( "asker".to_string(), Def { name: "asker".into(), does: "needs approvals".into(), model: "m-asker".into(), tools: vec![check_secret], ..Default::default() }, )]); let rt = Runtime::new(Options { in_process: Some(model), registry, ..Default::default() });
let h = rt.spawn(&rt.root(), "asker", None)?; rt.wake(&h, "get the secret"); let r = rt.wait(&h, None).await; assert_eq!(r.status, "pending", "{r:?}"); assert_eq!(rt.state_of(&h), State::Suspended); let v = rt.inspect(&h); assert_eq!(v.pending_kind, "approval");
// The out-of-band Answer resumes the DEEPEST suspended handle; resume hands back the // result of the topmost handle the cascade re-ran. let id = r.pending.expect("a pending request").id; let done = rt.resume(Answer { id, ok: true, ..Default::default() }).await?; assert_eq!(done.status, "done", "{done:?}"); assert_eq!(rt.state_of(&h), State::Idle);
println!("ok: suspended ({}) -> resumed -> {}", v.pending_kind, rt.state_of(&h)); Ok(())}HandleView fields
Section titled “HandleView fields”| Field | Type | What it holds |
|---|---|---|
id |
String |
The handle’s deterministic id. |
state |
State |
Idle | Running | Suspended | Closed (as_str() gives the lowercase spelling). |
tokens |
i64 |
Rolled-up token usage of the subtree — the budget ledger. |
turns |
usize |
Cumulative LLM round trips. |
inbox |
usize |
Current inbox depth. |
pending_kind |
String |
The suspended Request’s kind; "" unless state == Suspended. |
TaskResult carries turns and total_tokens cumulatively on every status; own_tokens
excludes children; limit names the stop on incomplete/timeout.
See also
Section titled “See also”agents::new— Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent.Runtime::new— The six host verbs that drive sub-agents, plus the read-only list and inspect views.Budget— Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight.