Budget
Clojure (JVM) + cljgo · package net.clojars.muthuishere/toolnexus · SPEC §7D · clojure/src/toolnexus/agents/runtime.cljc:512
;; a Budget is a PLAIN MAP of any of these keys — all optional, money deliberately absent{:max-turns 6 ; lifetime turn cap for this handle (default 6) :max-tokens 20000 ; token pool shared by this handle and its whole subtree :max-tool-calls 50 ; tool-call cap for this handle :max-wall-ms 60000 ; wall-clock cap since spawn :max-children 4 ; how many DIRECT children this handle may spawn :max-concurrent 8 ; how many of this handle's children may be "running" at once (default 8) :max-depth 3} ; how many delegation levels below THIS handle are allowed (default 3)
;; set on an AgentDef, carved from the parent's pool at spawn time{:name "worker" :model "m" :budget {:max-tokens 20000 :max-depth 5}}
;; the host callback, given ONLY when a limit would stop the NEXT turn(fn [info] "stop") ; or "extend" or "suspend" — anything else is read as "stop";; info = {:handle id :limit "maxTurns" :prompt "…" :turns n :tokens n :tool-calls n :pool {...}}Budget caps tool calls and wall-clock time per agent and per team, enforced while the run is
in flight — not just checked once at spawn. “A Budget is a map of any of :max-turns :max-tokens :max-tool-calls :max-wall-ms :max-children :max-concurrent :max-depth. Money is
deliberately absent — it is vendor data, and a host converts tokens to money itself”
(clojure/src/toolnexus/agents/runtime.cljc:512-513).
Hierarchical enforcement — the carve is not the whole story
Section titled “Hierarchical enforcement — the carve is not the whole story”A child’s budget is carved from its parent’s shared pool at spawn
time, but that carve alone would miss a sibling draining the same pool first. “Usage roll-up IS
the budget ledger: a child’s spend drains every ancestor’s” pool
(clojure/src/toolnexus/agents/runtime.cljc:~296), and both spawn and wake do a live walk
up the ancestor chain before admitting new work — so a handle whose own carved :pool-tokens
(visible via inspect) still looks untouched can still be refused
because a sibling elsewhere in the tree already spent the shared pool down. maxDepth and
maxChildren are checked only at spawn, against the spawning parent’s own effective cap —
not an ancestor’s — so a child with no :budget of its own inherits a fresh default maxDepth 3
for ITS OWN children, counted from itself.
Nine named limit strings exist across the whole §7D vocabulary — the seven budget fields above
plus "completion" and "timeout" — spelled once as toolnexus.agents.runtime/limit-max-turns
etc. and documented in full on
agents.runtime/statuses / agents.runtime/limits; maxChildren
/ maxConcurrent / maxDepth are the three that surface as a thrown spawn refusal rather
than a settled TaskResult (A17), because they are admission refusals, not turn stops.
The :on-budget host callback
Section titled “The :on-budget host callback”create-runtime’s optional :on-budget is consulted only when a limit would stop the next
turn — absent, the limit always stops it, byte-identically to every other port. When present, it
is (fn [info] "stop"|"extend"|"suspend") and the three decisions are:
"stop"— settle{:status "incomplete" :limit "<name>"}, naming the limit. What happens by default with no hook at all."extend"— grant exactly one more turn past the limit (a one-shot:budget-grantflag), then re-runwakeso gate 2’s concurrency queue is honoured unchanged. The pool itself is not rewritten — the host is asked again before the turn after that. The host owns the loop bound, never the runtime."suspend"— park the limit as a §10"approval"Request(idle→suspended, neverrunning→suspended, because the suspension happens before the turn admits). The classic “402 → top-up → resume” story: the host answers withresumeand anAnswer;ok=trueruns the turn the approval bought,ok=falsesettles the same"incomplete"a"stop"would have.
Anything else the callback returns — nil, a typo, a thrown exception — is read as "stop". An
unrecognised decision must never become a silent grant, which is the one way an :on-budget hook
could turn a bounded run into an unbounded one.
Defaults
Section titled “Defaults”Three ceilings are pinned across every port when an AgentDef sets no :budget of its own
(clojure/src/toolnexus/agents/runtime.cljc:130-135):
default-max-turns= 6default-max-concurrent= 8 (also the default:max-concurrent-turnstree-wide gate oncreate-runtimeitself — same number, two different scopes)default-max-depth= 3
When to use it
Section titled “When to use it”- Bounding how far a delegation tree can grow —
:max-depthand:max-childrenstop a runawaytaskchain before it spawns unboundedly. - Capping spend per team, not just per agent — put
:max-tokenson a coordinator’s def and every child it spawns draws from that one shared pool, live, not just at carve time. - Turning a hard stop into a host decision — set
:on-budgetoncreate-runtimewhen you want a top-up flow, a one-shot grace turn, or a metered escalation instead of an unconditional"incomplete".
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — a hard turn cap with no host hook
Section titled “1. The smallest useful call — a hard turn cap with no host hook”(require '[toolnexus.agents.runtime :as rt] '[koine.json :as json])
(println)
(defn- text-response [t] {:choices [{:message {:role "assistant" :content t}}] :usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def runtime (rt/create-runtime {:registry {"w" {:name "w" :does "…" :model "wm" :budget {:max-turns 1}}} :llm {:base-url "http://mock.local" :model "default"} :http-client (fn [_url _headers _body] {:status 200 :headers {} :body (json/write-str (text-response "hi"))})}))
(let [h (rt/spawn runtime rt/root "w")] (rt/wake runtime h "first") (rt/wait runtime h) ; => "done", turn 1 of 1 spent (let [refusal (rt/wake runtime h "second")] (assert (false? (:ok refusal)))) ; => false — the SECOND wake exceeds the cap (let [r (rt/wait runtime h)] (assert (= "incomplete" (:status r))) (assert (= "maxTurns" (:limit r)))))(println "OK")The handle is never closed or crashed by a limit stop — it stays "idle", ready to be woken
again once the host raises the budget or spawns a successor.
2. The realistic case — a shared pool a sibling can drain out from under you
Section titled “2. The realistic case — a shared pool a sibling can drain out from under you”(require '[toolnexus.agents.runtime :as rt] '[koine.json :as json])
(println)
(defn- text-response [t] {:choices [{:message {:role "assistant" :content t}}] :usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def runtime (rt/create-runtime {:registry {"boss" {:name "boss" :does "…" :model "bm" :budget {:max-tokens 10 :max-depth 5}} "w" {:name "w" :does "…" :model "wm"}} :llm {:base-url "http://mock.local" :model "default"} :http-client (fn [_url _headers _body] {:status 200 :headers {} :body (json/write-str (text-response "hi"))})}))
(let [boss (rt/spawn runtime rt/root "boss") ; boss's def sets :budget {:max-tokens 10 :max-depth 5} a (rt/spawn runtime boss "w") b (rt/spawn runtime boss "w")] ;; b was carved BEFORE a spent anything — its own view still says the full 10 (assert (= 10 (:pool-tokens (rt/inspect runtime b))))
(rt/wake runtime a "burn it") (rt/wait runtime a) (assert (= 15 (:tokens (rt/inspect runtime a)))) ; spent more than the pool alone allowed (assert (= 10 (:pool-tokens (rt/inspect runtime b)))) ; UNCHANGED — the carve alone would say "fine"
;; but the LIVE ancestor walk at wake time catches it anyway (assert (false? (:ok (rt/wake runtime b "my turn")))) (let [r (rt/wait runtime b)] (assert (= "incomplete" (:status r))) (assert (= "maxTokens" (:limit r)))))(println "OK")3. The full surface — an :on-budget top-up flow via "suspend" and resume
Section titled “3. The full surface — an :on-budget top-up flow via "suspend" and resume”(require '[toolnexus.agents.runtime :as rt] '[toolnexus.client :as client] '[koine.json :as json])
(println)
(defn- text-response [t] {:choices [{:message {:role "assistant" :content t}}] :usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def runtime (rt/create-runtime {:registry {"w" {:name "w" :does "…" :model "wm" :budget {:max-turns 1}}} :llm {:base-url "http://mock.local" :model "default"} :http-client (fn [_url _headers _body] {:status 200 :headers {} :body (json/write-str (text-response "hi"))}) :on-budget (fn [_info] "suspend")})) ; route every limit hit through §10 approval
(let [h (rt/spawn runtime rt/root "w")] (rt/wake runtime h "one") (rt/wait runtime h) (rt/wake runtime h "two") ; parks rather than failing outright (assert (= "suspended" (:state (rt/inspect runtime h)))) (let [req (:pending (rt/wait runtime h))] (assert (= "approval" (:kind req)))
;; the host tops up: an Answer with ok=true buys the exact one turn the limit blocked (rt/resume runtime (client/make-answer (:id req) true)) (let [r (rt/wait runtime h)] (assert (= "done" (:status r))) ; turns GREW to 2, never reset (assert (= 2 (:turns r))))))(println "OK")See also
Section titled “See also”AgentDef— Where:budgetis set per registered agent.create-runtime—spawncarves the budget and walks the ancestor chain;wakere-checks it live;resumeis how a"suspend"decision is answered.agents.runtime/statuses / agents.runtime/limits— The nine named limit strings (limit-max-turnsetc.) a stoppedTaskResult’s:limitfield names.