AgentTypes.Budget
C# · package Toolnexus · SPEC §7D · Agents/AgentTypes.cs
namespace Toolnexus.Agents;
public sealed class Budget{ public int? MaxTurns { get; set; } // default 6 when unset public long? MaxTokens { get; set; } public long? MaxToolCalls { get; set; } public long? MaxWallMs { get; set; } public int? MaxChildren { get; set; } public int? MaxConcurrent { get; set; } // default 8 when unset public int? MaxDepth { get; set; } // default 3 when unset}Hierarchical, live-enforced limits (SPEC §7D). A child’s budget is carved at spawn —
effective = min(own value, parent's remaining) for every pool — and then checked again by a live
ancestor-chain walk before every turn and every spawn, because carving alone misses spend by
siblings. Any limit stop surfaces as AgentResult.Status == "incomplete" with the limit named —
never a silent "done", never a crash. Money is deliberately excluded (vendor-specific pricing);
convert token/call counts to cost in your own onBudget/telemetry if you need it.
When to use it
Section titled “When to use it”You’re delegating to a sub-agent (or a whole team) and want a hard ceiling it cannot exceed —
tokens for a cost cap, tool calls to stop a runaway loop, wall-clock for a latency SLA, or
MaxChildren/MaxConcurrent/MaxDepth to bound how wide and deep the delegation tree can grow.
Set Budget on an AgentDef/AgentSpec, or pass an override to AgentRuntime.Spawn for a
one-off tighter cap on a single spawn.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — MaxTurns caps a loop that never finishes
Section titled “1. The smallest useful call — MaxTurns caps a loop that never finishes”using Toolnexus;using Toolnexus.Agents;
var lookup = NativeTool.Of("lookup", "look something up", new Dictionary<string, object?> { ["type"] = "object", ["properties"] = new Dictionary<string, object?>() }, (IDictionary<string, object?> _) => "data");
var calls = 0;var llm = new MockLlm(_ =>{ calls++; // always another tool call — a runaway loop with no final answer. return Task.FromResult(MockLlm.ToolCall($"c{calls}", "lookup", new { }));});
var rt = new AgentRuntime(new RuntimeOptions{ ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary<string, AgentDef> { ["looper"] = new() { Name = "looper", Does = "loops forever", Tools = new List<ITool> { lookup }, Budget = new Budget { MaxTurns = 1 } }, },});
var h = rt.Spawn(rt.Root, "looper").Handle!;var wait = rt.WaitAsync(h);rt.Wake(h, "look something up, repeatedly");var result = await wait;
if (result.Status != "incomplete") throw new Exception(result.Status);if (result.Text != "hit maxTurns without a final answer") throw new Exception(result.Text);
await rt.CloseAsync(rt.Root);Console.WriteLine($"ok: {result.Status} — {result.Text}");
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 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 — an exhausted pool stops a turn before it calls the model at all
Section titled “2. The realistic case — an exhausted pool stops a turn before it calls the model at all”using Toolnexus.Agents;
// The mock is never actually invoked in this example — the budget check runs BEFORE the HTTP call.var llm = new MockLlm(_ => throw new Exception("should never be called — budget is pre-exhausted"));
var rt = new AgentRuntime(new RuntimeOptions{ ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary<string, AgentDef> { ["capped"] = new() { Name = "capped", Does = "has zero tool-call budget", Budget = new Budget { MaxToolCalls = 0 } }, },});
var h = rt.Spawn(rt.Root, "capped").Handle!;if (h.PoolToolCalls != 0) throw new Exception($"expected a carved pool of 0, got {h.PoolToolCalls}");
var result = await rt.RunTurnAsync(h, "do anything");
if (result.Status != "incomplete") throw new Exception(result.Status);if (result.Text != "budget exhausted (toolCalls); partial work preserved") throw new Exception(result.Text);
await rt.CloseAsync(rt.Root);Console.WriteLine($"ok: {result.Status} — {result.Text}");
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);}3. Full surface — hierarchical carve, then the live ledger draining every ancestor
Section titled “3. Full surface — hierarchical carve, then the live ledger draining every ancestor”using Toolnexus.Agents;
var llm = new MockLlm(_ => Task.FromResult(MockLlm.Text("filed")));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 = "coordinates", Budget = new Budget { MaxTokens = 100 } }, ["helper"] = new() { Name = "helper", Does = "helps", Budget = new Budget { MaxTokens = 1_000 } }, // wider than the parent },});
var coordinator = rt.Spawn(rt.Root, "coordinator").Handle!;var helper = rt.Spawn(coordinator, "helper").Handle!;
// Carve: effective = min(own, parent remaining) — helper asked for 1000 but the parent only has 100.if (helper.PoolTokens != 100) throw new Exception($"expected the carve to cap at 100, got {helper.PoolTokens}");
await rt.RunTurnAsync(helper, "help out");
// The mock spends 8 tokens (usage.total_tokens) — the roll-up drains EVERY ancestor's pool live.if (helper.PoolTokens != 92) throw new Exception($"helper: expected 92, got {helper.PoolTokens}");if (coordinator.PoolTokens != 92) throw new Exception($"coordinator: expected 92 (rolled up), got {coordinator.PoolTokens}");if (coordinator.UsageTotal != 8) throw new Exception($"expected the coordinator's ledger to see the child's spend, got {coordinator.UsageTotal}");
await rt.CloseAsync(rt.Root);Console.WriteLine($"ok: carved to {helper.PoolTokens + 8}, drained to helper={helper.PoolTokens} coordinator={coordinator.PoolTokens}");
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) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(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 }, }), System.Text.Encoding.UTF8, "application/json"), };}Fields
Section titled “Fields”| Field | Type | Unset default | What it caps |
|---|---|---|---|
MaxTurns |
int? |
6 |
The client loop’s turns for one spawn’s turn — hit without a final answer ⇒ "incomplete". |
MaxTokens |
long? |
unlimited | Total tokens across this handle’s turns; ledger rolls up to every ancestor. |
MaxToolCalls |
long? |
unlimited | Total tool calls across this handle’s turns. |
MaxWallMs |
long? |
unlimited | Wall-clock deadline from spawn time, min’d with the parent’s. |
MaxChildren |
int? |
unlimited | Live children this handle may hold at once. |
MaxConcurrent |
int? |
8 |
Children of this handle allowed to run a turn simultaneously (excess wakes queue FIFO). |
MaxDepth |
int? |
3 |
How many spawn levels deep this handle’s subtree may go. |
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.