toolnexus.agents.loop
Clojure (JVM) + cljgo · package net.clojars.muthuishere/toolnexus · SPEC §7D · clojure/src/toolnexus/agents/loop.cljc
(require '[toolnexus.agents.loop :as tnloop])
;; harness is a NAME, not a wrapper — identity function(tnloop/harness agent-def) ; => agent-def, unchanged
;; open a live execution(def lp (tnloop/create agent-def options toolkit));; => {:def agent-def :options options :toolkit toolkit;; :status "idle" :turns 0 :history []}
;; run one request — returns [outcome loop'](let [[outcome lp'] (tnloop/run lp "do the thing")] outcome) ; a RunResult, same shape client/run returns
;; the built-in completion verifier: reads the shipped `todowrite` builtin's;; result metadata, requires every item checked(tnloop/all-todos-done result) ; => {:ok true} | {:ok false :reason "…"}
;; which agent-def fields THIS driver cannot honour, by name (present-only, order-pinned)(tnloop/loop-unsupported agent-def) ; => (subset of) ["tools" "team" "waitFor" "onMetric"]tnloop/loop-unsupported-fields ; => ["tools" "team" "waitFor" "onMetric"] — the canonical order
;; shared gate primitive — a client run wrapped with the completion check(tnloop/run-gated ask prompt completion state)Agent.Loop(...).Run drives the agent under a Guardrail policy that vets every tool call and a
Completion check that decides when the task is done — the gated door beside the plain
Agent.Run, with unsupported spec fields (tools, team, waitFor, onMetric) named explicitly
rather than silently dropped. See the narrative doc, Harness & loop, for the placement
law this namespace encodes — this page is the Clojure API surface for it, not a restatement.
The placement law, briefly: the harness (an agent def — tools, soul, team, budget, model, policy, ceilings) answers “MAY it?”, fixed per problem. Run options answer “with WHAT?” — the model for this call. The loop answers “DID it?” — status, turns, stop reason — and is observed, never configured; a loop takes no options of its own.
When to use it
Section titled “When to use it”- You want a completion gate — retry the model automatically while a
:completioncheck keeps failing, up to:max-attempts, feeding back why it failed on each retry prompt — without hand-writing that retry loop yourself. - You want the built-in
todowrite-based completion check (all-todos-done) rather than writing your own:verify— a plan-using agent is only “done” once every declared todo item is checked off. - You want to know, up front, which parts of an agent def a plain loop cannot honour —
:tools(child delegation),:team,:wait-for,:on-metric— rather than have them silently ignored. An agent def whose:teammatters belongs ontoolnexus.agents.runtimeinstead, which honours all four.
Why this and not the alternative
Section titled “Why this and not the alternative”Guardrail and Verdict/Completion are carried as plain data here — guarded-hooks compiles
a vector of guardrail functions into one :before-tool hook (first-deny-wins), and a verdict is
just {:ok bool :reason str}, not a defrecord or a named protocol. That is deliberate, idiomatic
Clojure: a guardrail is “a function from an event to a string or nil,” which needs no type beyond
what fn? already checks, and a verdict is “two keys,” which needs no type beyond what a map
already is. Other ports name these as an interface (Guardrail in js/python/java/csharp) or a
struct (Verdict in js/java/csharp/elixir) because their type systems make that the idiomatic
spelling of the same contract — this is a language-shape difference, not a missing feature.
Examples
Section titled “Examples”1. The smallest useful call — a loop with no gate at all
Section titled “1. The smallest useful call — a loop with no gate at all”(require '[toolnexus.agents.loop :as tnloop] '[toolnexus.tool :as tool] '[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 agent-def (tnloop/harness {:soul "You are concise." :model "gpt-4.1"}))(def opts {:base-url "http://mock.local" :http-client (fn [_url _headers _body] {:status 200 :headers {} :body (json/write-str (text-response "Node offline: rebooted the switch."))})})(def lp (tnloop/create agent-def opts (tool/toolkit [])))
(let [[outcome lp'] (tnloop/run lp "Summarise the incident in one sentence")] (assert (= "done" (:status outcome))) (assert (= "Node offline: rebooted the switch." (:text outcome))))(println "OK")No :completion, no :guardrails — this is byte-identical to calling client/run directly, which
is the point: absent gating costs nothing.
2. The realistic case — gate on the built-in todo-completion check
Section titled “2. The realistic case — gate on the built-in todo-completion check”(require '[toolnexus.agents.loop :as tnloop] '[toolnexus.tool :as tool] '[toolnexus.builtin :as builtin] '[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}})
;; turn 1: the model claims done with an OPEN todo — the gate re-prompts;;; turn 2 (after the re-prompt): it checks the item off, and the gate passes(def script (atom [{:calls [{:id "t1" :name "todowrite" :args {:todos [{:text "migrate schema" :completed false}]}}]} {:text "done, schema migrated"} {:calls [{:id "t2" :name "todowrite" :args {:todos [{:text "migrate schema" :completed true}]}}]} {:text "done, all checked off"}]))
(defn- mock-http [] (let [n (atom 0)] (fn [_url _headers _body] (let [i (swap! n inc) spec (get @script (dec i))] {:status 200 :headers {} :body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response (:text spec))))}))))
(def todowrite-tool (first (filter #(= "todowrite" (:name %)) builtin/builtin-tools)))
(def agent-def (tnloop/harness {:soul "You plan your work with todowrite before finishing." :model "gpt-4.1" :completion {:verify tnloop/all-todos-done :max-attempts 3}}))
(def opts {:base-url "http://mock.local" :http-client (mock-http)})(def lp (tnloop/create agent-def opts (tool/toolkit [todowrite-tool])))
(let [[outcome _] (tnloop/run lp "Migrate the config schema")] (case (:status outcome) "done" (println "finished, all todos checked off:" (:text outcome)) "incomplete" (println "gate failed" (:limit outcome) "-" (:text outcome)) (println (:status outcome))) (assert (= "done" (:status outcome))) (assert (= "done, all checked off" (:text outcome))))(println "OK")If the model claims done with open todo items, the gate re-prompts with
"Your work did not verify: <n> item(s) still open: <...>. Fix it and finish." up to
:max-attempts times before giving up with :status "incomplete" :limit "completion".
3. The full surface — a guardrail policy plus checking what the loop cannot honour
Section titled “3. The full surface — a guardrail policy plus checking what the loop cannot honour”(require '[toolnexus.agents.loop :as tnloop] '[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 (atom [{:calls [{:id "d1" :name "delete_file" :args {:path "/tmp/x"}}]} {:text "could not delete, denied"}]))
(defn- mock-http [] (let [n (atom 0)] (fn [_url _headers _body] (let [i (swap! n inc) spec (get @script (dec i))] {:status 200 :headers {} :body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response (:text spec))))}))))
(def delete-file-tool (tool/tool {:name "delete_file" :description "deletes a file" :execute (fn [_args] (tool/success "deleted"))}))
(def child-agent-def {:name "child" :does "helps" :model "m"})
(def agent-def (tnloop/harness {:soul "You may read files but never delete them without asking." :model "gpt-4.1" :guardrails [(fn [ev] (when (= "delete_file" (:tool ev)) "deletion requires human approval"))] :completion {:verify tnloop/all-todos-done :max-attempts 2} ;; these two are NOT honoured by a Loop — see loop-unsupported below :tools [child-agent-def] :team ["writer" "auditor"]}))
;; find out, before running, which fields this driver will ignore(def unsupported (tnloop/loop-unsupported agent-def));; => ["tools" "team"] -- present on the def, but not honoured by a plain Loop(assert (= ["tools" "team"] unsupported))
(def opts {:base-url "http://mock.local" :http-client (mock-http)})(def lp (tnloop/create agent-def opts (tool/toolkit [delete-file-tool])))(let [[outcome _] (tnloop/run lp "Clean up the temp directory")] (assert (= "could not delete, denied" (:text outcome))));; a guardrail denial never reaches the model as a tool result silently allowed —;; it comes back as "denied: deletion requires human approval"(println "OK")loop-unsupported returns only the fields actually present on the def, in the canonical order
pinned by loop-unsupported-fields — an agent def with no :team and no :on-metric reports
neither, so the check is additive: it never claims a problem the def does not actually have.
See also
Section titled “See also”agents.runtime/statuses / agents.runtime/limits— The canonical status/limit stringsall-todos-doneand a:completionverify function read off a run’s result.- Harness & loop — The narrative doc: the placement law (MAY it? / with WHAT? / DID it? / is it RIGHT?) this namespace’s split between agent def, run options, and loop encodes.
toolnexus.agents.runtime— The full runtime this loop sits beside: honours:tools,:team,:wait-forand:on-metricwhere a plainLoopcannot.toolnexus.client/run— The unwrapped §8 client run a gate-free loop degenerates to.