Skip to content

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.

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-grant flag), then re-run wake so 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, never running→suspended, because the suspension happens before the turn admits). The classic “402 → top-up → resume” story: the host answers with resume and an Answer; ok=true runs the turn the approval bought, ok=false settles 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.

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 = 6
  • default-max-concurrent = 8 (also the default :max-concurrent-turns tree-wide gate on create-runtime itself — same number, two different scopes)
  • default-max-depth = 3
  • Bounding how far a delegation tree can grow — :max-depth and :max-children stop a runaway task chain before it spawns unboundedly.
  • Capping spend per team, not just per agent — put :max-tokens on 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-budget on create-runtime when you want a top-up flow, a one-shot grace turn, or a metered escalation instead of an unconditional "incomplete".

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")
  • AgentDef — Where :budget is set per registered agent.
  • create-runtime — spawn carves the budget and walks the ancestor chain; wake re-checks it live; resume is how a "suspend" decision is answered.
  • agents.runtime/statuses / agents.runtime/limits — The nine named limit strings (limit-max-turns etc.) a stopped TaskResult’s :limit field names.