dasLLAMA thinking/tool audit: per-arch reasoning matcher, reasoning_content on the server, live per-family proof tests - #3688
Conversation
…ontent on the server, live per-family proof tests The reply side of thinking models becomes per-arch data (THINKING.md's plan, executed): ThinkMode (symmetric/channel_switch/asymmetric) + markers on ChatTemplate, a streaming-capable splitter in dasllama_chat (think_feed/think_finish hold partial markers across SSE chunks; truncated reasoning classifies as reasoning), and split_reasoning as strip_think's generalization — respond() history rides it. gemma-4 E-series gains thinking-ON (think_default polarity, the <|think|> system gate, the bare assistant_open_think header) with the instruct default pinned token-for-token; stop_nothink cuts instruct-mode generation at a stray channel marker (observed live: "…4.<channel|>4") via the new effective_stop_ids verb. qwen35 gets the Hermes tool block it was missing. The server surfaces reasoning_content (message + streaming deltas, DeepSeek/llama.cpp framing), splits reasoning before tool-call parsing so thinking + tools compose, and treats an absent enable_thinking as the family default (tri-state). Tests: test_think_split (model-free, every family's wire shape at every chunk boundary), test_chat render pins (gemma-4 off/on, qwen35 markers), test_openai_server_think live legs — Qwen3-0.6B (think + streaming order + tools-with-thinking compose), gemma-4-E2B (off/on), gpt-oss-20b (Harmony, PARITY_FULL-gated) — all green on this box. CODEREVIEW.md binds new families to these tests; GLM-4's live leg is recorded in THINKING.md for the zen2 box (no small local model exists). Drive-by: test_facade_docs ENGINE_FILES caught up with the common split (a pre-existing red — load_model_/generate_ et al live in load/blocks/batch/sampling). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR completes the dasLLAMA “thinking/tool-use” audit by making reply-side reasoning detection architecture-aware, exposing reasoning via OpenAI-compatible reasoning_content (buffered + streaming), and adding both model-free and live, model-gated tests to prove the behavior across supported families.
Changes:
- Added a per-family reply reasoning matcher (symmetric
<think>, gemma-4 asymmetric thought channel, Harmony channel-switch) plus an incremental streaming splitter. - Extended the OpenAI server surface to emit
reasoning_content(message + delta) and to respect tri-stateenable_thinkingwhile usingeffective_stop_idsfor “thinking-off” stop behavior. - Added/updated tests and documentation to pin exact wire shapes and to require test legs when new families declare thinking/tool markers.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| utils/dasllama-server/test_openai_server_think.das | New live, model-gated conformance tests for reasoning/tool-call behavior through the server. |
| utils/dasllama-server/openai_server.das | Adds reasoning_content to responses/deltas and wires in the per-request think stream + effective stop ids. |
| modules/dasLLAMA/THINKING.md | Updates the thinking/tool audit spec to match the new per-family matcher and test map. |
| modules/dasLLAMA/tests/test_think_split.das | New model-free tests that pin whole-string + chunked streaming splits for each family format. |
| modules/dasLLAMA/tests/test_facade_docs.das | Fixes ENGINE_FILES list after engine entrypoints were split/moved. |
| modules/dasLLAMA/tests/test_chat.das | Adds render/prompt-side tests for gemma-4 thinking gate/opener and qwen35 Hermes tool markers. |
| modules/dasLLAMA/dasllama/dasllama.das | Exposes new facade APIs: effective_stop_ids, split_reasoning, and streaming split helpers. |
| modules/dasLLAMA/dasllama/dasllama_common.das | Adds ThinkMode and ChatTemplate fields for reply-side reasoning formats and thinking defaults. |
| modules/dasLLAMA/dasllama/dasllama_chat.das | Implements reply-side split engine + effective stop ids; updates history storage to reasoning-stripped content. |
| modules/dasLLAMA/dasllama/dasllama_arch_qwen35.das | Restores Hermes tool format registration for qwen35. |
| modules/dasLLAMA/dasllama/dasllama_arch_gptoss.das | Declares Harmony channel_switch think mode for gpt-oss. |
| modules/dasLLAMA/dasllama/dasllama_arch_glm4moe.das | Declares symmetric <think> reply mode for GLM-4 MoE family. |
| modules/dasLLAMA/dasllama/dasllama_arch_gemma4.das | Declares asymmetric thought-channel format + thinking gate/default and thinking-off extra stops. |
| modules/dasLLAMA/CODEREVIEW.md | Adds a binding rule requiring recognition tests when adding thinking/tool markers to an arch. |
| doc/reflections/das2rst.das | Updates reflection doc grouping/stanzas to include the new reasoning-facing API surface. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ed-keyed strip, think_drain dedup, qwen35 live leg, CI + docs + checklist repairs The A-F review findings from the opus code-review + CODEREVIEW.md audit: history keeps the legacy strip for think_mode=none families (R1-distills on llama must not bloat history); Harmony headers route by the channel NAME token, not a 'final' substring; the reply matcher, alternate opener, and framing stops all key on one think_turn_active predicate — vocab-gated (qwen2.5's inert ChatML declaration never arms) and gate-aware (a mid-conversation set_thinking(true) on gemma-4 stays instruct with stops armed, pinned by test); the strip rule keys on a CONSUMED span so an empty think block strips (strip_think parity); think_drain is the one drain engine behind split_reasoning, the server, and the tests; render_system_turn unifies the text/audio prompt paths (audio gains the tool-marker split). qwen35 gets its live Hermes leg (the model calls get_weather, the server parses it — green); glm's wire shape runs against glm's own template; CI compiles the think test; test_think_split registers in tests/CLAUDE.md. READMEs document reasoning_content + tri-state enable_thinking; the CODEREVIEW.md recognition-test rule is rewritten per its own self-review (trigger on template data incl. shared helpers, inert-vocab exemption, tools pins in test_chat, the server-file carve-out + conventions named). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated no new comments.
Suppressed comments (3)
modules/dasLLAMA/dasllama/dasllama_chat.das:988
- History stripping currently uses
split_reasoning_(c.tmpl, reply)wheneverthink_mode != none, which bypasses the vocab/gate arming logic. For chatml-based families whose vocab lacks<think>tokens (e.g. qwen2.5), this can incorrectly reclassify a literal "" echo at the start of a non-thinking reply and strip it from history. Gate the split onthink_turn_active_(c)(or at leastc.think_vocab) so inert declarations don’t affect stored history.
// history holds only the answer — the per-arch reasoning span strips; families with no
// declared reply format keep the legacy unconditional <think> strip (a thinking fine-tune
// served under a non-thinking arch — R1-distills on `llama` — must not bloat history)
let stored = (c.tmpl.think_mode == ThinkMode.none ? strip_think(reply)
: split_reasoning_(c.tmpl, reply).content)
utils/dasllama-server/test_openai_server_think.das:212
- In the streaming SSE loop,
from_JV(jsi, type<Chunk>)produces a struct with owned arrays/strings, but it is never deleted. In daslang, plain structs/arrays don’t automatically finalize at scope exit, so this will leak per chunk and can skew long streaming tests.
var jsi = read_json(datas[i], perr)
if (jsi != null) {
let c = from_JV(jsi, type<Chunk>)
if (length(c.choices) == 1) {
if (!empty(c.choices[0].delta.reasoning_content)) {
reasoning = "{reasoning}{c.choices[0].delta.reasoning_content}"
if (first_reason < 0) {
first_reason = i
}
}
if (!empty(c.choices[0].delta.content)) {
content = "{content}{c.choices[0].delta.content}"
if (first_content < 0) {
first_content = i
}
}
}
unsafe {
delete jsi
}
modules/dasLLAMA/dasllama/dasllama_arch_gemma4.das:56
d.lcaps.system_promptis still set to false (and the comment says “no system role”), but this template now clearly uses a system turn (d.chat.system.parts) and tests expect the<|think|>gate + system prompt text to render in that system turn. Keepingsystem_prompt=falsemakescaps()/arch_lcaps()lie to API consumers about supported system prompts.
d.lcaps.system_prompt = false // no system role — the chat layer folds it into the first user turn
…thout its das2rst home - CI's Uncategorized gate caught it) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| if (ready) { | ||
| invoke(blk, base_url) | ||
| } | ||
| POST("{base_url}/shutdown", "") $(_resp) {} | ||
| finished |> join | ||
| t |> success(ready, "dasllama server became ready") |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated no new comments.
Suppressed comments (2)
modules/dasLLAMA/dasllama/dasllama_chat.das:865
think_feed_allocates two temporaryarray<string>buffers (rp/cp) and joins them on every streamed piece. In server streaming this can run per-token delta, so these per-call heap allocations and joins can become a noticeable overhead.
Consider changing the splitter helpers to append directly into two string accumulators (or store/reuse scratch buffers inside ThinkStream) so feeding a piece is allocation-free in the common case.
var rp : array<string>
var cp : array<string>
if (ts.mode == ThinkMode.channel_switch) {
utils/dasllama-server/openai_server.das:1943
- For streaming chat replies, the code may emit up to two SSE chunks per scheduler piece (one for
reasoning_content, one forcontent). This can significantly increase SSE event count and JSON serialization overhead versus the pre-split streaming path.
If throughput/latency becomes an issue, consider emitting a single chunk when both deltas are non-empty (with both reasoning_content and content set), and only splitting into two chunks when needed to preserve the “reasoning first” ordering at the boundary.
var rsn = ""
var cnt = ""
think_feed(w.think, emit_now, rsn, cnt)
if (!empty(rsn)) {
send_chat_chunk(srv, w.writer, w.id, w.created, "", "", "", rsn)
Executes THINKING.md's reply-side plan and closes the thinking/tool-use audit: every locally-stocked thinking family now demonstrably thinks — and the server demonstrably recognizes it.
Chat layer
ThinkMode(symmetric / channel_switch / asymmetric) + reasoning markers as ChatTemplate data. The engine issplit_reasoning(whole reply, via the sharedthink_drain) plus a streaming splitter (make_think_stream/think_feed/think_finish) that holds partial markers back across chunk boundaries; a reasoning span truncated by the budget classifies as reasoning; an empty consumed think block still strips (strip_thinkparity). Harmony headers route by the channel NAME token (recipient-qualifiedcommentary to=...headers stay reasoning).think_turn_activepredicate for the matcher, the alternate opener, and the stop merge): an inert declaration — Qwen2.5 shares the ChatML template without think tokens — never arms, and a mid-conversationset_thinking(true)on gemma-4 stays instruct-shaped (the gate cannot enter an already-rendered context) with the framing stops still armed. Families with no declared reply format keep the legacy unconditional history strip (R1-distills served underllamamust not bloat history).think_defaultpolarity, the<|think|>system gate, a bare thinking-mode assistant opener; the instruct default renders token-for-token what it rendered before (pinned by test).stop_nothink+effective_stop_idscut instruct-mode generation at a stray channel marker (observed live: the model answering then rambling a closer and a repeat).hermes_tools— the Qwen3.5 template kept the Hermes tool block; the registration had dropped it.Server (
utils/dasllama-server)reasoning_contenton chat-completion messages and as streaming deltas (the DeepSeek/llama.cpp framing); reasoning splits before tool-call parsing, so a thinking model that calls tools returnsreasoning_contentANDtool_callsfrom one reply.enable_thinkingis tri-state: absent leaves the family default in force; a present bool (either spelling) overrides.Tests — commands and evidence
Module tests (all
-jit,DASLLAMA_CPU_PREFILL=1on this Metal-capable box):Live server legs (
DASLLAMA_MODELS_DIRset, full tier):Server regressions:
test_openai_server.das19/19,test_openai_server_stream.das8/8. Norun.das --armset applies: the change is chat-layer + server only and reaches no metal/prefill/decode/matrix arm (the runner suites cover engine kernels, which this diff does not touch).modules/dasLLAMA/tests/test_think_split.das— model-free: every family's exact wire shape (glm's against its own template), whole-string and per-chunk down to 1 byte, header-token routing, empty-block parity.extended_checks) now compile-guards the think test file alongside the existing server test.Drive-by fix:
test_facade_docsENGINE_FILESwent stale when the common split moved the engine entry points — the 1:1 facade check was failing on master and the miss was silent.Process: full preflight ran once (green after lint/docs fixes were re-validated with targeted gates); the second commit is the review round from the opus code-review + CODEREVIEW.md audit (10 + 14 findings, all applied). Pushes used
--no-verifyper the fix-after-full-run policy, announced.🤖 Generated with Claude Code