task-tool
Clojure (JVM) + cljgo · package net.clojars.muthuishere/toolnexus · SPEC §7D · clojure/src/toolnexus/agents/runtime.cljc:1548-1554
;; NOT called directly — the runtime builds one per handle, IFF the AgentDef has a :team(toolnexus.agents.runtime/task-tool rt parent-id def-map);; => a native Tool named "task", input {:agent "…" :prompt "…"}, :ctx? true
;; the input schema the model sees{:type "object" :properties {:agent {:type "string" :description "Team agent to delegate to"} :prompt {:type "string" :description "The subtask prompt"}} :required ["agent" "prompt"]}
;; the sibling: an AgentDef exposed AS a Tool, droppable into ANY toolkit(toolnexus.agents.runtime/agent-tool rt def-name);; => a native Tool named after def-name, input {:prompt "…"}The task {agent, prompt} tool is spawn→wake→wait→close, fused into one tool call — the
opt-in surface that lets the model itself delegate to a teammate, rather than only the host
calling spawn by hand.
Opt-in, and how it turns on
Section titled “Opt-in, and how it turns on”task-tool is never something you construct and hand to a toolkit yourself — the runtime’s
private toolkit-for adds it to a handle’s toolkit automatically, and only when that handle’s
AgentDef sets a non-empty :team
(clojure/src/toolnexus/agents/runtime.cljc:842-850):
;; no :team ⇒ no task tool at all — this agent CANNOT delegate, full stop{:name "solo" :does "…" :model "m"}
;; :team present ⇒ toolkit-for adds exactly one "task" tool, advertising only these names{:name "boss" :does "…" :model "m" :team ["writer" "auditor"]}“Delegation, like recursion, is OPT-IN” — an agent with no :team is offered no delegation tool
at all, no matter how many other agents exist in the runtime’s registry. This is enforced at the
toolkit-construction boundary, not by a runtime-wide flag: two agents in the same runtime can have
completely different delegation rights, because the switch lives on each AgentDef.
The tool’s model-facing description is composed only from the caller’s own :team, sorted by
name, using each teammate’s :does — e.g. "Delegate a subtask to an isolated subagent … — Available agents — auditor: checks numbers; writer: writes prose" — never from the whole
registry. Calling task with an :agent outside the team is a tool error naming the allowed
team, not a silent reach into the registry.
What one task call actually does
Section titled “What one task call actually does”- Fresh transcript, one tool message back. The child runs with no system prompt and no parent
history; the parent’s own transcript gains exactly one
toolmessage containing the child’s final text — never the child’s turn-by-turn conversation. - Usage rolls up. The child’s token spend adds to the parent’s own
:tokensoninspect— a 15-token child turn inside a 30-token parent turn reports 45 on the parent. - The child is closed when it settles, so a later reattach can still answer from its recorded result — “…→CLOSE fused. The child’s final state stays queryable.”
- Reattachment by task key, not a cache. A re-invoked call with the same
{agent, prompt}key finds the existing child by that key on the parent’s own children, rather than spawning a duplicate: settled ⇒ its recorded result,"suspended"⇒ its pending request (or an inline resume when the retry carries the matchingAnswer),"running"⇒waits on it. This is what makes an upward §10 resume cascade safe when a model replays the same tool call off a rewound transcript. - A suspending child presents to its parent as an ordinary suspending tool — §10’s shape
verbatim, one hop at a time, with the handle path already stamped into the request’s
:data. No new pending type exists for “this came from a delegated child.”
agent-tool — the same axiom, the other direction
Section titled “agent-tool — the same axiom, the other direction”task-tool lets a model inside the runtime spawn a teammate; agent-tool is “the axiom’s
other direction: an AgentDef AS a Tool, droppable into any toolkit’s :tools”
(clojure/src/toolnexus/agents/runtime.cljc:1654-1660). It wraps run-agent (one-shot
spawn→wake→wait, closing on settlement, leaving a "pending" result open for a later
resume) as {:name (sanitized def-name) :description (:does d) :input-schema {:prompt}}, and
returns only the agent’s final text plus {:agent :turns :total-tokens}. From the outside, a
caller sees an ordinary tool; behind it is a whole agent with its own soul, its own toolkit view,
and its own budget — the caller cannot tell the difference. There is no separate manifest entry
for agent-tool because it is the same primitive as task-tool, aimed the other way — use
task-tool when a whole runtime’s worth of agents delegate to each other by team; reach for
agent-tool when you just want to drop ONE named agent into some other toolkit as a single
callable tool.
When to use it
Section titled “When to use it”- You want the model to choose which teammate handles a subtask, rather than the host
hard-coding a
spawncall for every possible delegation. - You want an agent usable as a plain tool from outside the runtime’s own toolkit-building —
agent-toolfor a single named agent, wherever aToolis accepted. - You want reattachment for free across a §10 suspend/resume cascade, rather than hand-rolling idempotency around repeated tool calls on a replayed transcript.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — a boss delegating to one teammate
Section titled “1. The smallest useful call — a boss delegating to one teammate”(require '[toolnexus.agents.runtime :as rt] '[koine.json :as json])
(println)
(defn- calls-response [calls] {:choices [{:message {:role "assistant" :content nil :tool_calls (mapv (fn [c] {:id (:id c) :type "function" :function {:name (:name c) :arguments (json/write-str (or (:args c) {}))}}) calls)}}] :usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(defn- text-response [t] {:choices [{:message {:role "assistant" :content t}}] :usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
;; scripted per MODEL: boss (bm) delegates once, then reports done;;; writer (wm) just answers(def script {"bm" [{:calls [{:id "c1" :name "task" :args {:agent "writer" :prompt "the intro"}}]} {:text "shipped"}] "wm" [{:text "intro written"}]})
(defn- mock-http [] (let [counts (atom {})] (fn [_url _headers body] (let [b (json/read-str body) model (:model b) n (get (swap! counts update model (fnil inc 0)) model) spec (get (get script model) (dec n))] {:status 200 :headers {} :body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response (:text spec))))}))))
(def runtime (rt/create-runtime {:registry {"boss" {:name "boss" :does "coordinates" :model "bm" :team ["writer"] :budget {:max-depth 5}} "writer" {:name "writer" :does "writes" :model "wm"}} :llm {:base-url "http://mock.local" :model "default"} :http-client (mock-http)}))
;; the model, inside boss's own turn, calls: task {:agent "writer" :prompt "the intro"};; — the runtime does NOT expose that call to you directly; it happens through the;; client loop when boss's toolkit includes the task tool `toolkit-for` added for it.(let [h (rt/spawn runtime rt/root "boss")] (rt/wake runtime h "write me a post") (let [r (rt/wait runtime h)] (assert (= "shipped" (:text r)))) ; => whatever boss's final turn says (assert (= "closed" (:state (rt/inspect runtime (str h "/writer.1"))))))(println "OK")The child appears as "root/boss.1/writer.1", is "closed" once its result settles, and its
token spend is already rolled into boss’s own totals by the time wait returns.
2. The realistic case — an out-of-team target is refused, not a silent reach
Section titled “2. The realistic case — an out-of-team target is refused, not a silent reach”(require '[toolnexus.agents.runtime :as rt] '[koine.json :as json])
(println)
(defn- calls-response [calls] {:choices [{:message {:role "assistant" :content nil :tool_calls (mapv (fn [c] {:id (:id c) :type "function" :function {:name (:name c) :arguments (json/write-str (or (:args c) {}))}}) calls)}}] :usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(defn- text-response [t] {:choices [{:message {:role "assistant" :content t}}] :usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def script {"bm" [{:calls [{:id "c1" :name "task" :args {:agent "other" :prompt "sneak"}}]} {:text "gave up"}]})
(defn- mock-http [] (let [counts (atom {})] (fn [_url _headers body] (let [b (json/read-str body) model (:model b) n (get (swap! counts update model (fnil inc 0)) model) spec (get (get script model) (dec n))] {:status 200 :headers {} :body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response (:text spec))))}))))
;; boss's :team is ["writer"] only — "other" is registered but not on the team(def runtime (rt/create-runtime {:registry {"boss" {:name "boss" :does "coordinates" :model "bm" :team ["writer"] :budget {:max-depth 5}} "writer" {:name "writer" :does "writes" :model "wm"} "other" {:name "other" :does "?" :model "om"}} :llm {:base-url "http://mock.local" :model "default"} :http-client (mock-http)}))
(let [h (rt/spawn runtime rt/root "boss")] (rt/wake runtime h "go") ; the model's turn calls task {:agent "other" :prompt "sneak"} (let [r (rt/wait runtime h)] (assert (= "gave up" (:text r)))) ;; nothing was spawned for "other" at all — the refusal is a plain tool error ;; the model sees inline, listing the team it actually has, not a crash (assert (= [h] (mapv :id (rt/handles runtime))))) ; => only ["root/boss.1"], no "other" handle(println "OK")3. The full surface — a suspending delegation, and agent-tool used standalone
Section titled “3. The full surface — a suspending delegation, and agent-tool used standalone”(require '[toolnexus.agents.runtime :as rt] '[toolnexus.client :as client] '[toolnexus.tool :as tool] '[koine.json :as json])
(println)
(defn- calls-response [calls] {:choices [{:message {:role "assistant" :content nil :tool_calls (mapv (fn [c] {:id (:id c) :type "function" :function {:name (:name c) :arguments (json/write-str (or (:args c) {}))}}) calls)}}] :usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(defn- text-response [t] {:choices [{:message {:role "assistant" :content t}}] :usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def script {"bm" [{:calls [{:id "c1" :name "task" :args {:agent "worker" :prompt "risky"}}]}] "wm" [{:calls [{:id "w1" :name "approve" :args {}}]}]})
(defn- mock-http [] (let [counts (atom {})] (fn [_url _headers body] (let [b (json/read-str body) model (:model b) n (get (swap! counts update model (fnil inc 0)) model) spec (get (get script model) (dec n))] {:status 200 :headers {} :body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response "done")))}))))
(def approve-tool (tool/tool {:name "approve" :description "needs approval" :execute (fn ([_args] (client/suspend (client/make-request "approval" "Approve the impossible"))) ([_args ctx] (tool/success "approved")))}))
(def runtime (rt/create-runtime {:registry {"boss" {:name "boss" :does "coordinates" :model "bm" :team ["worker"] :budget {:max-depth 5}} "worker" {:name "worker" :does "does the risky part" :model "wm" :tools [approve-tool]}} :llm {:base-url "http://mock.local" :model "default"} :http-client (mock-http)}))
;; a delegated child whose own tool call suspends on a §10 approval request;; presents to boss (and boss's own caller) as an ordinary pending tool result:(let [h (rt/spawn runtime rt/root "boss")] (rt/wake runtime h "go") (let [r (rt/wait runtime h)] (assert (= "pending" (:status r))) ; not an error; both boss and the child are "suspended" ;; the host answers with the matching Answer id, and the cascade resumes both levels (rt/resume runtime (client/make-answer (:id (:pending r)) true))))
;; agent-tool: drop ONE named agent into a toolkit as a plain callable, outside any team(def worker-as-tool (rt/agent-tool runtime "worker"))(def toolkit (tool/toolkit [worker-as-tool]));; a caller of `toolkit` sees a tool named "worker" with {:prompt "…"} — nothing about;; teams, task keys, or reattachment leaks through; it is run-agent under the hood(assert (= ["worker"] (tool/tool-names toolkit)))(println "OK")See also
Section titled “See also”AgentDef—:teamis the only switch that turnstask-toolon for a given agent.create-runtime— The six verbstask-toolfuses (spawn/wake/wait/close) and the runtime it is built against.Handle— What a delegated child looks like viainspectwhile it runs, suspends, or aftertask-toolcloses it.