The task tool — model-facing delegation
C# · package Toolnexus · SPEC §7D · Agents/AgentRuntime.cs
task { agent: string, prompt: string } -> stringtask = spawn → wake → wait → close, fused into one call. The parent’s transcript gains exactly
one tool message per call, however long the child’s own run takes — the child runs on a fresh
transcript, and its usage rolls up into the parent’s ledger. The tool’s description lists only
the caller’s own Team (sorted by name, each entry "<name>: <does>"); a target outside the team
is a loud tool error, never silently ignored.
When to use it
Section titled “When to use it”You want the model itself to decide when to delegate — not you, wiring a fixed pipeline of
task tool calls in your own code. Give an AgentDef/AgentSpec a Team, and its model gains a
task tool scoped to exactly that team; nothing else changes about how you build or run the agent.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — the model delegates, once
Section titled “1. The smallest useful call — the model delegates, once”using Toolnexus.Agents;
var llm = new MockLlm(req =>{ var body = ReadBody(req); if (body.Contains("\"role\":\"tool\"")) return Task.FromResult(MockLlm.Text($"coordinator says: {ExtractLastToolContent(body)}")); return Task.FromResult(MockLlm.ToolCall("t1", "task", new { agent = "explorer", prompt = "find the treasure" }));});
var rt = new AgentRuntime(new RuntimeOptions{ ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary<string, AgentDef> { ["coordinator"] = new() { Name = "coordinator", Does = "delegates exploration", Team = new List<string> { "explorer" }, // non-empty Team ⇒ the runtime registers `task` }, ["explorer"] = new() { Name = "explorer", Does = "explores and reports back" }, },});
var h = rt.Spawn(rt.Root, "coordinator").Handle!;var wait = rt.WaitAsync(h);rt.Wake(h, "find the treasure via the team");var result = await wait;
if (result.Status != "done") throw new Exception(result.Status);if (!result.Text.Contains("coordinator says:")) throw new Exception(result.Text);
await rt.CloseAsync(rt.Root);Console.WriteLine($"ok: {result.Text}");
static string ReadBody(System.Net.Http.HttpRequestMessage req) => req.Content!.ReadAsStringAsync().GetAwaiter().GetResult();static string ExtractLastToolContent(string body){ using var doc = System.Text.Json.JsonDocument.Parse(body); var msgs = doc.RootElement.GetProperty("messages").EnumerateArray().ToList(); return msgs.Last(m => m.GetProperty("role").GetString() == "tool").GetProperty("content").GetString() ?? "";}
sealed class MockLlm : System.Net.Http.HttpMessageHandler{ readonly Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> _respond; public MockLlm(Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> respond) => _respond = respond; protected override Task<System.Net.Http.HttpResponseMessage> SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 10, completion_tokens = 5, total_tokens = 15 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), };}2. The realistic case — two parallel calls to the same agent+prompt reattach, not duplicate
Section titled “2. The realistic case — two parallel calls to the same agent+prompt reattach, not duplicate”using Toolnexus.Agents;
var childRuns = 0;var llm = new MockLlm(req =>{ var body = ReadBody(req); var model = System.Text.Json.JsonDocument.Parse(body).RootElement.GetProperty("model").GetString(); if (model == "researcher-model") { childRuns++; return Task.FromResult(MockLlm.Text("42")); } if (body.Contains("\"role\":\"tool\"")) return Task.FromResult(MockLlm.Text("got two answers back")); // ONE turn issues TWO parallel task calls for the SAME agent+prompt (SPEC §7D: parallel task // calls in one turn run concurrently; identical key ⇒ reattach to one child, never a duplicate spawn). return Task.FromResult(MockLlm.ToolCalls( ("c1", "task", new { agent = "researcher", prompt = "what is the answer?" }), ("c2", "task", new { agent = "researcher", prompt = "what is the answer?" })));});
var rt = new AgentRuntime(new RuntimeOptions{ ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "coordinator-model", Registry = new Dictionary<string, AgentDef> { ["coordinator"] = new() { Name = "coordinator", Does = "asks twice, by accident", Team = new List<string> { "researcher" } }, ["researcher"] = new() { Name = "researcher", Does = "researches an answer", Model = "researcher-model" }, },});
var h = rt.Spawn(rt.Root, "coordinator").Handle!;var wait = rt.WaitAsync(h);rt.Wake(h, "ask the researcher, twice");var result = await wait;
if (result.Status != "done") throw new Exception(result.Status);// Reattachment by task key (agent+prompt) is the ONLY idempotency mechanism — only one child spawned.if (h.Children.Count != 1) throw new Exception($"expected 1 reattached child, got {h.Children.Count}");if (childRuns != 1) throw new Exception($"expected the researcher model to run exactly once, ran {childRuns} time(s)");
await rt.CloseAsync(rt.Root);Console.WriteLine($"ok: {result.Text} (1 child spawned for 2 identical task calls, ran {childRuns} time)");
static string ReadBody(System.Net.Http.HttpRequestMessage req) => req.Content!.ReadAsStringAsync().GetAwaiter().GetResult();
sealed class MockLlm : System.Net.Http.HttpMessageHandler{ readonly Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> _respond; public MockLlm(Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> respond) => _respond = respond; protected override Task<System.Net.Http.HttpResponseMessage> SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCalls(params (string id, string name, object args)[] calls) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = calls.Select(c => new { id = c.id, type = "function", function = new { name = c.name, arguments = System.Text.Json.JsonSerializer.Serialize(c.args) } }).ToArray() } }, }, usage = new { prompt_tokens = 10, completion_tokens = 5, total_tokens = 15 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), };}3. Full surface — a target outside the team is a loud, named error
Section titled “3. Full surface — a target outside the team is a loud, named error”using Toolnexus.Agents;
var llm = new MockLlm(req =>{ var body = ReadBody(req); if (body.Contains("\"role\":\"tool\"")) return Task.FromResult(MockLlm.Text($"acknowledged: {ExtractLastToolContent(body)}")); // targets an agent that exists in the REGISTRY but not in THIS agent's team. return Task.FromResult(MockLlm.ToolCall("r1", "task", new { agent = "stranger", prompt = "hi" }));});
var rt = new AgentRuntime(new RuntimeOptions{ ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary<string, AgentDef> { ["rogue"] = new() { Name = "rogue", Does = "tries to go off-team", Team = new List<string> { "ally" } }, ["ally"] = new() { Name = "ally", Does = "a legitimate teammate" }, ["stranger"] = new() { Name = "stranger", Does = "not on rogue's team" }, },});
var h = rt.Spawn(rt.Root, "rogue").Handle!;var wait = rt.WaitAsync(h);rt.Wake(h, "try to reach stranger");var result = await wait;
if (result.Status != "done") throw new Exception(result.Status);if (!result.Text.Contains("not in this agent's team")) throw new Exception(result.Text);if (!result.Text.Contains("ally")) throw new Exception("the error should list the actual team");if (h.Children.Count != 0) throw new Exception("an out-of-team target must never spawn a child");
await rt.CloseAsync(rt.Root);Console.WriteLine($"ok: {result.Text}");
static string ReadBody(System.Net.Http.HttpRequestMessage req) => req.Content!.ReadAsStringAsync().GetAwaiter().GetResult();static string ExtractLastToolContent(string body){ using var doc = System.Text.Json.JsonDocument.Parse(body); var msgs = doc.RootElement.GetProperty("messages").EnumerateArray().ToList(); return msgs.Last(m => m.GetProperty("role").GetString() == "tool").GetProperty("content").GetString() ?? "";}
sealed class MockLlm : System.Net.Http.HttpMessageHandler{ readonly Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> _respond; public MockLlm(Func<System.Net.Http.HttpRequestMessage, Task<System.Net.Http.HttpResponseMessage>> respond) => _respond = respond; protected override Task<System.Net.Http.HttpResponseMessage> SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 10, completion_tokens = 5, total_tokens = 15 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), };}| Field | What it is |
|---|---|
agent |
Must be one of the caller’s Team names — anything else ⇒ an isError result listing the real team, never a spawn. |
prompt |
The task text; together with agent this is the reattachment key — a repeat call with the same pair reuses the existing child. |
| Result | Text (or [<status>] <text> when the child didn’t finish "done"), IsError from the child, Metadata { agent, turns, totalTokens }. |
See also
Section titled “See also”Agent— Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent.AgentRuntime— The six host verbs that drive sub-agents, plus the read-only list and inspect views.Handle— The state machine for one spawned agent: pending, running, suspended, done.AgentTypes.Budget— Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight.