fix: parse reasoning when the chat template pre-opens <think> (#108) - #115
Conversation
Qwen3/3.5/3.6 chat templates append `<think>\n` to the generation prompt
when enable_thinking is true:
{%- if add_generation_prompt %}
{{- '<|im_start|>assistant\n' }}
{%- if enable_thinking is defined and enable_thinking is false %}
{{- '<think>\n\n</think>\n\n' }}
{%- else %}
{{- '<think>\n' }} <-- prompt ends inside the block
So the model emits reasoning with no opening tag of its own, terminated by
`</think>`. ThinkingStateTracker always started in .responding and
extractThinkingBlock required both tags, so the entire reasoning section was
misfiled as response content with a stray `</think>` inside it. The client
never received an opening tag, and replaying that assistant turn taught the
model to omit its own first line — the truncation in the report.
- Decode the rendered prompt tail and detect an unclosed opening tag, then
start the parser inside the thinking block. Detection is at runtime rather
than keyed to a model family: the same Qwen line differs by version
(Qwen3-1.7B does not pre-open, Qwen3.6 does).
- extractThinkingBlock gains `alreadyOpen` for the non-streaming path.
- Drain the tracker at end of stream. A response whose final characters look
like the start of a tag was held in the buffer and dropped entirely.
- Tag lists are now shared constants, and tag matching picks the earliest
match rather than preferring `<thinking>` found later in the buffer.
Verified end-to-end against a local model with a Qwen3.6-style template:
reasoning lands in reasoning_content, content is clean, no leaked tags, in
both streaming and non-streaming mode. A model whose template does not
pre-open is unaffected — detection stays off and output is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Update: the tool-call flow from the report is now reproduced and verified, not just inferred. Same model, same flags, two release binaries differing only in Before ( After (this branch): This is exactly @coolaj86's description: the client receives the model's private reasoning as On the reporter's exact model. I downloaded The same binary produces coherent output on So the verification above stands on |
- Detection now requires the opening tag to be the last thing in the prompt. Scanning the whole tail meant any `<think>` in the conversation itself fired it — a user asking "explain the <think> tag" had their entire answer routed into reasoning_content with content left empty. Templates that pre-open always end `…assistant\n<think>\n`, so the constraint costs nothing. - extractThinkingBlock with alreadyOpen now only honours an opening tag at index 0. A tag the model mentions inside its reasoning previously sent control down the both-tags path, which discarded everything before it — text that appeared in neither reasoning_content nor content. This also makes the non-streaming path agree with the streaming tracker, which stays in the thinking phase until a closing tag. - The stop-sequence exit path now emits what the tracker actually produced for the current chunk and drains it, instead of re-processing a slice of fullText. The old arithmetic assumed everything before the chunk had been emitted, which is false while the tracker holds a partial tag, so that text was dropped — the same truncation this PR set out to fix, on a path it did not cover. Three tests added: a tag mentioned in the prompt is not a pre-open, trailing whitespace still is, and leading reasoning survives a tag mentioned mid-block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review follow-up on 849745b. That commit fixed the dropped-held-text bug on the stop path but introduced a worse one: it fed the whole chunk to the tracker and emitted the result verbatim, so the client received the stop string itself and anything after it — with a multi-token stop like "\nUser:" that leaks an entire hallucinated turn. The three sibling stop-handling sites in this file all trim; this one silently stopped. The stop check now runs before the tracker is fed, and only the slice of the chunk that survives the trim enters the state machine. This keeps both properties: held partial-tag text is still drained via flush(), and the stop sequence never reaches the client. The unused `trimmedFull` binding the review flagged is gone because the value is used again. Also made lastRange's tie rule explicit (longest tag wins at the same start position) instead of depending on openTags array order; a reorder would have silently broken <thinking>-style pre-open detection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes #108.
Root cause
Qwen3/3.5/3.6 chat templates append
<think>\nto the generation prompt whenenable_thinkingis true. From the cachedQwen3.6-35B-A3Btokenizer_config.json:The model therefore emits reasoning with no opening tag of its own, terminated by
</think>. ButThinkingStateTrackeralways started in.responding(Server.swift:1695) andextractThinkingBlockrequired both tags (Server.swift:2137). So the whole reasoning section was misfiled ascontent, with a stray</think>inside it.That is exactly the reported symptom — "the client doesn't get the beginning
<think>tag" — and it explains the follow-on behaviour with tool calls: the replayed assistant turn starts mid-thought, so the model learns to omit its own first line.The reporter noting that vllm-mlx behaves identically fits: it's the template, not MLX.
Changes
Qwen3-1.7Bdoes not pre-open,Qwen3.6does), so hard-coding would break on the next release.ThinkingStateTracker(startInThinking:)— starts the parser inside the block;extractThinkingBlock(from:alreadyOpen:)does the same for the non-streaming path.<thinking>found later in the buffer.Tests
New
tests/SwiftLMTests/ThinkingPreOpenedTests.swift— 18 tests over prompt detection (both template branches, prior closed blocks in history, Gemma channel markers), streaming with a pre-opened block (including a closing tag split across chunks), the end-of-stream flush, and non-streaming extraction.testExtractPreOpenedBlockWithoutFlagIsUnchangedpins the old broken output so the difference is explicit.Verified red: with
startInThinkingignored andflush()stubbed to return empty, 9 of the 18 fail.End-to-end, against
mlx-community/Qwen3.5-4B-4bit— a real, unmodified model whosechat_template.jinjacarries the same pre-opening form as Qwen3.6:--thinking, non-streamingreasoning_content(461 chars),content="Tokyo", no leaked tags--thinking, streamingreasoning_contentdeltas,content="\n\n54", no leaked tags--thinking(control)reasoning_content= null,content="Tokyo"Also confirmed on
mlx-community/Qwen3.5-0.8B-MLX-4bitthat detection fires and reasoning is routed correctly, though that model loops without ever emitting</think>, so it never reaches the content phase.Full suite: 114 tests across 9 suites, 0 failures.
PromptCacheTestsaborts underswift testwithFailed to load the default metallib— pre-existing at HEAD and environmental, so suites were run individually.Note on scope
Streaming does not trim the newline that follows
</think>, socontentbegins with\n\n(visible in the table above). That is pre-existing behaviour shared with the self-opened path — the non-streaming path does trim — and this PR leaves it alone rather than widening scope. Worth a follow-up if the inconsistency matters.🤖 Generated with Claude Code