Skip to content

ToolContext — what execute receives

Rust · crate toolnexus · SPEC §1

pub struct ToolContext {
pub cancel: CancellationToken,
pub call_id: Option<String>,
pub timeout_ms: u64,
pub answer: Option<Answer>,
}

Optional per-call context handed to execute: cancellation, identity, and host-supplied state.

Inside a tool’s execute: honour cancel when the turn is cancelled, tell sibling calls apart with call_id (the loop runs one message’s calls concurrently), and read answer when the loop re-runs you after a §10 suspension.

Read cancellation from the context rather than a global flag: the loop creates one token per turn, so cancelling one turn never stops another.

use std::time::{Duration, Instant};
use serde_json::json;
use toolnexus::{ToolContext, define_tool};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let slow = define_tool("slow", "Waits five seconds", None, |_a, ctx| async move {
tokio::select! {
_ = ctx.cancel.cancelled() => Err("cancelled".to_string()),
_ = tokio::time::sleep(Duration::from_secs(5)) => Ok("done".to_string()),
}
});
let ctx = ToolContext::default();
let token = ctx.cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
token.cancel();
});
let t0 = Instant::now();
let r = slow.call(json!({}), ctx).await;
assert!(r.is_error && r.output == "cancelled");
assert!(t0.elapsed() < Duration::from_secs(2), "returned promptly");
println!("ok: {} after {:?}", r.output, t0.elapsed());
Ok(())
}

timeout_ms is this call’s budget in milliseconds; 0 means unset. A tool that does its own waiting should bound itself by it.

use std::time::Duration;
use serde_json::json;
use toolnexus::{ToolContext, define_tool};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let fetch = define_tool("fetch", "Simulates a 200 ms call", None, |_a, ctx| async move {
let work = tokio::time::sleep(Duration::from_millis(200));
if ctx.timeout_ms == 0 {
work.await;
return Ok("finished".to_string());
}
tokio::time::timeout(Duration::from_millis(ctx.timeout_ms), work)
.await
.map(|_| "finished".to_string())
.map_err(|_| format!("timed out after {}ms", ctx.timeout_ms))
});
let tight = fetch
.call(json!({}), ToolContext { timeout_ms: 20, ..Default::default() })
.await;
let loose = fetch.call(json!({}), ToolContext::default()).await;
assert_eq!((tight.is_error, tight.output.as_str()), (true, "timed out after 20ms"));
assert_eq!((loose.is_error, loose.output.as_str()), (false, "finished"));
println!("ok: {} / {}", tight.output, loose.output);
Ok(())
}

3. answer — the second half of a suspension

Section titled “3. answer — the second half of a suspension”

The first call suspends with pending(..); the loop asks wait_for, then re-runs the same call with ctx.answer set. A suspension must be the tool’s whole ToolResult (it carries metadata.pending), so this tool is built as a Tool literal rather than through define_tool.

use std::sync::Arc;
use toolnexus::client::{InProcessOptions, InProcessResponse, InProcessToolCall, create_in_process_client};
use toolnexus::{
Answer, BuiltinsConfig, Request, Tool, ToolResult, ToolSource, ToolkitOptions, create_toolkit,
empty_schema, pending,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let confirm = Tool {
concurrency_safe: None,
name: "confirm".into(),
description: "Ask first".into(),
input_schema: empty_schema(),
source: ToolSource::Custom,
hints: None,
execute: Arc::new(|_a, ctx| {
Box::pin(async move {
match ctx.answer {
Some(a) if a.ok => ToolResult::ok("confirmed"),
Some(a) => ToolResult::error(format!("declined: {}", a.reason)),
None => pending(Request {
kind: "approval".into(),
prompt: "Proceed?".into(),
..Default::default()
}),
}
})
}),
};
let tk = create_toolkit(ToolkitOptions {
extra_tools: vec![confirm],
builtins: Some(BuiltinsConfig::off()),
..Default::default()
})
.await?;
// Scripted model: call `confirm` once, then repeat the tool's answer.
let client = create_in_process_client(InProcessOptions {
model: "scripted".into(),
generate: Some(Arc::new(|req| {
Box::pin(async move {
let last = req.messages.last().cloned().unwrap_or_default();
if last["role"] == "tool" {
let out = last["content"].as_str().unwrap_or("").to_string();
return Ok(InProcessResponse { content: format!("tool said: {out}"), ..Default::default() });
}
Ok(InProcessResponse {
tool_calls: vec![InProcessToolCall { name: "confirm".into(), ..Default::default() }],
..Default::default()
})
})
})),
// The host's human-in-the-loop: approve every request.
wait_for: Some(Arc::new(|req: Request| {
Box::pin(async move { Ok(Answer { id: req.id, ok: true, ..Default::default() }) })
})),
..Default::default()
});
let res = client.run("delete the build dir", &tk).await?;
assert_eq!(res.text, "tool said: confirmed");
println!("ok: {}", res.text);
Ok(())
}

A tool run by the client loop gets ToolContext.call_id = the model’s id for that call (None outside the loop). The loop runs one message’s tool calls concurrently, so this is how a tool — or a host tracking which subagent call is the parent of which UI row — tells sibling calls apart.

use std::sync::{Arc, Mutex};
use toolnexus::client::{InProcessOptions, InProcessResponse, InProcessToolCall, create_in_process_client};
use toolnexus::{BuiltinsConfig, ToolkitOptions, create_toolkit, define_tool};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let ids = Arc::new(Mutex::new(Vec::<String>::new()));
let seen = ids.clone();
let probe = define_tool("probe", "records its call id", None, move |_a, ctx| {
let seen = seen.clone();
async move {
seen.lock().unwrap().push(ctx.call_id.unwrap_or_else(|| "direct".into()));
Ok::<_, String>("ok".to_string())
}
});
let tk = create_toolkit(ToolkitOptions {
extra_tools: vec![probe],
builtins: Some(BuiltinsConfig::off()),
..Default::default()
})
.await?;
let client = create_in_process_client(InProcessOptions {
model: "scripted".into(),
generate: Some(Arc::new(|req| {
Box::pin(async move {
if req.messages.iter().any(|m| m["role"] == "tool") {
return Ok(InProcessResponse { content: "done".into(), ..Default::default() });
}
let call = |id: &str| InProcessToolCall { id: id.into(), name: "probe".into(), ..Default::default() };
Ok(InProcessResponse { tool_calls: vec![call("call_a"), call("call_b")], ..Default::default() })
})
})),
..Default::default()
});
client.run("probe twice", &tk).await?;
let mut got = ids.lock().unwrap().clone();
got.sort();
assert_eq!(got, ["call_a", "call_b"]);
println!("ok: {got:?}");
Ok(())
}
  • Tool — The uniform shape every tool source collapses to: name, description, JSON-Schema parameters, execute.
  • ToolResult — The result envelope: output text, optional error flag, optional non-text parts, and optional metadata that can carry a suspension.
  • ContentPart — The non-text half of a message: text | image | file | audio, carrying base64 bytes or a URL plus a mimeType — never a path.