diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py
index eae705d20e..8d262bbb0f 100644
--- a/studio/backend/core/inference/inference.py
+++ b/studio/backend/core/inference/inference.py
@@ -888,6 +888,7 @@ class InferenceBackend:
thread_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
presence_penalty: float = 0.0,
+ reasoning_prefilled: bool = False,
):
"""Run an agentic tool loop on top of ``generate_chat_response``.
@@ -941,6 +942,7 @@ class InferenceBackend:
session_id = session_id,
thread_id = thread_id,
rag_scope = rag_scope,
+ reasoning_prefilled = reasoning_prefilled,
)
def generate_chat_response(
diff --git a/studio/backend/core/inference/orchestrator.py b/studio/backend/core/inference/orchestrator.py
index ea3b164052..3afda74411 100644
--- a/studio/backend/core/inference/orchestrator.py
+++ b/studio/backend/core/inference/orchestrator.py
@@ -1407,6 +1407,7 @@ class InferenceOrchestrator:
use_adapter: Optional[Union[bool, str]] = None,
stats_holder: Optional[dict] = None,
presence_penalty: float = 0.0,
+ reasoning_prefilled: bool = False,
**_unused,
):
"""Run the safetensors agentic tool loop in the parent process,
@@ -1487,6 +1488,7 @@ class InferenceOrchestrator:
confirm_tool_calls = confirm_tool_calls,
bypass_permissions = bypass_permissions,
permission_mode = permission_mode,
+ reasoning_prefilled = reasoning_prefilled,
)
def generate_with_adapter_control(
diff --git a/studio/backend/core/inference/safetensors_agentic.py b/studio/backend/core/inference/safetensors_agentic.py
index 43b72110ff..9110315815 100644
--- a/studio/backend/core/inference/safetensors_agentic.py
+++ b/studio/backend/core/inference/safetensors_agentic.py
@@ -50,6 +50,7 @@ from core.inference.tool_call_parser import (
# pattern lists, so the safetensors streaming strip stays aligned with the parser.
from core.tool_healing import (
_REHEARSAL_TAIL_STRIP_RE,
+ _THINK_CLOSE_RE,
_strip_bracket_tag_calls,
_think_spans_outside_tool_markup,
apply_tool_strip_patterns,
@@ -304,6 +305,45 @@ def _status_for_tool(tool_name: str, arguments: dict) -> str:
return status_for_tool(tool_name, arguments)
+def _reprompt_intent_text(text: str, *, reasoning_prefilled: bool = False) -> str:
+ """Return visible answer text for the plan-without-action classifier.
+
+ Safetensors reasoning shares the cumulative text channel with the answer.
+ Forward-looking phrases inside ```` / ``[THINK]`` are private
+ planning, not a user-visible promise to call a tool. Match GGUF's behavior:
+ classify visible content when present and fall back to reasoning only for a
+ reasoning-only stall.
+ """
+ prefilled_reasoning = ""
+ if reasoning_prefilled:
+ close = _THINK_CLOSE_RE.search(text)
+ if close is None:
+ return text.strip()
+ prefilled_reasoning = text[: close.end()].strip()
+ text = text[close.end() :].strip()
+ if not text:
+ return prefilled_reasoning
+
+ spans = _think_spans_outside_tool_markup(text)
+ if not spans:
+ return text.strip()
+
+ visible: list[str] = []
+ reasoning: list[str] = []
+ cursor = 0
+ for start, end in spans:
+ visible.append(text[cursor:start])
+ reasoning.append(text[start:end])
+ cursor = end
+ visible.append(text[cursor:])
+
+ visible_text = "".join(visible).strip()
+ reasoning_text = "".join(reasoning).strip()
+ if visible_text:
+ return visible_text
+ return "\n".join(part for part in (prefilled_reasoning, reasoning_text) if part).strip()
+
+
def _looks_like_enabled_bare_json(text: str, enabled_tool_names: Optional[set]) -> bool:
"""True when ``text`` opens with an ENABLED markerless bare-JSON call; an ordinary JSON answer returns False."""
probe = strip_llama3_leading_sentinels(text.lstrip())
@@ -448,6 +488,7 @@ def run_safetensors_tool_loop(
confirm_tool_calls: bool = False,
bypass_permissions: bool = False,
permission_mode: Optional[str] = None,
+ reasoning_prefilled: bool = False,
) -> Generator[dict, None, None]:
"""Drive an agentic tool loop on top of a cumulative-text generator.
@@ -956,7 +997,10 @@ def run_safetensors_tool_loop(
# (GGUF loop parity). The retry is gated on nudge_tool_calls so
# Studio callers (which send True) always nudge, while API callers
# who omit the flag keep today's no-reprompt behavior (opt-in).
- stripped_answer = content_accum.strip()
+ intent_text = _reprompt_intent_text(
+ content_accum,
+ reasoning_prefilled = reasoning_prefilled,
+ )
if (
auto_heal_tool_calls
and nudge_tool_calls
@@ -965,7 +1009,7 @@ def run_safetensors_tool_loop(
and not rag_autoinjected
and not tool_denied
and not any(record.executed for record in tool_controller.history)
- and is_short_intent_without_action(stripped_answer)
+ and is_short_intent_without_action(intent_text)
):
reprompt_count += 1
logger.info(
@@ -973,9 +1017,9 @@ def run_safetensors_tool_loop(
"calling tools (%d chars)",
reprompt_count,
MAX_ACT_REPROMPTS,
- len(stripped_answer),
+ len(intent_text),
)
- conversation.append({"role": "assistant", "content": stripped_answer})
+ conversation.append({"role": "assistant", "content": intent_text})
tool_hint = " or ".join(_active_tool_names(active_tools)) or "an available tool"
conversation.append(
{
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index d6090e2b62..52ea1f86a3 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -8886,6 +8886,7 @@ async def openai_chat_completions(
permission_mode = payload.permission_mode,
use_adapter = payload.use_adapter,
stats_holder = _sf_stats_holder,
+ reasoning_prefilled = _sf_reasoning_prefilled,
)
_sf_tool_sentinel = object()
diff --git a/studio/backend/tests/test_safetensors_tool_loop.py b/studio/backend/tests/test_safetensors_tool_loop.py
index e3633de289..915f82ac8e 100644
--- a/studio/backend/tests/test_safetensors_tool_loop.py
+++ b/studio/backend/tests/test_safetensors_tool_loop.py
@@ -3205,6 +3205,196 @@ class TestLoopBehaviour:
class TestLoopRePrompt:
"""Plan-without-action re-prompt parity with GGUF: nudge instead of terminating, up to ``MAX_ACT_REPROMPTS`` extra slots. Studio always nudges, so these drive the loop with ``nudge_tool_calls=True``."""
+ def test_reasoning_intent_does_not_reprompt_a_visible_answer(self):
+ generations = 0
+
+ def _gen(_messages, active_tools = None):
+ nonlocal generations
+ generations += 1
+ yield (
+ "Let me prepare the requested summary carefully."
+ "This is the final visible answer."
+ )
+
+ exec_fn = FakeExecuteTool([])
+ events = _collect_events(
+ run_safetensors_tool_loop(
+ single_turn = _gen,
+ messages = [{"role": "user", "content": "summarize this"}],
+ tools = [{"type": "function", "function": {"name": "web_search"}}],
+ execute_tool = exec_fn,
+ nudge_tool_calls = True,
+ )
+ )
+
+ assert generations == 1
+ assert exec_fn.calls == []
+ contents = [e["text"] for e in events if e["type"] == "content"]
+ assert contents[-1].endswith("This is the final visible answer.")
+
+ def test_prefilled_reasoning_intent_does_not_reprompt_a_visible_answer(self):
+ generations = 0
+
+ def _gen(_messages, active_tools = None):
+ nonlocal generations
+ generations += 1
+ yield "Let me prepare the requested summary carefully.This is the final visible answer."
+
+ exec_fn = FakeExecuteTool([])
+ events = _collect_events(
+ run_safetensors_tool_loop(
+ single_turn = _gen,
+ messages = [{"role": "user", "content": "summarize this"}],
+ tools = [{"type": "function", "function": {"name": "web_search"}}],
+ execute_tool = exec_fn,
+ nudge_tool_calls = True,
+ reasoning_prefilled = True,
+ )
+ )
+
+ assert generations == 1
+ assert exec_fn.calls == []
+ contents = [e["text"] for e in events if e["type"] == "content"]
+ assert contents[-1].endswith("This is the final visible answer.")
+
+ def test_prefilled_reasoning_with_reemitted_think_does_not_reprompt(self):
+ generations = 0
+
+ def _gen(_messages, active_tools = None):
+ nonlocal generations
+ generations += 1
+ yield (
+ "Let me prepare the requested summary carefully."
+ "more private planningThis is the final visible answer."
+ )
+
+ exec_fn = FakeExecuteTool([])
+ events = _collect_events(
+ run_safetensors_tool_loop(
+ single_turn = _gen,
+ messages = [{"role": "user", "content": "summarize this"}],
+ tools = [{"type": "function", "function": {"name": "web_search"}}],
+ execute_tool = exec_fn,
+ nudge_tool_calls = True,
+ reasoning_prefilled = True,
+ )
+ )
+
+ assert generations == 1
+ assert exec_fn.calls == []
+ contents = [e["text"] for e in events if e["type"] == "content"]
+ assert contents[-1].endswith("This is the final visible answer.")
+
+ def test_prefilled_reasoning_with_later_think_does_not_reprompt(self):
+ generations = 0
+
+ def _gen(_messages, active_tools = None):
+ nonlocal generations
+ generations += 1
+ yield (
+ "private prefilled planning"
+ "Let me prepare the requested summary carefully."
+ "This is the final visible answer."
+ )
+
+ exec_fn = FakeExecuteTool([])
+ events = _collect_events(
+ run_safetensors_tool_loop(
+ single_turn = _gen,
+ messages = [{"role": "user", "content": "summarize this"}],
+ tools = [{"type": "function", "function": {"name": "web_search"}}],
+ execute_tool = exec_fn,
+ nudge_tool_calls = True,
+ reasoning_prefilled = True,
+ )
+ )
+
+ assert generations == 1
+ assert exec_fn.calls == []
+ contents = [e["text"] for e in events if e["type"] == "content"]
+ assert contents[-1].endswith("This is the final visible answer.")
+
+ def test_reasoning_only_intent_still_reprompts_and_uses_a_tool(self):
+ loop, exec_fn = _make_loop(
+ turns = [
+ ["Let me search for that."],
+ ['{"name":"web_search","arguments":{"query":"cats"}}'],
+ ["Here is the answer."],
+ ],
+ exec_results = ["result"],
+ nudge_tool_calls = True,
+ )
+
+ events = _collect_events(loop)
+
+ assert exec_fn.calls == [("web_search", {"query": "cats"})]
+ contents = [e["text"] for e in events if e["type"] == "content"]
+ assert contents[-1] == "Here is the answer."
+
+ def test_prefilled_no_close_reasoning_intent_still_reprompts(self):
+ loop, exec_fn = _make_loop(
+ turns = [
+ ["I need more context.Let me search for that."],
+ ['{"name":"web_search","arguments":{"query":"cats"}}'],
+ ["Here is the answer."],
+ ],
+ exec_results = ["result"],
+ nudge_tool_calls = True,
+ reasoning_prefilled = True,
+ )
+
+ events = _collect_events(loop)
+
+ assert exec_fn.calls == [("web_search", {"query": "cats"})]
+ contents = [e["text"] for e in events if e["type"] == "content"]
+ assert contents[-1] == "Here is the answer."
+
+ def test_prefilled_reasoning_prefix_is_kept_for_reasoning_only_reprompt(self):
+ loop, exec_fn = _make_loop(
+ turns = [
+ ["Let me search for that.checking details"],
+ ['{"name":"web_search","arguments":{"query":"cats"}}'],
+ ["Here is the answer."],
+ ],
+ exec_results = ["result"],
+ nudge_tool_calls = True,
+ reasoning_prefilled = True,
+ )
+
+ events = _collect_events(loop)
+
+ assert exec_fn.calls == [("web_search", {"query": "cats"})]
+ contents = [e["text"] for e in events if e["type"] == "content"]
+ assert contents[-1] == "Here is the answer."
+
+ def test_reprompt_history_uses_visible_intent_text(self):
+ captured: list[list[dict]] = []
+
+ def _gen(messages, active_tools = None):
+ captured.append([dict(message) for message in messages])
+ if len(captured) == 1:
+ yield "private planning detailsLet me search for that."
+ elif len(captured) == 2:
+ yield '{"name":"web_search","arguments":{"query":"cats"}}'
+ else:
+ yield "Here is the answer."
+
+ exec_fn = FakeExecuteTool(["result"])
+ events = _collect_events(
+ run_safetensors_tool_loop(
+ single_turn = _gen,
+ messages = [{"role": "user", "content": "find cats"}],
+ tools = [{"type": "function", "function": {"name": "web_search"}}],
+ execute_tool = exec_fn,
+ nudge_tool_calls = True,
+ )
+ )
+
+ assert exec_fn.calls == [("web_search", {"query": "cats"})]
+ assert captured[1][1] == {"role": "assistant", "content": "Let me search for that."}
+ contents = [e["text"] for e in events if e["type"] == "content"]
+ assert contents[-1] == "Here is the answer."
+
def test_intent_signal_triggers_reprompt(self):
# Turn 1: intent signal, no tool call.
# Turn 2 (re-prompt): proper tool call -> executes.