fix: normalize thinking tags in reasoning output

This commit is contained in:
Zhou
2026-06-24 15:45:34 +08:00
committed by Xubin Ren
parent f9afc9389b
commit 523bb928bf
4 changed files with 131 additions and 12 deletions
+5 -2
View File
@@ -34,6 +34,7 @@ from nanobot.utils.helpers import (
extract_reasoning,
find_legal_message_start,
maybe_persist_tool_result,
strip_reasoning_tags,
strip_think,
truncate_text,
)
@@ -778,8 +779,10 @@ class AgentRunner:
async def _thinking(delta: str) -> None:
if not delta:
return
context.streamed_reasoning = True
await hook.emit_reasoning(delta)
delta = strip_reasoning_tags(delta)
if delta:
context.streamed_reasoning = True
await hook.emit_reasoning(delta)
async def _stream_recover() -> None:
await hook.on_stream_end(context, resuming=True)
+33 -9
View File
@@ -71,7 +71,8 @@ def strip_think(text: str) -> str:
Ollama renderer).
Covers:
1. Well-formed `<think>...</think>` and `<thought>...</thought>` blocks.
1. Well-formed `<think>...</think>`, `<thinking>...</thinking>`,
and `<thought>...</thought>` blocks.
2. Streaming prefixes where the block is never closed.
3. *Malformed* opening tags missing the `>` — e.g. `<think广场…`. The
model sometimes emits the tag name directly followed by user-facing
@@ -80,8 +81,8 @@ def strip_think(text: str) -> str:
4. Harmony-style channel markers like `<channel|>` / `<|channel|>`
**at the start of the text** — conservative to avoid eating
explanatory prose that mentions these tokens.
5. Orphan closing tags `</think>` / `</thought>` **at the very start
or end of the text** only, for the same reason.
5. Orphan closing tags `</think>` / `</thinking>` / `</thought>`
**at the very start or end of the text** only, for the same reason.
6. Trailing partial control tags split across stream chunks, such as
`<thi`, `<thin`, or `<tho`.
@@ -93,19 +94,27 @@ def strip_think(text: str) -> str:
# Well-formed blocks first.
text = re.sub(r"<think>[\s\S]*?</think>", "", text)
text = re.sub(r"^\s*<think>[\s\S]*$", "", text)
text = re.sub(r"<thinking>[\s\S]*?</thinking>", "", text)
text = re.sub(r"^\s*<thinking>[\s\S]*$", "", text)
text = re.sub(r"<thought>[\s\S]*?</thought>", "", text)
text = re.sub(r"^\s*<thought>[\s\S]*$", "", text)
# Malformed opening tags: `<think` / `<thought` where the next char is
# Self-closing `<thinking/>` is an empty marker, not user-visible text.
text = re.sub(r"^\s*<thinking/>\s*", "", text)
text = re.sub(r"\s*<thinking/>\s*$", "", text)
# Malformed opening tags: `<think` / `<thinking` / `<thought` where the next char is
# NOT one that could continue a valid tag / identifier name. Explicitly
# listing ASCII tag-name chars (letters, digits, `_`, `-`, `:`) plus
# `>` / `/` — we can't use `\w` here because in Python's default
# Unicode regex mode it matches CJK characters too, which would defeat
# the primary fix for `<think广场…` leaks.
text = re.sub(r"<think(?![A-Za-z0-9_\-:>/])", "", text)
text = re.sub(r"<thinking(?![A-Za-z0-9_\-:>/])", "", text)
text = re.sub(r"<thought(?![A-Za-z0-9_\-:>/])", "", text)
# Edge-only orphan closing tags (start or end of text).
text = re.sub(r"^\s*</think>\s*", "", text)
text = re.sub(r"\s*</think>\s*$", "", text)
text = re.sub(r"^\s*</thinking>\s*", "", text)
text = re.sub(r"\s*</thinking>\s*$", "", text)
text = re.sub(r"^\s*</thought>\s*", "", text)
text = re.sub(r"\s*</thought>\s*$", "", text)
# Edge-only channel markers (harmony / Gemma 4 variant leaks).
@@ -113,7 +122,7 @@ def strip_think(text: str) -> str:
# Stream chunks may end in the middle of a control tag. Strip only known
# control-token prefixes at the very end.
partial_control_tag = (
r"</?(?:t|th|thi|thin|think|tho|thou|thoug|though|thought)>?"
r"</?(?:t|th|thi|thin|think|thinki|thinkin|thinking|tho|thou|thoug|though|thought)>?"
r"|<\|?(?:c|ch|cha|chan|chann|channe|channel)(?:\|?>?)?"
)
text = re.sub(rf"(?:{partial_control_tag})$", "", text)
@@ -121,8 +130,17 @@ def strip_think(text: str) -> str:
return text.strip()
def strip_reasoning_tags(text: str) -> str:
"""Remove wrapper tags from text that is already known to be reasoning."""
text = re.sub(r"^\s*<(?:think|thinking|thought)/>\s*", "", text)
text = re.sub(r"\s*<(?:think|thinking|thought)/>\s*$", "", text)
text = re.sub(r"^\s*<(?:think|thinking|thought)>\s*", "", text)
text = re.sub(r"\s*</(?:think|thinking|thought)>\s*$", "", text)
return text.strip()
def extract_think(text: str) -> tuple[str | None, str]:
"""Extract thinking content from inline ``<think>`` / ``<thought>`` blocks.
"""Extract thinking content from inline thinking tags.
Returns ``(thinking_text, cleaned_text)``. Only closed blocks are
extracted; unclosed streaming prefixes are stripped from the cleaned
@@ -131,6 +149,8 @@ def extract_think(text: str) -> tuple[str | None, str]:
parts: list[str] = []
for m in re.finditer(r"<think>([\s\S]*?)</think>", text):
parts.append(m.group(1).strip())
for m in re.finditer(r"<thinking>([\s\S]*?)</thinking>", text):
parts.append(m.group(1).strip())
for m in re.finditer(r"<thought>([\s\S]*?)</thought>", text):
parts.append(m.group(1).strip())
thinking = "\n\n".join(parts) if parts else None
@@ -194,10 +214,10 @@ def extract_reasoning(
final answer.
"""
if reasoning_content:
return reasoning_content, strip_think(content) if content else content
return strip_reasoning_tags(reasoning_content), strip_think(content) if content else content
if thinking_blocks:
parts = [
tb.get("thinking", "")
strip_reasoning_tags(tb.get("thinking", ""))
for tb in thinking_blocks
if isinstance(tb, dict) and tb.get("type") == "thinking"
]
@@ -520,7 +540,11 @@ def build_assistant_message(
if tool_calls:
msg["tool_calls"] = tool_calls
if reasoning_content is not None or thinking_blocks:
msg["reasoning_content"] = reasoning_content if reasoning_content is not None else ""
msg["reasoning_content"] = (
strip_reasoning_tags(reasoning_content)
if reasoning_content is not None
else ""
)
if thinking_blocks:
msg["thinking_blocks"] = thinking_blocks
return msg