Toolnexus.Agents budget
Elixir · package toolnexus · SPEC §7D · elixir/lib/toolnexus/agents/handle.ex
# A budget is a PLAIN MAP — no dedicated struct in this port — passed as# :budget in an agent/2 spec, or as the 4th arg to spawn_agent/4:
%{ max_turns: 6, # LLM calls per Handle turn (client-loop cap; default 6) max_tokens: nil, # carved at spawn: effective = min(own request, parent remaining) max_tool_calls: nil, # cumulative across every wake this handle has done max_wall_ms: nil, # wall-clock since spawn max_children: :infinity, max_concurrent: 8, # running children per parent, admitted atomically max_depth: 3 # checked at spawn, on the PARENT}
@spec spawn_agent(pid(), pid(), String.t(), map() | nil) :: {:ok, pid()} | {:error, String.t()}def spawn_agent(rt, parent, def_name, budget \\ nil)# budget here OVERRIDES/merges onto the definition's own :budget for this one spawnBudgets are hierarchical and live-enforced, not just carved once at spawn: pool_tokens carves
min(own request, parent's remaining) when a handle is created, and every ancestor’s remaining pool
is walked again before each turn and each spawn — carving alone would miss sibling spend. Any
limit stop is loud: the handle settles with status: "incomplete" and the limit named in
limit ("maxTokens", "maxToolCalls", "maxWallMs", "maxChildren", "maxDepth") — never a
silent "done", never a crash. Partial work and the transcript are preserved.
When to use it
Section titled “When to use it”- Bounding a delegated sub-agent’s spend —
budget: %{max_tokens: 20_000}onagent/2, or a one-off override on a specificspawn_agent/4call. - Capping fan-out —
max_childrenandmax_depthstop a runaway delegation tree at spawn time, before any tokens are spent on it. - A slow tool or a stuck loop —
max_tool_callsandmax_wall_msbound a handle even when the model keeps asking for more turns.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — a budget map on agent/2
Section titled “1. The smallest useful call — a budget map on agent/2”alias Toolnexus.Agents
coder = Agents.agent("coder", does: "implements changes", budget: %{max_turns: 4, max_tokens: 20_000, max_tool_calls: 10})
reg = Agents.registry(coder)true = reg["coder"][:budget] == %{max_turns: 4, max_tokens: 20_000, max_tool_calls: 10}
IO.puts("ok: #{inspect(reg["coder"][:budget])}")2. The realistic case — carve at spawn, and max_children refused at spawn
Section titled “2. The realistic case — carve at spawn, and max_children refused at spawn”alias Toolnexus.{Agents, Agents.Runtime}
transport = fn _req -> {:ok, %{status: 200, headers: %{}, body: %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "ok"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}}}}end
explore = Agents.agent("explore", does: "read-only research", model: "m-explore")coordinator = Agents.agent("coordinator", does: "delegates", team: [explore], model: "m-coordinator")
rt = Runtime.new(%{transport: transport, registry: Agents.registry(coordinator)})
# coordinator carves 100 tokens off the (unlimited) root; its child ASKS for 500, but the# effective pool is min(own request, parent remaining) = 100.{:ok, c} = Runtime.spawn_agent(rt, Runtime.root(rt), "coordinator", %{max_tokens: 100, max_children: 2}){:ok, kid} = Runtime.spawn_agent(rt, c, "explore", %{max_tokens: 500})true = Runtime.snapshot(kid).pool_tokens == 100
{:ok, _kid2} = Runtime.spawn_agent(rt, c, "explore")kid3 = Runtime.spawn_agent(rt, c, "explore")
# maxChildren is checked at spawn time, on the PARENT — never mid-runtrue = match?({:error, _}, kid3){:error, msg} = kid3true = msg =~ "maxChildren"
carved = Runtime.snapshot(kid).pool_tokensRuntime.shutdown(rt)IO.puts("ok: carved #{carved} token(s); 3rd child refused: #{msg}")3. The full surface — max_tool_calls exhausted: loud incomplete, never a crash
Section titled “3. The full surface — max_tool_calls exhausted: loud incomplete, never a crash”The ancestor-chain walk runs before every turn, so a second wake is refused synchronously —
the handle never even starts a Run it cannot afford.
alias Toolnexus.{Agents, Agents.Runtime}
usage = %{"prompt_tokens" => 5, "completion_tokens" => 3, "total_tokens" => 8}
transport = fn %{body: body} -> tool_msgs = Enum.filter(body["messages"] || [], &(&1["role"] == "tool"))
resp = if tool_msgs == [] do %{"choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "l1", "type" => "function", "function" => %{"name" => "lookup", "arguments" => ~s({"q":"x"})}} ]}}], "usage" => usage} else %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "looked it up"}}], "usage" => usage} end
{:ok, %{status: 200, headers: %{}, body: resp}}end
lookup = Toolnexus.define_tool(%{ name: "lookup", description: "look something up", input_schema: %{"type" => "object", "properties" => %{"q" => %{"type" => "string"}}}, execute: fn args -> "data(#{args["q"]})" end})
worker = Agents.agent("worker", does: "answers, one tool call at a time", uses: %{tools: [lookup]}, model: "m-worker", budget: %{max_tool_calls: 1})
rt = Runtime.new(%{transport: transport, registry: Agents.registry(worker)}){:ok, h} = Runtime.spawn_agent(rt, Runtime.root(rt), "worker")
Runtime.wake(rt, h, "look something up")r1 = Runtime.wait(rt, h)true = r1.status == "done"true = Runtime.snapshot(h).tool_calls_total == 1
# a SECOND wake is refused synchronously — cumulative tool_calls_total (1) >= max_tool_calls (1)refusal = Runtime.wake(rt, h, "look up something else")true = refusal == %{ok: false, is_error: "budget exhausted (maxToolCalls 1); partial work preserved"}
r2 = Runtime.wait(rt, h)true = r2.status == "incomplete"true = r2.limit == "maxToolCalls"true = r2.is_error
Runtime.shutdown(rt)IO.puts("ok: 1st wake done, 2nd wake refused loudly — limit=#{r2.limit}")Budget map fields
Section titled “Budget map fields”| Field | Default | Enforced |
|---|---|---|
max_turns |
6 |
The client loop’s own cap (§8) — per Handle turn, not cumulative. |
max_tokens |
:infinity |
Carved at spawn (min(own, parent remaining)); walked live before each turn/spawn. |
max_tool_calls |
:infinity |
Cumulative across every wake this handle has done. |
max_wall_ms |
:infinity |
Wall-clock since spawn (via the runtime’s injectable clock). |
max_children |
:infinity |
Checked at spawn, on the parent. |
max_concurrent |
8 |
Running children per parent — an atomic admission gate, not a hard reject (excess wakes queue FIFO). |
max_depth |
3 |
Checked at spawn, on the parent. |
A limit stop is always status: "incomplete" with limit naming which one tripped — money is
excluded from the budget dimensions (vendor-specific; hosts convert their own cost tracking in an
optional onBudget callback).
See also
Section titled “See also”Toolnexus.Agents.agent— define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent.Toolnexus.Agents.Runtime— the six host verbs that drive sub-agents, plus the read-only list and inspect views.Toolnexus.Agents.Handle— the state machine for one spawned agent: pending, running, suspended, done.