Skip to content

toolnexus.client/pending-of

Clojure (JVM) + cljgo · package net.clojars.muthuishere/toolnexus · SPEC §10 · clojure/src/toolnexus/client.cljc

(pending-of result) ; result is a ToolResult
;; => the §10 Request iff this result is a suspension, else nil
;; equivalent to (get-in result [:metadata :pending])

pending-of is the reader half of suspend. Hand it a ToolResult and it returns the Request buried in metadata.pending, or nil for an ordinary result. That is the entire function, and it is the only supported way to ask “did this suspend?” — :isError is true on a suspension as well as on a plain failure, so it cannot answer the question.

Note the argument type. pending-of takes a ToolResult, not a RunResult. On a RunResult the parked request is already a top-level key: check (= "pending" (:status r)) and read (:pending r). Use pending-of when you are holding a tool’s own return value — inside a wrapper, in a test, or when calling a tool directly without the client loop.

  • Wrapping a tool — a decorator that logs, retries or vetoes must not treat a suspension as a failure; pending-of is how it tells them apart.
  • Testing a tool in isolation — call :execute yourself and assert on the Request without standing up a client or a fake LLM.
  • Executing tools without the client loop — if you drive your own provider calls, this is how you detect that a result needs a human before you feed it back to the model.

Reading (get-in result [:metadata :pending]) by hand does the same thing, but the accessor is the part of the contract other ports also expose, and it keeps the key path in one place.

Testing a suspending tool without a client

Section titled “Testing a suspending tool without a client”
(require '[toolnexus.tool :as tool]
'[toolnexus.client :as client])
(def result ((:execute book-flight) {:flight "AI-503"}))
(client/pending-of result)
;; => {:id "sus-1754006400000-1" :kind "input" :prompt "Which city?"
;; :data {:schema {:type "object" …}}}
(:isError result) ;; => true — a suspension always reads as "no value produced"
(:output result) ;; => "Input required: Which city?"
;; an ordinary result has no pending
(client/pending-of (tool/success "plain")) ;; => nil

Asserting on the Request directly is the cheapest test of a suspending tool: no server, no model, no loop.

A wrapper that must not mistake a suspension for a failure

Section titled “A wrapper that must not mistake a suspension for a failure”
(defn retried
"Retry a tool once on failure — but never on a suspension, which is not a failure."
[t]
(update t :execute
(fn [f]
(fn
([args]
(let [r (f args)]
(if (and (:isError r) (nil? (client/pending-of r)))
(f args) ; a real error: one more go
r))) ; success, or a parked request: hand it straight back
([args ctx] (f args ctx))))))

Retrying a suspension would ask the human the same question twice and discard the first Request id, which is exactly the bug this accessor exists to prevent.

Driving tools yourself, with no client loop

Section titled “Driving tools yourself, with no client loop”
(require '[toolnexus.core :as tn])
(defn call-tool [toolkit name args]
(let [r (tn/execute toolkit name args)]
(if-let [req (client/pending-of r)]
;; park it: the model gets the placeholder, the human gets the prompt
{:for-model (:output r)
:for-human (select-keys req [:id :kind :prompt :url :data :expiresAt])}
{:for-model (:output r)})))
(call-tool toolkit "list_repos" {})
;; => {:for-model "Login required: https://example.com/oauth/authorize"
;; :for-human {:id "sus-…" :kind "authorization"
;; :prompt "Connect your GitHub account to continue"
;; :url "https://example.com/oauth/authorize"}}

Once the human answers, re-execute the same tool with the same args and the answer in Context — (tn/execute toolkit name args {:answer answer}) — which is precisely what the client loop does internally.

You hold Suspension check The Request
A ToolResult (a tool’s return value) (some? (client/pending-of r)) (client/pending-of r)
A RunResult (from run) (= "pending" (:status r)) (:pending r)
A tool_result event from :on-event not exposed use the earlier pending event’s :request