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 = [