Skip to content

answer-output

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

(answer-output id output)
(answer-output id output is-error)
;; => {:id id :ok true :data {:output "…"}}
;; => {:id id :ok true :data {:output "…" :isError true}} ; is-error true
;; a non-string output THROWS — it never silently degrades to ""
(answer-output "sus-1" {:city "Chennai"})
;; => ExceptionInfo "toolnexus: Answer output must be a string"

Wraps a human’s typed string reply into the Answer a suspended run resumes with — the success counterpart to answer-declined. It exists so a host building an Answer by hand never has a key to get wrong (ADR 0026): before this constructor shipped, the recognised payload shape had to be reconstructed at every call site, and a mistyped key there is silent — the loop would treat a malformed answer as an unrecognised one rather than failing loudly.

output must be a string. A non-string argument throws immediately rather than being coerced or blanked to "": degrading a wrong answer to an empty string is exactly how a bad payload used to reach the model quietly, with no error and no log — the class of defect ADR 0026 exists to close.

is-error (default false) sets :isError true in the payload, the same flag a ToolResult uses to mark its own output as a failure — a host resuming a tool that itself failed while the human was answering a different question can carry that failure through in the same shape.

  • Resuming a "input"-kind suspension where the human typed or picked something the tool needs — a city, a confirmation code, a chosen account — and you have it as a string in hand.
  • Inside a :wait-for hook, to build the return value once you have resolved a human’s reply (from a chat message, a form submission, a durable queue) into a plain string.
  • Not for an "authorization" suspension where the answer carries no meaningful payload — see auth-required, whose retry arity typically checks (:ok answer) alone and never reads :data.

Hand-building {:id id :ok true :data {:output s}} yourself works too — answer-output is not a new mechanism, just the one call site that cannot typo a key or forget the string check. Prefer it over hand-rolling the map for exactly the reason ADR 0026 exists.

1. The smallest useful call — answer a plain question

Section titled “1. The smallest useful call — answer a plain question”
(require '[toolnexus.client :as client])
(println)
(def a (client/answer-output "sus-1" "Chennai"))
;; => {:id "sus-1" :ok true :data {:output "Chennai"}}
(assert (= {:id "sus-1" :ok true :data {:output "Chennai"}} a))
(println "OK")

2. The realistic case — inside a :wait-for hook, resolving a queued human reply

Section titled “2. The realistic case — inside a :wait-for hook, resolving a queued human reply”
(require '[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 (atom [{:calls [{:id "c1" :name "ask_city" :args {}}]}
{:text "The weather in Chennai is sunny."}]))
(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))))}))))
;; a suspending tool: no answer yet -> park; ctx carries the answer on retry
(def ask-city-tool
(tool/tool {:name "ask_city" :description "which city"
:execute (fn ([_args] (client/suspend (client/make-request "input" "Which city?")))
([_args ctx] (tool/success (str (get-in ctx [:answer :data :output])))))}))
(defn block-until-chat-reply! [_id] "Chennai")
(def llm
(client/create-client
{:base-url "http://mock.local"
:style "openai"
:model "gpt-4o-mini"
:http-client (mock-http)
:wait-for (fn [request]
;; blocks until a human answers via chat, then wraps the reply
(let [reply (block-until-chat-reply! (:id request))]
(client/answer-output (:id request) reply)))}))
(def rr
(client/run llm "Which city should I check the weather for?"
{:toolkit (tool/toolkit [ask-city-tool])}))
(assert (= "done" (:status rr)))
(assert (= "The weather in Chennai is sunny." (:text rr)))
(println "OK")

3. The full surface — marking the resumed answer itself as an error result

Section titled “3. The full surface — marking the resumed answer itself as an error result”
(require '[toolnexus.client :as client])
(println)
;; the tool being resumed will see :isError true on the answer's :data,
;; so its retry arity can distinguish "granted, but the value failed validation"
;; from "granted, and the value is good"
(def a (client/answer-output "sus-2" "not-a-real-city" true))
;; => {:id "sus-2" :ok true :data {:output "not-a-real-city" :isError true}}
(assert (= {:id "sus-2" :ok true :data {:output "not-a-real-city" :isError true}} a))
;; a non-string output is refused rather than silently coerced
(def refused
(try
(client/answer-output "sus-3" 42)
(catch Throwable e
(ex-message e))))
;; => "toolnexus: Answer output must be a string"
(assert (= "toolnexus: Answer output must be a string" refused))
(println "OK")