Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions studio/backend/core/inference/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,7 @@ def generate_chat_completion_with_tools(
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``.

Expand Down Expand Up @@ -941,6 +942,7 @@ def _single_turn(conv: list, *, active_tools: Optional[list[dict]] = None):
session_id = session_id,
thread_id = thread_id,
rag_scope = rag_scope,
reasoning_prefilled = reasoning_prefilled,
)

def generate_chat_response(
Expand Down
2 changes: 2 additions & 0 deletions studio/backend/core/inference/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,7 @@ def generate_chat_completion_with_tools(
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,
Expand Down Expand Up @@ -1487,6 +1488,7 @@ def _single_turn(conv: list, *, active_tools: Optional[list[dict]] = None):
confirm_tool_calls = confirm_tool_calls,
bypass_permissions = bypass_permissions,
permission_mode = permission_mode,
reasoning_prefilled = reasoning_prefilled,
)

def generate_with_adapter_control(
Expand Down
52 changes: 48 additions & 4 deletions studio/backend/core/inference/safetensors_agentic.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
# 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,
Expand Down Expand Up @@ -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>`` / ``[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())
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -956,7 +997,10 @@ def _tool_succeeded(tool_name: str) -> bool:
# (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
Expand All @@ -965,17 +1009,17 @@ def _tool_succeeded(tool_name: str) -> bool:
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(
"Safetensors re-prompt %d/%d: model responded without "
"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(
{
Expand Down
1 change: 1 addition & 0 deletions studio/backend/routes/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -8886,6 +8886,7 @@ def sf_generate_with_tools():
permission_mode = payload.permission_mode,
use_adapter = payload.use_adapter,
stats_holder = _sf_stats_holder,
reasoning_prefilled = _sf_reasoning_prefilled,
)

_sf_tool_sentinel = object()
Expand Down
190 changes: 190 additions & 0 deletions studio/backend/tests/test_safetensors_tool_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -3205,6 +3205,196 @@ def test_exception_in_executor_does_not_raise(self):
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 (
"<think>Let me prepare the requested summary carefully.</think>"
"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.</think>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."
"<think>more private planning</think>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_later_think_does_not_reprompt(self):
generations = 0

def _gen(_messages, active_tools = None):
nonlocal generations
generations += 1
yield (
"private prefilled planning</think>"
"<think>Let me prepare the requested summary carefully.</think>"
"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 = [
["<think>Let me search for that.</think>"],
['<tool_call>{"name":"web_search","arguments":{"query":"cats"}}</tool_call>'],
["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.<think>Let me search for that."],
['<tool_call>{"name":"web_search","arguments":{"query":"cats"}}</tool_call>'],
["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.</think><think>checking details</think>"],
['<tool_call>{"name":"web_search","arguments":{"query":"cats"}}</tool_call>'],
["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 "<think>private planning details</think>Let me search for that."
elif len(captured) == 2:
yield '<tool_call>{"name":"web_search","arguments":{"query":"cats"}}</tool_call>'
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.
Expand Down
Loading