Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/RFCS/RFC-005-provider-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ is the question a cell, a gate or an adapter asks.
|---|---|
| `:prefill` | a trailing assistant message is continued (DeepSeek /beta, llama.cpp --jinja) |
| `:native-tool-choice` | tools + `tool_choice {type function}` |
| `:native-tools` | the whole tool surface as `tools` on every turn not forced by a prefill or grammar |
| `:grammar` | a GBNF `grammar` applied at sampling (llama.cpp) |
| `:cache-prompt` | `cache_prompt` / `id_slot` prefix reuse (llama.cpp) |
| `:reasoning-budget` | `reasoning_budget_tokens` per call (llama.cpp) |
Expand Down
9 changes: 9 additions & 0 deletions resources/gates.edn
Original file line number Diff line number Diff line change
Expand Up @@ -4054,6 +4054,15 @@
:prediction "the branch commits a change, runs a test, or ships"
:window 3}]

:native-tool-description-chars
{:value 240 :kind :threshold :capability-tunable? true
:provenance ["karamazov-jl1f"]
:doc "How many characters of each tool's description go in the native
`tools` array (samizdat.llm.toolspec), cut at a sentence end. The
array rides in every request and the full prose is already in the
system prompt, so a sentence or two is what it is for; eval's
paragraph is ~1500 characters. Absent, descriptions go whole."}

:forceable-tools
;; Schemas for the tools a gate may FORCE via native tool_choice (tier 3b:
;; data). Only the terminal tools — forcing a mid-task tool would be the
Expand Down
2 changes: 1 addition & 1 deletion resources/manual.edn
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@
{:name samizdat.llm.grammar/fence-grammar
:summary "The GBNF under which a llama.cpp reply's tool calls are well-formed fences naming only the listed tools — required (the force) or optional (the restriction). Cache-safe: applied at sampling, never in the prompt. gates.edn :local-grammar says when it is sent."}
{:name samizdat.config/supports?
:summary "Whether this run's endpoint has a feature (:prefill :grammar :cache-prompt :reasoning-budget :thinking-toggle :native-tool-choice :reasoning-effort) — declared per provider preset, added to by the startup probe, editable in a config file's :llm :features. The one question to ask before choosing a mechanism; the adapter emits only what the features allow."}
:summary "Whether this run's endpoint has a feature (:prefill :grammar :cache-prompt :reasoning-budget :thinking-toggle :native-tool-choice :native-tools :reasoning-effort) — declared per provider preset, added to by the startup probe, editable in a config file's :llm :features. The one question to ask before choosing a mechanism; the adapter emits only what the features allow."}
{:name samizdat.config/features-of
:summary "The features a provider preset declares, before discovery."}
{:name samizdat.agent.infer/force-mechanism
Expand Down
28 changes: 20 additions & 8 deletions resources/wordlists.edn
Original file line number Diff line number Diff line change
Expand Up @@ -165,19 +165,31 @@
;; body with a message that names nothing in the English list. The set is
;; the vendor's own — ZCode (Zhipu's client, karamazov-luqc.3) keeps it as
;; the codes a workflow stops on to wait for quota rather than retry: 1005
;; 1308 1310 1313 1316-1321 2056 20097 — and its OpenAI/Anthropic-family
;; strings for the same wall (credit_balance_exhausted, the spend_limit and
;; usage_limit families). What that table marks RETRYABLE is deliberately
;; NOT here: 1302/1303/1305 are per-minute limits and 3008-3010 are
;; concurrency caps, both of which clear on their own and are the retry
;; ladder's and the rate-limit latch's case. Unmeasured on this side; the
;; codes are bounded on both ends so a token count cannot match them.
;; 1304 1308 1310 1313 1316-1321 2056 20097 3008-3010 — and its
;; OpenAI/Anthropic-family strings for the same wall (credit_balance_exhausted,
;; the spend_limit and usage_limit families). 3008-3010 and 1304 were once
;; read as retryable concurrency caps; the table, read directly on
;; 2026-09-24, marks all four retryable false (karamazov-jvdu). What it
;; marks RETRYABLE is :retryable-business-codes below. The codes are bounded
;; on both ends so a token count cannot match them.
;;
;; Here rather than in the adapter because it is a fact about PROVIDERS, and
;; providers change their error text without asking. A user hitting a new
;; gateway's wording should be able to add it and keep working, not wait for
;; a release. Kept as a string because EDN has no regex literal.
"(?i)insufficient|quota|billing|exceeded your current|payment|per-day|daily limit|credit_balance_exhausted|spend_limit_exceeded|usage_limit_exceeded|使用上限|每日|\\b(1005|1308|1310|1313|131[6-9]|132[01]|2056|20097)\\b"
"(?i)insufficient|quota|billing|exceeded your current|payment|per-day|daily limit|credit_balance_exhausted|spend_limit_exceeded|usage_limit_exceeded|使用上限|每日|\\b(1005|1304|1308|1310|1313|131[6-9]|132[01]|2056|20097|300[89]|3010)\\b"

:retryable-business-codes
;; BigModel business codes that mean TRY AGAIN — a per-minute limit (1302
;; 1303 1305), an overload (1312), a server or network hiccup (1120 1230
;; 1234 2007 3002) — from ZCode's own table (Zhipu's client,
;; failure-provider-business-codes.ts, read 2026-09-24). Retried whatever
;; HTTP status carries them: GLM answers some under a 400, or inside a 200,
;; and the status ladder alone called those fatal (karamazov-jvdu). Matched
;; against the CODE — the body's code / error_code field, or a `[1302]`
;; prefix on its message — never against free text, where a figure is not a
;; code. A set of strings.
#{"1120" "1230" "1234" "1302" "1303" "1305" "1312" "2007" "3002"}

:tool-version
;; A tool name followed by its version. Stripped BEFORE tokenizing,
Expand Down
13 changes: 12 additions & 1 deletion src/samizdat/agent/infer.clj
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
[samizdat.llm.fence :as fence]
[samizdat.llm.grammar :as grammar]
[samizdat.llm.message :as message]
[samizdat.llm.toolspec :as toolspec]
[samizdat.store.journal :as journal]
[samizdat.tape :as tape]))

Expand Down Expand Up @@ -378,7 +379,16 @@
;; endpoint's features and policy (force-mechanism).
{:keys [grammar] forced-prefill :prefill forced-tool :force-tool}
(force-mechanism ctx tape)
reasoning-budget (reasoning-budget-for ctx tape)]
reasoning-budget (reasoning-budget-for ctx tape)
;; The tool surface as native specs, read off the documentation
;; this branch's system prompt gives it, gates.edn's exact schemas
;; winning (llm.toolspec). Only for an endpoint measured to take
;; them; the adapter still leaves them off a prefilled or grammar
;; turn (karamazov-jl1f).
tools (when (config/supports? (:llm-config ctx) :native-tools)
(not-empty (toolspec/for-messages (:messages tape)
(gates/threshold :forceable-tools)
(gates/threshold :native-tool-description-chars))))]
(loop [attempt 1]
(let [;; Somebody may be watching the run: its calls stream, and what
;; the model writes goes onto the bus as it is written. Not a
Expand Down Expand Up @@ -408,6 +418,7 @@
;; A per-call thinking cap on llama.cpp
;; (reasoning-budget-for).
reasoning-budget (assoc :reasoning-budget reasoning-budget)
tools (assoc :tools tools)
;; The stable conversation key an endpoint
;; pins its prefix cache to. Only the local
;; adapter emits it; see LR-5.
Expand Down
12 changes: 10 additions & 2 deletions src/samizdat/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,11 @@
;; URL continues a flagged assistant prefix; `thinking {type
;; disabled}` reliably yields no reasoning; a native tool_choice
;; is honoured, but only with thinking off (the adapter knows).
:features #{:prefill :native-tool-choice :thinking-toggle :reasoning-effort :stream}
;; :native-tools — the tool surface sent on every request —
;; measured 2026-09-24: native tool_calls back with thinking on,
;; and no DSML in the content (samizdat.llm.toolspec).
:features #{:prefill :native-tool-choice :native-tools :thinking-toggle
:reasoning-effort :stream}
:key-env "DEEPSEEK_API_KEY"
;; deepseek-v4-flash is the development and test model: cheap
;; enough to run the beam repeatedly. deepseek-v4-pro is the
Expand All @@ -275,7 +279,9 @@
;; Measured 2026-09-06: ignores a trailing assistant prefix
;; entirely, cannot be told not to think (effort low is the
;; least), honours tool_choice {type function} despite its docs.
:features #{:native-tool-choice :reasoning-effort :stream}
;; :native-tools measured 2026-09-24 as for DeepSeek, and it is
;; how Zhipu's own harness (ZCode) calls every GLM model.
:features #{:native-tool-choice :native-tools :reasoning-effort :stream}
:key-env "ZHIPU_API_KEY"
:model "glm-5.3"
;; GLM benefits from a low temperature on coding tasks (dirge
Expand Down Expand Up @@ -320,6 +326,8 @@
;; :prefill continues a trailing assistant message (the fence
;; force; the reply content may or may not repeat it)
;; :native-tool-choice tools + tool_choice {type function}
;; :native-tools the whole tool surface sent as `tools` on every turn
;; a prefill or grammar does not force (llm.toolspec)
;; :grammar a GBNF `grammar` field applied at sampling (llama.cpp)
;; :cache-prompt `cache_prompt` / `id_slot` prefix-cache reuse (llama.cpp)
;; :reasoning-budget `reasoning_budget_tokens` per call (llama.cpp)
Expand Down
63 changes: 47 additions & 16 deletions src/samizdat/llm/adapter/openai.clj
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,34 @@

(defn- tool-call->fence
"A native OpenAI tool_call turned into the harness's text-fence convention, so
a forced (tool_choice) response parses through exactly the same path a normal
fenced response does. `arguments` is a JSON string; it is parsed and re-embedded
as `args` so the downstream fence parser reads one object."
a native call parses through exactly the same path a fenced one does.
`arguments` is a JSON string, re-embedded as `args` when it parses — and
embedded AS WRITTEN when it does not, so the fence parser's repair ladder
gets its chance and a failure is reported to the model as the parse error
it is, rather than becoming an empty `{}` the tool then calls missing."
[tc]
(let [name (get-in tc [:function :name])
args (try (json/read-str (str (get-in tc [:function :arguments])))
(catch Throwable _ {}))]
raw (str (get-in tc [:function :arguments]))
args (try (json/read-str raw) (catch Throwable _ ::unparsed))]
(str "```tool-call\n"
(json/write-str {:name name :args args})
(if (= ::unparsed args)
(str "{\"name\": " (json/write-str name) ", \"args\": " raw "}")
(json/write-str {:name name :args args}))
"\n```")))

(defn- native-content
"What a reply said, with every native call it made appended as a fence:
the words a model wrote beside its call are kept (glm-5.3 writes a
sentence and a call), and several calls stay several fences, for the fence
rule to pick from and count rather than for this to drop quietly."
[msg]
(let [calls (:tool_calls msg)
said (str/trim (str (:content msg)))]
(if (seq calls)
(str/join "\n\n" (cond->> (map tool-call->fence calls)
(seq said) (cons said)))
(:content msg))))

(defn- supports-prefill?
"Whether this endpoint continues a flagged trailing assistant message:
config :llm :features says (samizdat.config), not the provider id and not
Expand Down Expand Up @@ -158,7 +175,7 @@
{}))

(chat-body [this config {:keys [messages max-tokens temperature prefill force-tool
cache-key reasoning-effort grammar reasoning-budget]}]
cache-key reasoning-effort grammar reasoning-budget tools]}]
;; The gate is the protocol method on THIS adapter (provenance R3-14), so the
;; answer a caller can query and the answer chat-body acts on are one
;; path and cannot drift apart.
Expand All @@ -182,7 +199,17 @@
;; because tool_choice is incompatible with DeepSeek's thinking mode;
;; and where a grammar forces it, the tools array is not sent either,
;; because the template would render it into the prefix.
forcing? (and force-tool (not use-prefill?) (not grammar*))]
forcing? (and force-tool (not use-prefill?) (not grammar*))
;; The whole tool surface, natively, on every turn the endpoint takes
;; it (config :native-tools, measured) — the way DeepSeek's and
;; Zhipu's own harnesses call their models (samizdat.llm.toolspec).
;; Never beside a prefill: DeepSeek answers `Function call should
;; not be used with prefix` (400, measured 2026-09-24). Never beside
;; a grammar, which forces the call at sampling on an endpoint that
;; renders tools into the prefix.
native (when (and (seq tools) (config/supports? config :native-tools)
(not use-prefill?) (not grammar*))
(mapv (fn [t] {:type "function" :function t}) tools))]
(cond-> {:model (:model config)
:messages (if use-prefill?
;; `:prefix true` is what makes the provider CONTINUE
Expand All @@ -205,9 +232,16 @@

grammar* (assoc :grammar grammar*)

;; Only the forced tool is exposed.
native (assoc :tools native)

;; A force names its tool. With the surface already native the array
;; stays whole, so the forced turn's prefix is the same bytes as every
;; other turn's; otherwise only the forced tool is exposed.
forcing?
(assoc :tools [{:type "function" :function force-tool}]
(assoc :tools (cond
(nil? native) [{:type "function" :function force-tool}]
(some #(= (:name force-tool) (get-in % [:function :name])) native) native
:else (conj native {:type "function" :function force-tool}))
:tool_choice {:type "function"
:function {:name (:name force-tool)}})

Expand All @@ -230,12 +264,9 @@
(parse-chat [_ body]
(when-let [choice (first (:choices body))]
(let [msg (:message choice)]
{;; A forced tool_choice response carries the call in tool_calls, not
;; content — fold it into the fence convention so downstream is blind to
;; how the call was produced.
:content (if-let [tc (first (:tool_calls msg))]
(tool-call->fence tc)
(:content msg))
{;; A native call arrives in tool_calls, not content — folded into the
;; fence convention so downstream is blind to how the call was made.
:content (native-content msg)
:reasoning (get msg reasoning-key)
:finish-reason (or (:finish_reason choice) "stop")
;; The model that ANSWERED, as the provider names it, or nil when
Expand Down
Loading
Loading