Repository navigation
Reasoning parsers re-enter reasoning on a <think> after </think> when streaming, and delete it when not (qwen3, glm, minimax, deepseekv32, gemma4, minimax_m3) #711
Description
What happened
With thinking on (the default for qwen3), the chat template opens the think block and the model emits only </think>. After that, a later <think> in the answer is plain text, for example inside a tool-call argument when an agent greps for <think> or edits code that contains the markers. The two modes handle it differently, and both modes corrupt the call:
grep call with pattern="<think>" |
edit argument START = "<think>", END = "</think>" |
|
|---|---|---|
| Expected | pattern is <think> |
the argument text unchanged |
| Streaming | the rest of the call, </parameter></function></tool_call>, streams as reasoning_content. The tool parser never sees the call close, so its streamed arguments stop at {"pattern":" (invalid JSON). |
"\nEND = " moves into reasoning_content. Both markers are stripped and the argument becomes START = "" |
| Non-streaming | pattern is empty |
<think> is deleted and </think> is kept: START = ""\nEND = "</think>" |
Streaming treats the first <think> it sees as a new reasoning block, even after reasoning has ended. Non-streaming deletes every <think> in the text and splits at the first </think>. Every parser built on BaseReasoningParser is affected in streaming: qwen3, glm and minimax (ThinkReasoningParser), deepseekv32 and gemma4 (with its own markers), and minimax_m3 (<mm:think>). minimax_m3 already keeps the markers verbatim in non-streaming, so its two modes disagree. gpt_oss and muse_glimmer override all three methods and are not covered here.
Minimal reproduction (CPU only, PYTHONPATH=python):
from freetoken.server.reasoning_parser import ReasoningParser as RP
def both(name, toks):
p = RP(name, force_reasoning=True); R = C = ""
for t in toks + [None]:
r, c = p.flush() if t is None else p.parse_stream_chunk(t); R += r; C += c
print(name, "stream:", (R, C)); print(name, "non-stream:", RP(name, force_reasoning=True).parse_non_stream("".join(toks)))
both("qwen3", ["plan", "</think>", "\n\n", "<tool_call>\n<function=grep>\n<parameter=pattern>\n",
"<think>", "\n</parameter>\n</function>\n</tool_call>"])
marks = {"qwen3": ("<think>", "</think>"), "glm": ("<think>", "</think>"), "minimax": ("<think>", "</think>"),
"deepseekv32": ("<think>", "</think>"), "gemma4": ("<|channel>thought\n", "<channel|>"),
"minimax_m3": ("<mm:think>", "</mm:think>")}
for name, (o, c) in marks.items():
toks = ["plan", c, "\n\n", "<tool_call>\n<function=edit>\n<parameter=new>\n",
'START = "', o, '"\nEND = "', c, '"\n', "</parameter>\n</function>\n</tool_call>"]
p = RP(name, force_reasoning=True); R = C = ""
for t in toks + [None]:
r, d = p.flush() if t is None else p.parse_stream_chunk(t); R += r; C += d
nr, nc = RP(name, force_reasoning=True).parse_non_stream("".join(toks))
arg = lambda s: s.split("<parameter=new>\n")[1].split("\n</parameter>")[0]
print(f"{name:12} stream: reasoning={R!r:22} arg={arg(C)!r}\n{'':12} non-stream: reasoning={nr!r:18} arg={arg(nc)!r}")Output on main 9b68e00:
qwen3 stream: ('plan\n</parameter>\n</function>\n</tool_call>', '\n\n<tool_call>\n<function=grep>\n<parameter=pattern>\n')
qwen3 non-stream: ('plan', '<tool_call>\n<function=grep>\n<parameter=pattern>\n\n</parameter>\n</function>\n</tool_call>')
qwen3 stream: reasoning='plan"\nEND = "' arg='START = ""'
non-stream: reasoning='plan' arg='START = ""\nEND = "</think>"'
glm stream: reasoning='plan"\nEND = "' arg='START = ""'
non-stream: reasoning='plan' arg='START = ""\nEND = "</think>"'
minimax stream: reasoning='plan"\nEND = "' arg='START = ""'
non-stream: reasoning='plan' arg='START = ""\nEND = "</think>"'
deepseekv32 stream: reasoning='plan"\nEND = "' arg='START = ""'
non-stream: reasoning='plan' arg='START = ""\nEND = "</think>"'
gemma4 stream: reasoning='plan"\nEND = "' arg='START = ""'
non-stream: reasoning='plan' arg='START = ""\nEND = "<channel|>"'
minimax_m3 stream: reasoning='plan"\nEND = "' arg='START = ""'
non-stream: reasoning='plan' arg='START = "<mm:think>"\nEND = "</mm:think>"'
With thinking off (force_reasoning=False), a <think>…</think> that the model quotes in its answer is also taken as reasoning: "Wrap it as <think>x</think> then answer." streams as reasoning 'x'. Non-streaming returns reasoning 'Wrap it as x', so the prose before the opener is moved into reasoning as well.
Cause (current main, 9b68e00)
python/freetoken/server/reasoning_parser.py:168-172(BaseReasoningParser.parse_streaming_increment): the first completethink_start_tokenin the buffer is stripped and sets_in_reasoning = Truewhenever_stripped_think_startis still False. Withforce_reasoning=Trueno opener was ever stripped, so the first opener after</think>re-enters reasoning. The next</think>(lines 176-183) then ends it again and is stripped too._split_trailing_partial(lines 222-224) also holds back partial openers for the same reason.python/freetoken/server/reasoning_parser.py:130-151(detect_and_parse):in_reasoningis true if the opener appears anywhere,text.replace(self.think_start_token, "")deletes every opener, and the text is split at the first closer. The two code paths share no logic, so they disagree.MiniMaxM3ReasoningParser.detect_and_parse(lines 452-492) already treats later markers as data, but its streaming path calls the base increment (line 518), so it re-enters there.python/freetoken/server/generation.py:398defaultsforce_reasoningto on forqwen3, so this is the default path for Qwen3 agent traffic.
Proposed fix
- Reasoning is one block. Once it closes (at the first closer, or at the tool marker for
deepseekv32andminimax_m3), the parser stays in content for the rest of the turn, and any later opener or closer is literal content in both modes. - With
force_reasoning=False, an opener starts reasoning at most once, and the text before it stays content.minimax_m3already does this in non-streaming. - Define
detect_and_parseas feeding the whole text to a fresh instance's streaming path and then callingflush(), asMuseGlimmerReasoningParser.detect_and_parsealready does. Then the modes agree by construction, and a parity test over chunkings, with markers quoted in content and tool arguments, covers every family.
Environment
Seen serving nvidia/Qwen3.8-Flash-Next-NVFP4 on an RTX 5080 16 GB; the reproduction above is CPU-only on main 9b68e00.
Related: #712 (whitespace around </think> also differs between the two modes).
Activity