(add-usage acc client raw)Sum token usage across turns. OpenAI prompt/completion/total_tokens; Anthropic input_tokens -> prompt, output_tokens -> completion. Public because §11 translation reports the same Usage from the same provider payloads.
Sum token usage across turns. OpenAI prompt/completion/total_tokens; Anthropic input_tokens -> prompt, output_tokens -> completion. Public because §11 translation reports the same Usage from the same provider payloads.
(after-llm! client response turn)(after-llm! client response turn model)§8 afterLLM — observe only (logging, cost, tracing). :response is the
provider's decoded payload, so it carries usage. Returns nil; a return value
from an observer would be a silent contract nobody could rely on.
§8 `afterLLM` — observe only (logging, cost, tracing). `:response` is the provider's decoded payload, so it carries `usage`. Returns nil; a return value from an observer would be a silent contract nobody could rely on.
(answer-declined id)(answer-declined id reason)§10 Answer for a request the host will NOT grant (addendum A9, shipped in
all seven ports beside answer-output). R1: reason is carried only here,
because the loop rule branches on ok alone.
§10 `Answer` for a request the host will NOT grant (addendum A9, shipped in all seven ports beside `answer-output`). R1: `reason` is carried only here, because the loop rule branches on `ok` alone.
(answer-output id output)(answer-output id output is-error)§10 Answer carrying ONE tool output for a durable resume (ADR 0026: the
constructor exists so the map stops being hand-built and there is no key left
to get wrong). Recognised payload keys are results then output, in that
precedence (addendum A3); this builds the output shape.
A non-string output THROWS. Degrading it to "" is how a wrong answer
reaches the model quietly, which is the whole defect this constructor closes.
§10 `Answer` carrying ONE tool output for a durable resume (ADR 0026: the constructor exists so the map stops being hand-built and there is no key left to get wrong). Recognised payload keys are `results` then `output`, in that precedence (addendum A3); this builds the `output` shape. A non-string `output` THROWS. Degrading it to "" is how a wrong answer reaches the model quietly, which is the whole defect this constructor closes.
(as-answer answer)§10 Answer, normalised so a STRING-KEYED map means what it says.
§10 pins Answer's keys because they cross the wire, and that is precisely
why a host round-tripping an Answer through JSON — a database column, a
webhook body, a queue — hands this port {"id" … "ok" true}. Before this,
(:ok answer) read nil from such a map and the run treated a GRANTED answer
as DECLINED: no error, no log, the opposite outcome (issue #89, ADR 0026 §5).
It is the worst failure mode in that batch precisely because it is silent.
Only the TOP-LEVEL keys are normalised. :data is the host's own payload and
is handed to the tool untouched.
§10 `Answer`, normalised so a STRING-KEYED map means what it says.
§10 pins `Answer`'s keys because they cross the wire, and that is precisely
why a host round-tripping an Answer through JSON — a database column, a
webhook body, a queue — hands this port `{"id" … "ok" true}`. Before this,
`(:ok answer)` read nil from such a map and the run treated a GRANTED answer
as DECLINED: no error, no log, the opposite outcome (issue #89, ADR 0026 §5).
It is the worst failure mode in that batch precisely because it is silent.
Only the TOP-LEVEL keys are normalised. `:data` is the host's own payload and
is handed to the tool untouched.(ask client prompt {:keys [toolkit id on-event] :as ctx})run, remembering the conversation (§conversation-store) — the id-keyed
sugar every other port ships as ask.
(ask client "and the second one?" {:toolkit tk :id "muthu"})
With an :id, the store's history for that id is loaded before the call and
the full transcript saved after it, so the next ask with the same id
continues the conversation. WITHOUT an id it is a stateless one-shot,
identical to run — the same rule as js/src/client.ts, where ask with no
id delegates straight to run.
This is deliberately nothing more than run with :conversation-id filled
in: the memory mechanics live in ONE place, and a second copy of the
load-then-save rule here is exactly the drift the ports exist to prevent.
`run`, remembering the conversation (§conversation-store) — the id-keyed
sugar every other port ships as `ask`.
(ask client "and the second one?" {:toolkit tk :id "muthu"})
With an `:id`, the store's history for that id is loaded before the call and
the full transcript saved after it, so the next `ask` with the same id
continues the conversation. WITHOUT an id it is a stateless one-shot,
identical to `run` — the same rule as js/src/client.ts, where `ask` with no
id delegates straight to `run`.
This is deliberately nothing more than `run` with `:conversation-id` filled
in: the memory mechanics live in ONE place, and a second copy of the
load-then-save rule here is exactly the drift the ports exist to prevent.(auth-required url)(auth-required url prompt)§10 sugar: a kind:"authorization" suspension at url.
§10 sugar: a `kind:"authorization"` suspension at `url`.
(before-llm! client messages tools turn)§8 beforeLLM, applied to ONE round trip. Returns [messages tools model] —
the hook's replacements when it returned them, the originals otherwise.
model is the hook's non-empty :model for THIS turn only (change
add-judge-batteries), else the configured model verbatim.
Public because §11 toolnexus.translate must fire this hook exactly once for
its single call, and a second copy of the rule in that namespace is exactly
the drift the ports exist to prevent.
Ordering (SPEC.md §8 Gap 1, and it IS the spec):
base body -> beforeLLM hook -> :request-params merge -> :body-transform -> marshal -> wire
so this runs BEFORE the body is assembled, and its tools replacement feeds
the same empty-list omission (Gap 5) the toolkit's own list does.
§8 `beforeLLM`, applied to ONE round trip. Returns `[messages tools model]` —
the hook's replacements when it returned them, the originals otherwise.
`model` is the hook's non-empty `:model` for THIS turn only (change
add-judge-batteries), else the configured model verbatim.
Public because §11 `toolnexus.translate` must fire this hook exactly once for
its single call, and a second copy of the rule in that namespace is exactly
the drift the ports exist to prevent.
Ordering (SPEC.md §8 Gap 1, and it IS the spec):
base body -> beforeLLM hook -> :request-params merge -> :body-transform
-> marshal -> wire
so this runs BEFORE the body is assembled, and its `tools` replacement feeds
the same empty-list omission (Gap 5) the toolkit's own list does.(call-provider client body)ONE provider round trip for a CALLER-BUILT body map, through exactly the
endpoint, headers, §resilience-policy retry/backoff and llm metric the agent
loop uses. §8's :request-params merge and :body-transform apply unchanged.
This is the seam §11 toolnexus.translate sits on: single-turn translation
must lose neither resilience nor metrics, and the only way to guarantee that
is to share the code rather than copy it.
ONE provider round trip for a CALLER-BUILT body map, through exactly the endpoint, headers, §resilience-policy retry/backoff and `llm` metric the agent loop uses. §8's `:request-params` merge and `:body-transform` apply unchanged. This is the seam §11 `toolnexus.translate` sits on: single-turn translation must lose neither resilience nor metrics, and the only way to guarantee that is to share the code rather than copy it.
(call-provider* client body)call-provider, returning [decoded-response transmitted-model] — the model
the shaped body actually carried (§8/§11: the model reported is the model sent).
`call-provider`, returning `[decoded-response transmitted-model]` — the model the shaped body actually carried (§8/§11: the model reported is the model sent).
(create-client opts)Build a client. Options (idiomatic kebab-case — these never hit the wire):
:base-url required, e.g. "https://api.anthropic.com"
:style "openai" | "anthropic" (default "openai")
:model required
:api-key optional; falls back to OPENAI_API_KEY / ANTHROPIC_API_KEY
/ OPENROUTER_API_KEY
:headers extra request headers
:system-prompt prepended to the toolkit's skills prompt
:max-turns default 10
:retries transient-failure budget (default 2); retries on
429/500/502/503/504/529 + network. Widen the
status set with :retryable-statuses.
:retry-base-ms base of the exponential backoff in ms (default 500, as in
the other six ports). The wait is
base * 2^attempt + jitter[0,100)ms, and a usable
Retry-After still wins over it.
:retryable-statuses
extra HTTP statuses to treat as retryable, ADDED to the
default set (429/500/502/503/504/529). It can
only widen: a host cannot remove 429 and lose
Retry-After handling with it. This sets the DEFAULT
classification; :on-error still runs per attempt and has
the final say, so :on-error returning :fail overrides a
status listed here. Example: a Cloudflare-fronted origin
that answers 520–527.
:hooks §8 lifecycle middleware — a map of any of
{:before-llm :after-llm :before-tool :after-tool}; see
before-llm! / execute-tool. Absent => nothing changes.
:wait-for (fn [request] answer) — the ONE host slot of §10.
:max-part-bytes §1B — reject a content part carrying more than this many
DECODED bytes (never the +33% base64 string). Unset => no
limit, exactly as before.
:on-unsupported-part
§8A — "error" | "text", overriding the provenance rule
uniformly. Unset => an ATTACHED part the style cannot
represent stops the run, a TOOL-DERIVED one degrades to a
text placeholder and warns once.
On the name of that slot: §10 says "never await" because await is
reserved in JS/Python/C#. In Clojure it is not reserved — it is
clojure.core/await, and it exists on BOTH hosts, so (defn await ...)
would shadow a core name and (per the spike brief) can make cljgo's static
interop scan reject the WHOLE namespace. Same answer, sharper teeth.
Build a client. Options (idiomatic kebab-case — these never hit the wire):
:base-url required, e.g. "https://api.anthropic.com"
:style "openai" | "anthropic" (default "openai")
:model required
:api-key optional; falls back to OPENAI_API_KEY / ANTHROPIC_API_KEY
/ OPENROUTER_API_KEY
:headers extra request headers
:system-prompt prepended to the toolkit's skills prompt
:max-turns default 10
:retries transient-failure budget (default 2); retries on
`429`/`500`/`502`/`503`/`504`/`529` + network. Widen the
status set with `:retryable-statuses`.
:retry-base-ms base of the exponential backoff in ms (default 500, as in
the other six ports). The wait is
`base * 2^attempt + jitter[0,100)ms`, and a usable
`Retry-After` still wins over it.
:retryable-statuses
extra HTTP statuses to treat as retryable, ADDED to the
default set (`429`/`500`/`502`/`503`/`504`/`529`). It can
only widen: a host cannot remove `429` and lose
`Retry-After` handling with it. This sets the DEFAULT
classification; `:on-error` still runs per attempt and has
the final say, so `:on-error` returning `:fail` overrides a
status listed here. Example: a Cloudflare-fronted origin
that answers `520`–`527`.
:hooks §8 lifecycle middleware — a map of any of
{:before-llm :after-llm :before-tool :after-tool}; see
`before-llm!` / `execute-tool`. Absent => nothing changes.
:wait-for (fn [request] answer) — the ONE host slot of §10.
:max-part-bytes §1B — reject a content part carrying more than this many
DECODED bytes (never the +33% base64 string). Unset => no
limit, exactly as before.
:on-unsupported-part
§8A — "error" | "text", overriding the provenance rule
uniformly. Unset => an ATTACHED part the style cannot
represent stops the run, a TOOL-DERIVED one degrades to a
text placeholder and warns once.
On the name of that slot: §10 says "never `await`" because `await` is
reserved in JS/Python/C#. In Clojure it is not reserved — it is
`clojure.core/await`, and it exists on BOTH hosts, so `(defn await ...)`
would shadow a core name and (per the spike brief) can make cljgo's static
interop scan reject the WHOLE namespace. Same answer, sharper teeth.(create-in-process-client {:keys [model generate] :as opts})A client backed by a model running IN THIS PROCESS — no server, no socket, and no HTTP shapes to construct.
This is a second constructor, not a second seam: it builds on the same
:http-client transport, so the tool-calling loop, MCP servers, skills,
sub-agents, hooks, metrics and the completion gate behave identically.
(create-in-process-client {:model "my-local" :generate (fn [req] ;; req = {:messages [...] :tools [...] :model "my-local" :body {...}} {:content "hello"})}) ;; or {:tool-calls [{:name "add" :arguments {:a 2 :b 3}}]}
:generate returns ONE assistant message; :usage is optional. There is no
:base-url, :api-key or :style — there is no wire to configure.
NOTE this port has no streaming entry point (:on-event is a sink on the
non-streaming loop), so unlike the other six there is nothing here to refuse.
A client backed by a model running IN THIS PROCESS — no server, no socket, and no
HTTP shapes to construct.
This is a second constructor, not a second seam: it builds on the same
`:http-client` transport, so the tool-calling loop, MCP servers, skills,
sub-agents, hooks, metrics and the completion gate behave identically.
(create-in-process-client
{:model "my-local"
:generate (fn [req]
;; req = {:messages [...] :tools [...] :model "my-local" :body {...}}
{:content "hello"})})
;; or {:tool-calls [{:name "add" :arguments {:a 2 :b 3}}]}
`:generate` returns ONE assistant message; `:usage` is optional. There is no
`:base-url`, `:api-key` or `:style` — there is no wire to configure.
NOTE this port has no streaming entry point (`:on-event` is a sink on the
non-streaming loop), so unlike the other six there is nothing here to refuse.(in-memory-store)§conversation-store — the shipped default: per-client, process lifetime.
A store is exactly two operations, :get and :save, so a host can swap in
a file or a database without this namespace knowing anything about it.
§conversation-store — the shipped default: per-client, process lifetime. A store is exactly two operations, `:get` and `:save`, so a host can swap in a file or a database without this namespace knowing anything about it.
(in-process-http-client generate)Turns a semantic generate into the shipped :http-client seam: the host returns
ONE assistant message and this builds the provider envelope.
Public (ADR 0030) so a caller OTHER than create-in-process-client — the
agent runtime's :in-process option, in particular — can build the exact
same :http-client from a generate function with zero duplicated logic.
create-in-process-client itself is unchanged: it still calls this.
Turns a semantic `generate` into the shipped `:http-client` seam: the host returns ONE assistant message and this builds the provider envelope. Public (ADR 0030) so a caller OTHER than `create-in-process-client` — the agent runtime's `:in-process` option, in particular — can build the exact same `:http-client` from a `generate` function with zero duplicated logic. `create-in-process-client` itself is unchanged: it still calls this.
The §8 RunResult.limit vocabulary — which limit stopped an incomplete
run. Identical strings in every port.
maxTurns the turn budget was exhausted
contentPart a content part could not be sent (§8A)
timeout the whole-run :timeout-ms deadline expired
The §8 `RunResult.limit` vocabulary — which limit stopped an `incomplete` run. Identical strings in every port. maxTurns the turn budget was exhausted contentPart a content part could not be sent (§8A) timeout the whole-run `:timeout-ms` deadline expired
(make-answer id ok)(make-answer id ok data)(make-answer id ok data reason)§10 Answer. R1: reason is populated ONLY when ok == false; the loop rule
branches on ok alone and never reads it.
§10 `Answer`. R1: `reason` is populated ONLY when ok == false; the loop rule branches on `ok` alone and never reads it.
(make-request kind prompt)(make-request kind prompt opts)§10 Request. opts may carry :url, :data (incl. R2's data.schema) and
:expiresAt (RFC3339).
§10 `Request`. `opts` may carry :url, :data (incl. R2's `data.schema`) and :expiresAt (RFC3339).
(new-request-id)A unique correlation key for one suspension.
A unique correlation key for one suspension.
(pending-of result)The Request iff this ToolResult is a suspension, else nil.
The `Request` iff this ToolResult is a suspension, else nil.
(provider-error status body retry-after)The typed §8 provider failure: an ex-info whose DATA carries :status,
:body (fully redacted) and :retry-after (the RAW Retry-After header
verbatim, absent when the response sent none), and whose MESSAGE carries
the capped, redacted body.
:retry-after is deliberately NOT pre-parsed: an HTTP-date, fractional or
out-of-range value is information the response really supplied, and a numeric
field would have to drop it. The library's waiting rule (retry-after-ms,
delay-seconds only, falling back to backoff) is separate and unchanged.
Before this, a host could only regex the message — and the message was the raw body, account identifiers and all (#91/#92, ADR 0027).
The typed §8 provider failure: an ex-info whose DATA carries `:status`, `:body` (fully redacted) and `:retry-after` (the RAW `Retry-After` header verbatim, absent when the response sent none), and whose MESSAGE carries the capped, redacted body. `:retry-after` is deliberately NOT pre-parsed: an HTTP-date, fractional or out-of-range value is information the response really supplied, and a numeric field would have to drop it. The library's waiting rule (`retry-after-ms`, delay-seconds only, falling back to backoff) is separate and unchanged. Before this, a host could only regex the message — and the message was the raw body, account identifiers and all (#91/#92, ADR 0027).
(run client prompt {:keys [toolkit history on-event conversation-id]})Run the agent loop. ctx = {:toolkit tk :history [...] :on-event f}.
Returns a RunResult: {:text :messages :tool-calls :tool-call-count :turns :usage {:prompt-tokens :completion-tokens :total-tokens} :model :status ("done"|"pending"|"incomplete") :limit? :pending?}
:on-event is an optional synchronous sink for the §8 event vocabulary this
non-streaming loop can honestly produce — tool_call, tool_result,
pending (emitted BEFORE wait-for runs, so a channel handler can push the
link in real time), usage, done. There are no text deltas here: this
loop buffers the whole response, and faking deltas out of a buffered body
would be a lie. Real SSE streaming is a separate seam (koine.stream/sse-post
exists and is proven on both hosts) and is NOT implemented in this namespace.
Run the agent loop. `ctx` = {:toolkit tk :history [...] :on-event f}.
Returns a RunResult:
{:text :messages :tool-calls :tool-call-count :turns
:usage {:prompt-tokens :completion-tokens :total-tokens}
:model :status ("done"|"pending"|"incomplete") :limit? :pending?}
`:on-event` is an optional synchronous sink for the §8 event vocabulary this
non-streaming loop can honestly produce — `tool_call`, `tool_result`,
`pending` (emitted BEFORE `wait-for` runs, so a channel handler can push the
link in real time), `usage`, `done`. There are no `text` deltas here: this
loop buffers the whole response, and faking deltas out of a buffered body
would be a lie. Real SSE streaming is a separate seam (`koine.stream/sse-post`
exists and is proven on both hosts) and is NOT implemented in this namespace.The §8 RunResult status vocabulary — THREE values, and the ONLY three a
run can return:
done the model produced a final answer
pending a §10 durable suspension; the Request is on :pending
incomplete a limit stopped the run, and :limit NAMES which
IT IS NOT THE SAME VOCABULARY as toolnexus.agents.runtime/statuses, which
has SEVEN values for a TaskResult and includes "timeout". Two different
sets on two fields both spelled status is the collision behind #92.1, and
the fix is to name both rather than rename either (D5): a host switching on a
status must know WHICH vocabulary it holds. A §8 run never returns
"timeout" — a whole-run deadline throws timeout-error.
RESIDUAL GAP, stated rather than implied (addendum A7): these constants pin
the VALUES; nothing pins the invariant that a third vocabulary can never land
on a third field called status. Tracked as a follow-up conformance row.
The §8 `RunResult` status vocabulary — THREE values, and the ONLY three a `run` can return: done the model produced a final answer pending a §10 durable suspension; the Request is on `:pending` incomplete a limit stopped the run, and `:limit` NAMES which IT IS NOT THE SAME VOCABULARY as `toolnexus.agents.runtime/statuses`, which has SEVEN values for a TaskResult and includes `"timeout"`. Two different sets on two fields both spelled `status` is the collision behind #92.1, and the fix is to name both rather than rename either (D5): a host switching on a status must know WHICH vocabulary it holds. A §8 run never returns `"timeout"` — a whole-run deadline throws `timeout-error`. RESIDUAL GAP, stated rather than implied (addendum A7): these constants pin the VALUES; nothing pins the invariant that a third vocabulary can never land on a third field called `status`. Tracked as a follow-up conformance row.
(suspend request)(suspend request output)A ToolResult whose metadata.pending is a Request IS a suspension
(§0.12). execute's signature is untouched — suspension is data on the
existing result, not a new return type.
A `ToolResult` whose `metadata.pending` is a `Request` IS a suspension (§0.12). `execute`'s signature is untouched — suspension is data on the existing result, not a new return type.
(timeout-error timeout-ms elapsed-ms)The typed whole-run deadline failure. :timeout-ms is the WHOLE-RUN deadline
§8 specifies, and the message NAMES the budget that stopped the run.
The typed whole-run deadline failure. `:timeout-ms` is the WHOLE-RUN deadline §8 specifies, and the message NAMES the budget that stopped the run.
(user-message prompt)§1B — the ONE place a caller's prompt becomes a message in this port.
run is fixed 3-arity (ctx is required), so a new arity is impossible and
the dispatch is on the VALUE: a string is the pre-0.17 path and is stored
verbatim, byte-identical; a sequence is a ContentPart list and is stored as the
canonical transcript shape, which content/build-wire encodes on the way out.
§1B — the ONE place a caller's prompt becomes a message in this port. `run` is fixed 3-arity (`ctx` is required), so a new arity is impossible and the dispatch is on the VALUE: a string is the pre-0.17 path and is stored verbatim, byte-identical; a sequence is a ContentPart list and is stored as the canonical transcript shape, which `content/build-wire` encodes on the way out.
(wire-opts client)The §1B/§8A shaping options every wire build shares.
The §1B/§8A shaping options every wire build shares.
The empty §8 Usage. Idiomatic kebab-case — Usage is a RETURN value, it never crosses the wire.
The empty §8 Usage. Idiomatic kebab-case — Usage is a RETURN value, it never crosses the wire.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |