fix(studio): ignore reasoning in tool reprompts (#7134)

This commit is contained in:
Long Yixing 2026-07-18 07:22:11 +08:00 committed by GitHub
commit 8ff2f8e70c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 243 additions and 4 deletions

View file

@ -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(

View file

@ -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(

View file

@ -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>`` / ``[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(
{

View file

@ -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()

View file

@ -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 (
"<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.