Skip to content

answer-declined

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

(answer-declined id) ; :reason defaults to "declined"
(answer-declined id reason)
;; => {:id id :ok false} ; no reason given
;; => {:id id :ok false :reason "no today"} ; reason given

Wraps a human’s refusal into the Answer a suspended run resumes with, carrying a reason string that defaults to "declined". It is the decline counterpart to answer-output, and both are canonical, shipped in all seven ports (addendum A9): before these constructors existed, a host hand-built the Answer map and a mistyped :ok/:reason key was a silent wrong-outcome bug rather than a construction error.

:reason is carried only on a decline (R1) — answer-output never sets it, because the loop’s resume rule branches on :ok alone and never reads :reason to decide anything; it exists purely so the model (and any log line) can see why the human said no.

  • The human explicitly refuses a request — “no, don’t do that”, “cancel”, a rejected confirmation.
  • A suspension times out unanswered and the host decides to resolve it as a decline rather than leave the run parked forever — pass a reason like "expired" or "no reply within 5m".
  • A :wait-for hook that cannot route the request at all (an unrecognised :kind, a channel that is down) should decline with a reason naming the problem, rather than throw or silently grant — the run keeps going and the model sees why the tool call was blocked.

A declined answer is not a crash: the loop turns it into an error ToolResult fed back to the model ("declined/expired: <prompt>"), and the run continues — the model can explain the situation to the user instead of the whole run dying on a refusal.

1. The smallest useful call — the default reason

Section titled “1. The smallest useful call — the default reason”
(require '[toolnexus.client :as client])
(println)
(def a (client/answer-declined "sus-1"))
;; => {:id "sus-1" :ok false}
(assert (= {:id "sus-1" :ok false} a))
(println "OK")

Note there is no :reason key at all here — make-answer only assigns it when one is given, so a bare decline is not {:reason "declined"} on the wire; it is simply :ok false with no reason.

2. The realistic case — a human explicitly says no, inside :wait-for

Section titled “2. The realistic case — a human explicitly says no, inside :wait-for”
(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 "confirm_delete" :args {}}]}
{:text "OK, I left the branches alone."}]))
(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 confirm-delete-tool
(tool/tool {:name "confirm_delete" :description "confirm branch deletion"
:execute (fn ([_args] (client/suspend (client/make-request "approval" "Delete the stale branches?")))
([_args ctx] (if (get-in ctx [:answer :ok])
(tool/success "deleted")
(tool/failure (str "declined: " (get-in ctx [:answer :reason]))))))}))
(defn ask-human! [_prompt] {:approved? false})
(def llm
(client/create-client
{:base-url "http://mock.local"
:style "openai"
:model "gpt-4o-mini"
:http-client (mock-http)
:wait-for (fn [request]
(let [reply (ask-human! (:prompt request))]
(if (:approved? reply)
(client/answer-output (:id request) (:value reply))
(client/answer-declined (:id request) "not today"))))}))
(def rr
(client/run llm "Should I delete the stale branches?"
{:toolkit (tool/toolkit [confirm-delete-tool])}))
(assert (= "done" (:status rr)))
(assert (= "OK, I left the branches alone." (:text rr)))
(println "OK")

3. The full surface — an unroutable request, declined with a diagnostic reason

Section titled “3. The full surface — an unroutable request, declined with a diagnostic reason”
(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 "mystery" :args {}}]}
{:text "I could not route that request."}]))
(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))))}))))
(defn route-to-oauth! [_req] nil)
(defn route-to-form! [_req] nil)
;; a suspending tool whose :kind is not one the host recognises
(def mystery-tool
(tool/tool {:name "mystery" :description "an unroutable kind"
:execute (fn ([_args] (client/suspend (client/make-request "carrier-pigeon" "deliver a message")))
([_args ctx] (tool/success "n/a")))}))
(def llm
(client/create-client
{:base-url "http://mock.local"
:style "openai"
:model "gpt-4o-mini"
:http-client (mock-http)
:wait-for (fn [request]
(case (:kind request)
"authorization" (route-to-oauth! request)
"input" (route-to-form! request)
;; an unknown kind is declined, not thrown — the run keeps going
(client/answer-declined
(:id request)
(str "unsupported suspension kind: " (:kind request)))))}))
(def rr (client/run llm "do the thing" {:toolkit (tool/toolkit [mystery-tool])}))
(assert (= "done" (:status rr)))
(assert (= "I could not route that request." (:text rr)))
;; a string-keyed Answer a host round-trips through JSON is normalised the same
;; way regardless of which constructor built it
(assert (= false (:ok (client/as-answer {"id" "x" "ok" false "reason" "no"}))))
(println "OK")

A string-keyed Answer a host round-trips through JSON is normalised the same way regardless of which constructor built it — (client/as-answer {"id" "x" "ok" false "reason" "no"}) reads :ok false correctly, which is exactly the bug (#89, ADR 0026 §5) that made a granted answer look declined before normalisation existed.