fix(studio): ignore reasoning in tool reprompts (#7134)
This commit is contained in:
parent
5441266e3a
commit
8ff2f8e70c
5 changed files with 243 additions and 4 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue