From ac25864e92971bb9e17430734b1c94d3812cfefe Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:09:19 +0900 Subject: [PATCH] Fix: Send reasoning and tool tags as frames of their own The app opens the reasoning panel only on a frame that is exactly or . On the one-at-a-time path TextIteratorStreamer emits at word boundaries, and with Qwen3's tokenizer the tags arrive as '\n' and '\n\n', so the panel never opened and the tags showed up in the answer. The model layer now splits every special tag into its own frame. --- api/src/main/models/base.py | 18 +++++++++++++- api/tests/test_think_frames.py | 44 ++++++++++++++++++++++++++++++++++ docs/SPEC.md | 3 ++- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 api/tests/test_think_frames.py diff --git a/api/src/main/models/base.py b/api/src/main/models/base.py index 9307d620..e428e3c9 100644 --- a/api/src/main/models/base.py +++ b/api/src/main/models/base.py @@ -1,4 +1,5 @@ import logging +import re from re import finditer, DOTALL from dataclasses import dataclass from typing import Generator, Optional, List, Dict, Union @@ -172,6 +173,21 @@ def adaptive_special_tag_buffering(outs, wait_tokens_for=6): else: yield wd + tag_pattern = re.compile("(" + "|".join(re.escape(t) for t in ( + self.special_tags.REASONING, self.special_tags.REASONING_END, + self.special_tags.TOOLCALL, self.special_tags.TOOLCALL_END, + )) + ")") + + def split_special_tags(outs): + """ + Give every special tag a frame of its own. The app matches ``/`` exactly, and a + streamer that emits at word boundaries glues tags to whitespace or text (`'\\n'`). + """ + for wd in outs: + for piece in tag_pattern.split(wd): + if piece: + yield piece + initial_operation = True function_called = True while function_called: @@ -206,7 +222,7 @@ def adaptive_special_tag_buffering(outs, wait_tokens_for=6): ) generation_kwargs.update(kwargs) outputs = self.parse_tool_calling( - adaptive_special_tag_buffering(self.runtime(**generation_kwargs)), + split_special_tags(adaptive_special_tag_buffering(self.runtime(**generation_kwargs))), chat_history=chat_history, tools=tools, stream=stream, diff --git a/api/tests/test_think_frames.py b/api/tests/test_think_frames.py new file mode 100644 index 00000000..71952ec5 --- /dev/null +++ b/api/tests/test_think_frames.py @@ -0,0 +1,44 @@ +""" +Reasoning and tool tags reach the app as frames of their own (SPEC S1.4, #112): the app matches +`` and `` exactly, and the one-at-a-time path's streamer glues them to whitespace +(`'\n'`, `'\n\n'`). +""" +from api.src.main.models.base import BaseModel +from api.src.main.models.config import ChatHistory +from api.src.main.utils import FunctionCalling + + +class ScriptedEngine: + def __init__(self, chunks): + self.chunks = chunks + + def __call__(self, messages, **kwargs): + yield from self.chunks + + +class Plain(BaseModel): + model_id = "test" + supported_tools = FunctionCalling.DISABLED + + +def frames(chunks): + model = Plain(engine=ScriptedEngine(chunks)) + return [f for f in model.chat(ChatHistory(), "Hi", tools=[]) if f] + + +def test_think_tags_glued_to_whitespace_become_their_own_frames(): + out = frames(["\n", "The user ", "greets me.", "\n\n\n", "Hello", "!"]) + + assert "" in out + assert "" in out + assert "".join(out) == "\nThe user greets me.\n\n\nHello!" + + +def test_text_and_tag_in_one_chunk_are_split(): + out = frames(["ok", "x", "done"]) + + assert out == ["ok", "", "x", "", "done"] + + +def test_text_without_tags_is_untouched(): + assert frames(["Hello", " world"]) == ["Hello", " world"] diff --git a/docs/SPEC.md b/docs/SPEC.md index 95c82198..e4a24448 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -91,7 +91,8 @@ and S3.4 also record the maintainer's decisions of the same day ([`serving/engin The server then sends one text frame per generated chunk, then ``, then closes. An unknown session closes the socket with code `1008`. Reasoning arrives between `` and `` -frames; tool activity arrives as ` … ` frames (S1.6). Generation runs off +frames; tool activity arrives as ` … ` frames (S1.6). Each tag is a frame of its own, +even when the model's streamer emits it glued to whitespace or text (#112). Generation runs off the event loop; if the client disconnects mid-stream, generation stops and the model is free for the next request (#35).