diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index e3398ecb..8bb81ef8 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -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) diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 4243671b..bb0f5e55 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -71,7 +71,8 @@ def strip_think(text: str) -> str: Ollama renderer). Covers: - 1. Well-formed `...` and `...` blocks. + 1. Well-formed `...`, `...`, + and `...` blocks. 2. Streaming prefixes where the block is never closed. 3. *Malformed* opening tags missing the `>` — e.g. ` str: 4. Harmony-style channel markers like `` / `<|channel|>` **at the start of the text** — conservative to avoid eating explanatory prose that mentions these tokens. - 5. Orphan closing tags `` / `` **at the very start - or end of the text** only, for the same reason. + 5. Orphan closing tags `` / `` / `` + **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 ` str: # Well-formed blocks first. text = re.sub(r"[\s\S]*?", "", text) text = re.sub(r"^\s*[\s\S]*$", "", text) + text = re.sub(r"[\s\S]*?", "", text) + text = re.sub(r"^\s*[\s\S]*$", "", text) text = re.sub(r"[\s\S]*?", "", text) text = re.sub(r"^\s*[\s\S]*$", "", text) - # Malformed opening tags: `` is an empty marker, not user-visible text. + text = re.sub(r"^\s*\s*", "", text) + text = re.sub(r"\s*\s*$", "", text) + # Malformed opening tags: `` / `/` — 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 `/])", "", text) + text = re.sub(r"/])", "", text) text = re.sub(r"/])", "", text) # Edge-only orphan closing tags (start or end of text). text = re.sub(r"^\s*\s*", "", text) text = re.sub(r"\s*\s*$", "", text) + text = re.sub(r"^\s*\s*", "", text) + text = re.sub(r"\s*\s*$", "", text) text = re.sub(r"^\s*\s*", "", text) text = re.sub(r"\s*\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"?" + r"?" 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*\s*$", "", text) + return text.strip() + + def extract_think(text: str) -> tuple[str | None, str]: - """Extract thinking content from inline ```` / ```` 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"([\s\S]*?)", text): parts.append(m.group(1).strip()) + for m in re.finditer(r"([\s\S]*?)", text): + parts.append(m.group(1).strip()) for m in re.finditer(r"([\s\S]*?)", 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 diff --git a/tests/agent/test_runner_reasoning.py b/tests/agent/test_runner_reasoning.py index 9724d2b0..d74d4ba6 100644 --- a/tests/agent/test_runner_reasoning.py +++ b/tests/agent/test_runner_reasoning.py @@ -369,3 +369,39 @@ async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup(): assert result.final_content == "done" assert hook.emitted == ["part1", "part2"] + + +@pytest.mark.asyncio +async def test_runner_strips_thinking_tags_from_native_thinking_deltas(): + from nanobot.agent.runner import AgentRunner, AgentRunSpec + + provider = MagicMock() + + async def chat_stream_with_retry( + *, on_content_delta=None, on_thinking_delta=None, **kwargs + ): + if on_thinking_delta: + await on_thinking_delta("") + await on_thinking_delta("Preparing final response") + await on_thinking_delta("") + if on_content_delta: + await on_content_delta("done") + return LLMResponse(content="done", tool_calls=[], usage={}) + + provider.chat_stream_with_retry = chat_stream_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + + hook = _StreamRecordingHook() + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "q"}], + tools=tools, + model="test-model", + max_iterations=3, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + hook=hook, + )) + + assert result.final_content == "done" + assert hook.emitted == ["Preparing final response"] diff --git a/tests/utils/test_strip_think.py b/tests/utils/test_strip_think.py index f1048f40..cbcc65f5 100644 --- a/tests/utils/test_strip_think.py +++ b/tests/utils/test_strip_think.py @@ -1,4 +1,9 @@ -from nanobot.utils.helpers import extract_reasoning, extract_think, strip_think +from nanobot.utils.helpers import ( + extract_reasoning, + extract_think, + strip_reasoning_tags, + strip_think, +) class TestStripThinkTag: @@ -27,6 +32,15 @@ class TestStripThinkTag: def test_self_closing_tag_not_matched(self): assert strip_think("some text") == "some text" + def test_thinking_alias_closed_tag(self): + assert strip_think("Hello reasoning World") == "Hello World" + + def test_thinking_alias_unclosed_trailing_tag(self): + assert strip_think("ongoing...") == "" + + def test_self_closing_thinking_marker_at_start_stripped(self): + assert strip_think("some text") == "some text" + def test_normal_text_unchanged(self): assert strip_think("Just normal text") == "Just normal text" @@ -165,6 +179,12 @@ class TestExtractThink: assert thinking == "reasoning content" assert clean == "Hello World" + def test_single_thinking_block(self): + text = "Hello reasoning content World" + thinking, clean = extract_think(text) + assert thinking == "reasoning content" + assert clean == "Hello World" + def test_multiple_think_blocks(self): text = "AfirstBsecondC" thinking, clean = extract_think(text) @@ -230,6 +250,24 @@ squares = [x**2 for x in range(10)] class TestExtractReasoning: """Single source of truth for reasoning extraction across all providers.""" + def test_strips_tags_from_dedicated_reasoning_content(self): + reasoning, content = extract_reasoning( + "Preparing final response", + None, + "visible answer", + ) + assert reasoning == "Preparing final response" + assert content == "visible answer" + + def test_self_closing_thinking_marker_in_reasoning_content(self): + reasoning, content = extract_reasoning( + "Preparing final response", + None, + "visible answer", + ) + assert reasoning == "Preparing final response" + assert content == "visible answer" + def test_prefers_reasoning_content_and_strips_inline_think(self): # Dedicated field wins; inline tags are still scrubbed from content. reasoning, content = extract_reasoning( @@ -271,3 +309,21 @@ class TestExtractReasoning: ) assert reasoning == "plan" assert content == "answer" + + +class TestStripReasoningTags: + + def test_unclosed_thinking_wrapper_keeps_reasoning_body(self): + assert strip_reasoning_tags("Preparing final response") == ( + "Preparing final response" + ) + + def test_self_closing_thinking_marker_keeps_reasoning_body(self): + assert strip_reasoning_tags("Preparing final response") == ( + "Preparing final response" + ) + + def test_closing_thinking_wrapper_removed(self): + assert strip_reasoning_tags("Preparing final response") == ( + "Preparing final response" + )