From 4c877cc7d886ac670fec6b754a48ac092eab9abb Mon Sep 17 00:00:00 2001 From: wasimysaid Date: Fri, 19 Jun 2026 16:51:40 +0200 Subject: [PATCH] Keep reasoning-only Responses output hidden --- .../core/inference/tool_call_parser.py | 147 ++---------------- studio/backend/core/tool_healing.py | 8 +- studio/backend/routes/inference.py | 15 -- .../tests/test_responses_tool_passthrough.py | 51 ++++-- 4 files changed, 56 insertions(+), 165 deletions(-) diff --git a/studio/backend/core/inference/tool_call_parser.py b/studio/backend/core/inference/tool_call_parser.py index d44c1cea1d..78e8f14103 100644 --- a/studio/backend/core/inference/tool_call_parser.py +++ b/studio/backend/core/inference/tool_call_parser.py @@ -10,20 +10,19 @@ or ``v...`` shape. import json import re - -# _TOOL_CLOSED_PATS: closed pairs only. _TOOL_ALL_PATS: also trailing unclosed -# runs so truncated tails don't leak markup. The [\w-] name set matches OpenAI's -# so hyphenated MCP tool names (mcp__srv__list-issues) parse like built-ins. -_TOOL_CLOSED_PATS = [ - re.compile(r".*?", re.DOTALL), - re.compile(r"<\|tool_call>.*?", re.DOTALL), - re.compile(r".*?", re.DOTALL), -] -_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [ - re.compile(r".*$", re.DOTALL), - re.compile(r"<\|tool_call>.*$", re.DOTALL), - re.compile(r".*$", re.DOTALL), -] +from core.tool_healing import ( + _TC_END_TAG_RE, + _TC_FUNC_CLOSE_RE, + _TC_FUNC_START_RE, + _TC_GEMMA_START_RE, + _TC_JSON_START_RE, + _TC_PARAM_CLOSE_RE, + _TC_PARAM_START_RE, + _TOOL_ALL_PATS, + _TOOL_CLOSED_PATS, + _balanced_brace_end, + _gemma_arguments_to_json, +) # Prefixes the streaming buffer watches for to gate in-progress text. @@ -76,19 +75,9 @@ RAG_SEARCH_CAP_NUDGE = ( ) -# Pre-compiled patterns reused by ``parse_tool_calls_from_text``. -_TC_JSON_START_RE = re.compile(r"\s*\{") -_TC_GEMMA_START_RE = re.compile(r"<\|tool_call>call:([\w-]+)\s*\{") -_TC_FUNC_START_RE = re.compile(r"\s*") -_TC_END_TAG_RE = re.compile(r"") _TC_GEMMA_END_TAG_RE = re.compile(r"") -_TC_FUNC_CLOSE_RE = re.compile(r"\s*\s*$") -# [\w-] so hyphenated MCP param names (issue-number) aren't dropped. -_TC_PARAM_START_RE = re.compile(r"\s*") -_TC_PARAM_CLOSE_RE = re.compile(r"\s*\s*$") _PARAM_CLOSE_TAG = "" _FUNC_CLOSE_TAG = "" -_GEMMA_QUOTE = '<|"|>' def _inside_open_parameter(content: str, pos: int) -> bool: @@ -116,116 +105,6 @@ def strip_tool_markup(text: str, *, final: bool = False) -> str: return text.strip() if final else text -def _balanced_brace_end(content: str, brace_start: int) -> int: - depth = 0 - i = brace_start - in_string = False - in_gemma_string = False - while i < len(content): - if content.startswith(_GEMMA_QUOTE, i): - in_gemma_string = not in_gemma_string - i += len(_GEMMA_QUOTE) - continue - ch = content[i] - if in_gemma_string: - i += 1 - continue - if in_string: - if ch == "\\" and i + 1 < len(content): - i += 2 - continue - if ch == '"': - in_string = False - elif ch == '"': - in_string = True - elif ch == "{": - depth += 1 - elif ch == "}": - depth -= 1 - if depth == 0: - return i - i += 1 - return -1 - - -def _normalise_gemma_quoted_strings(src: str) -> str: - parts: list[str] = [] - i = 0 - while i < len(src): - if not src.startswith(_GEMMA_QUOTE, i): - parts.append(src[i]) - i += 1 - continue - end = src.find(_GEMMA_QUOTE, i + len(_GEMMA_QUOTE)) - if end < 0: - parts.append(src[i:]) - break - raw_value = src[i + len(_GEMMA_QUOTE) : end] - parts.append(json.dumps(raw_value)) - i = end + len(_GEMMA_QUOTE) - return "".join(parts) - - -def _quote_gemma_object_keys(src: str) -> str: - parts: list[str] = [] - i = 0 - in_string = False - while i < len(src): - ch = src[i] - if in_string: - parts.append(ch) - if ch == "\\" and i + 1 < len(src): - parts.append(src[i + 1]) - i += 2 - continue - if ch == '"': - in_string = False - i += 1 - continue - if ch == '"': - in_string = True - parts.append(ch) - i += 1 - continue - if ch not in "{,": - parts.append(ch) - i += 1 - continue - - parts.append(ch) - i += 1 - key_start = i - while i < len(src) and src[i].isspace(): - i += 1 - key_name_start = i - while i < len(src) and (src[i].isalnum() or src[i] in "_-"): - i += 1 - key_name = src[key_name_start:i] - colon_pos = i - while colon_pos < len(src) and src[colon_pos].isspace(): - colon_pos += 1 - if key_name and colon_pos < len(src) and src[colon_pos] == ":": - parts.append(src[key_start:key_name_start]) - parts.append(json.dumps(key_name)) - parts.append(src[i:colon_pos]) - parts.append(":") - i = colon_pos + 1 - else: - parts.append(src[key_start:i]) - return "".join(parts) - - -def _gemma_arguments_to_json(args_src: str) -> dict: - """Parse Gemma 4's native call:name{key:value} argument object.""" - args_src = args_src.strip() - if not args_src: - return {} - src = _normalise_gemma_quoted_strings(args_src) - src = "{" + src + "}" - src = _quote_gemma_object_keys(src) - return json.loads(src) - - def parse_tool_calls_from_text( content: str, *, diff --git a/studio/backend/core/tool_healing.py b/studio/backend/core/tool_healing.py index f3e52534e8..c419d8e21c 100644 --- a/studio/backend/core/tool_healing.py +++ b/studio/backend/core/tool_healing.py @@ -1,14 +1,10 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -"""Tool-call XML parsing and stripping helpers. +"""Lightweight tool-call XML parsing and stripping helpers. -Extracted verbatim from studio/backend/core/inference/llama_cpp.py so external -inference servers can reuse the logic without importing the inference +External inference servers import this module without pulling in the inference orchestrator, structlog, httpx, or the rest of the studio backend. - -Regexes and bodies are byte-for-byte identical to the original; any change must -preserve that. test_tool_healing_extraction_is_exact.py verifies via AST. """ import json diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index b680cd5a4a..f40348a9ae 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -7606,21 +7606,6 @@ async def _responses_stream( "delta": final_visible, }, ) - if full_reasoning and not full_text and not tool_call_state: - for event in _ensure_message_open(): - yield event - full_text = full_reasoning - api_monitor.set_reply(monitor_id, full_text) - yield _sse( - "response.output_text.delta", - { - "type": "response.output_text.delta", - "item_id": message_state["item_id"], - "output_index": message_state["output_index"], - "content_index": 0, - "delta": full_text, - }, - ) close_items: list[tuple[int, str, dict[str, Any]]] = [] if reasoning_state["opened"]: diff --git a/studio/backend/tests/test_responses_tool_passthrough.py b/studio/backend/tests/test_responses_tool_passthrough.py index f9d029babe..582fcfbe87 100644 --- a/studio/backend/tests/test_responses_tool_passthrough.py +++ b/studio/backend/tests/test_responses_tool_passthrough.py @@ -61,6 +61,7 @@ from models.inference import ( from routes.inference import ( _build_chat_request, _chat_tool_calls_to_responses_output, + _extract_responses_reasoning, _normalise_responses_input, _responses_tool_output_content, _responses_non_streaming, @@ -782,6 +783,15 @@ class TestResponsesNonStreamingAdapter: assert "" not in body["output"][1]["content"][0]["text"] assert "" not in body["output"][1]["content"][0]["text"] + def test_unclosed_think_block_extracts_as_reasoning(self): + reasoning, visible = _extract_responses_reasoning( + "partial plan", + parse_think_markers = True, + ) + + assert reasoning == "partial plan" + assert visible == "" + def test_monitor_records_translated_visible_text(self, monkeypatch): import routes.inference as inf_mod @@ -1318,7 +1328,7 @@ class TestResponsesStreamAdapter: assert entry["status"] == "completed" assert entry["reply"] == "tail" - def test_reasoning_only_fallback_updates_monitor(self, monkeypatch): + def test_reasoning_only_stream_does_not_update_visible_monitor_reply(self, monkeypatch): import routes.inference as inf_mod class FakeExtractor: @@ -1359,10 +1369,11 @@ class TestResponsesStreamAdapter: lines = asyncio.run(run()) - assert self._payloads(lines, "response.output_text.delta")[-1]["delta"] == "plan" + assert self._payloads(lines, "response.output_text.delta") == [] + assert self._payloads(lines, "response.reasoning_text.delta")[-1]["delta"] == "plan" [entry] = monitor.snapshot() assert entry["status"] == "completed" - assert entry["reply"] == "plan" + assert entry["reply"] == "" def test_reasoning_capable_gguf_stream_parses_think_tags_by_default(self, monkeypatch): chunks = [ @@ -1418,7 +1429,7 @@ class TestResponsesStreamAdapter: "show x tags" ) - def test_reasoning_only_streams_as_visible_message_text(self, monkeypatch): + def test_reasoning_only_stream_stays_out_of_visible_message_text(self, monkeypatch): chunks = [ {"choices": [{"delta": {"content": "plan"}}]}, {"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}}, @@ -1436,14 +1447,34 @@ class TestResponsesStreamAdapter: reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta") text_deltas = self._payloads(lines, "response.output_text.delta") assert "".join(event["delta"] for event in reasoning_deltas) == "plan" - assert "".join(event["delta"] for event in text_deltas) == "plan" + assert text_deltas == [] completed = self._payloads(lines, "response.completed")[0] - assert [item["type"] for item in completed["response"]["output"]] == [ - "reasoning", - "message", - ] + assert [item["type"] for item in completed["response"]["output"]] == ["reasoning"] + assert completed["response"]["output"][0]["content"][0]["text"] == "plan" + + def test_unclosed_think_stream_stays_out_of_visible_message_text(self, monkeypatch): + chunks = [ + {"choices": [{"delta": {"content": "plan"}}]}, + {"choices": [], "usage": {"prompt_tokens": 2, "completion_tokens": 3}}, + ] + self._install_stream_mock(monkeypatch, chunks) + payload = ResponsesRequest(input = "hi", stream = True, reasoning = {"effort": "high"}) + messages = [ChatMessage(role = "user", content = "hi")] + + async def run(): + response = await _responses_stream(payload, messages, self._Request()) + return await self._collect(response) + + lines = asyncio.run(run()) + + reasoning_deltas = self._payloads(lines, "response.reasoning_text.delta") + text_deltas = self._payloads(lines, "response.output_text.delta") + assert "".join(event["delta"] for event in reasoning_deltas) == "plan" + assert text_deltas == [] + completed = self._payloads(lines, "response.completed")[0] + assert [item["type"] for item in completed["response"]["output"]] == ["reasoning"] assert completed["response"]["output"][0]["content"][0]["text"] == "plan" - assert completed["response"]["output"][1]["content"][0]["text"] == "plan" def test_structured_reasoning_content_streams_as_reasoning(self, monkeypatch): chunks = [