Skip to content

The task tool — model-facing delegation

Rust · crate toolnexus · SPEC §7D · rust/src/agents/runtime.rs

The opt-in tool that lets the model itself spawn a teammate. Default OFF.

Declare Spec.team on an agent — a non-empty team is what turns the task tool on for that agent, scoped to exactly those names. There is nothing further to build:

let coordinator = agents::new("coordinator", Spec {
does: "splits work and delegates".into(),
team: vec![explorer, coder], // ⇒ a "task" tool appears, targets = these two
..Default::default()
});

An agent with an EMPTY team gets no task tool at all — delegation (and recursion) is opt-in, never a default. The tool’s description is generated for you: each teammate’s name plus its does string, sorted by name — the same pattern the §3 skill tool uses to describe what it can load.

Hermetic: an in-process model (Options.in_process) in place of a real provider.

The tool the model sees, team scoping, and idempotent reattachment

Section titled “The tool the model sees, team scoping, and idempotent reattachment”
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use toolnexus::agents::{self, Options, Spec};
use toolnexus::client::{InProcessResponse, InProcessToolCall};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let explorer = agents::new("explorer", Spec { does: "read-only research".into(), model: "m-explorer".into(), ..Default::default() });
let rogue = agents::new("rogue", Spec {
does: "tries to delegate outside its team".into(),
model: "m-rogue".into(),
team: vec![explorer],
..Default::default()
});
// A def with NO team declared gets no task tool at all.
let solo = agents::new("solo", Spec { does: "works alone".into(), model: "m-solo".into(), ..Default::default() });
assert_eq!(solo.registry().len(), 1, "a teamless agent's registry holds only itself");
let seen_tools: Arc<Mutex<Vec<Value>>> = Arc::default();
let seen = seen_tools.clone();
let opts = Options {
// The rogue model tries to delegate to a stranger, then echoes the tool result back
// verbatim so the final answer proves what the task tool returned.
in_process: Some(Arc::new(move |req| {
let seen = seen.clone();
Box::pin(async move {
let tool_result = req.messages.iter().rev()
.find(|m| m["role"] == "tool")
.and_then(|m| m["content"].as_str().map(String::from));
if req.model == "m-rogue" && tool_result.is_none() {
*seen.lock().unwrap() = req.tools.clone();
return Ok(InProcessResponse {
tool_calls: vec![InProcessToolCall {
id: "c1".into(), name: "task".into(),
arguments: json!({"agent": "stranger", "prompt": "hi"}),
}],
..Default::default()
});
}
Ok(InProcessResponse { content: tool_result.unwrap_or_default(), ..Default::default() })
})
})),
..Default::default()
};
let (r, _rt) = rogue.run(opts, "delegate to a stranger").await;
assert!(r.text.contains("not in this agent's team") && r.text.contains("explorer"),
"expected an out-of-team refusal naming the real team, got {:?}", r.text);
// The model saw exactly one task tool, described by its team.
let tools = seen_tools.lock().unwrap().clone();
let task = tools.iter().find(|t| t["function"]["name"] == "task").expect("a task tool");
assert!(task["function"]["description"].as_str().unwrap().contains("explorer: read-only research"));
println!("ok: {}", r.text);
Ok(())
}
Value
name "task"
description "Delegate a subtask to an isolated subagent. Available agents — <name>: <does>; ..." (team, name-sorted)
input_schema {agent: string, prompt: string}, both required

A call outside the declared team is refused with the team names listed — never silently ignored. A repeated call with the same (agent, prompt) pair REATTACHES to the existing child (by task key) instead of spawning a duplicate: settled ⇒ its recorded result, suspended ⇒ its pending, running ⇒ awaited. This is what makes a durable §10 resume idempotent across a re-invoked task call.