Skip to content

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.

  • You want a completion gate — retry the model automatically while a :completion check 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 :team matters belongs on toolnexus.agents.runtime instead, which honours all four.

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.

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.

  • agents.runtime/statuses / agents.runtime/limits — The canonical status/limit strings all-todos-done and a :completion verify 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-for and :on-metric where a plain Loop cannot.
  • toolnexus.client/run — The unwrapped §8 client run a gate-free loop degenerates to.