Skip to content

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.

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.

  • 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 tool message 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 :tokens on inspect — 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 matching Answer), "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.

  • You want the model to choose which teammate handles a subtask, rather than the host hard-coding a spawn call for every possible delegation.
  • You want an agent usable as a plain tool from outside the runtime’s own toolkit-building — agent-tool for a single named agent, wherever a Tool is 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.

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")
  • AgentDef — :team is the only switch that turns task-tool on for a given agent.
  • create-runtime — The six verbs task-tool fuses (spawn/wake/wait/close) and the runtime it is built against.
  • Handle — What a delegated child looks like via inspect while it runs, suspends, or after task-tool closes it.