Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
38 changes: 25 additions & 13 deletions studio/backend/core/inference/studio_tool_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -1043,29 +1043,41 @@ async def stream_with_studio_tools(
# gets one nudge to actually do it, the same recovery the local loops
# give a stalled small model, then the answer stands as written.
visible_answer = "".join(turn.text)
# Classify the same text the retry replays, as the local loops do
# (_reprompt_intent_text). A turn that is nothing but an unpromotable call
# block strips to "", leaving no assistant turn to append and no promise to
# continue from, so nudging on the raw text replayed it as user -> user.
leading_whitespace = visible_answer[
: len(visible_answer) - len(visible_answer.lstrip())
]
replayable_answer = leading_whitespace + strip_tool_markup(
visible_answer,
final = True,
enabled_tool_names = allowed_tool_names,
Comment on lines +1054 to +1057

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve reasoning-only intent when classifying stalls

For external models that emit a reasoning-only stall using Magistral-style [THINK]...[/THINK] markup, strip_tool_markup(..., final=True) removes the entire block, so replayable_answer is empty and is_short_intent_without_action never triggers the enabled nudge. This differs from the referenced _reprompt_intent_text, which deliberately falls back to reasoning when there is no visible answer, and causes these models to stop after planning to use a tool instead of receiving the retry; classify with the reasoning-aware fallback while keeping the separately stripped text for replay.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed. My first probe made this look like a non-issue, because the string I tried classified False before stripping as well. Shorter phrasings do diverge:

raw    stripped   text
True   False      '[THINK]I will search now.[/THINK]'
True   False      '[THINK]Let me look that up.[/THINK]'
True   True       '<think>I will search now.</think>'

So the nudge really was being dropped for reasoning-only stalls that main still nudges.

I matched _reprompt_intent_text rather than only classifying differently: it classifies and replays the same string, and for a reasoning-only stall that string is the think block itself. Classifying the reasoning while replaying the stripped text would have put back the empty-replay case this PR exists to fix.

Ordering carries the correctness here. The fallback runs after strip_tool_markup, so a turn that is only an unpromotable call block has no think span, stays empty, and is still not nudged:

reasoning-only stall     nudge=True   replay='[THINK]I will search now.[/THINK]'
reasoning + answer       nudge=True   replay='I will search now.'
markup-only              nudge=False  replay=''
real answer              nudge=False

Both cases are now pinned by tests. The reasoning-only test fails without the change and passes with it (1 failed, 2 passed then 3 passed); the suite is 54 passed.

Fixed in 0abe73b.

)
if (
tools_available
and nudge_enabled(policy.nudge_tool_calls)
and not controller.force_final_answer
and reprompts < max_reprompts
and is_short_intent_without_action(visible_answer)
and not is_reprompt_repeat(visible_answer, last_reprompt_text)
and is_short_intent_without_action(replayable_answer)
and not is_reprompt_repeat(replayable_answer, last_reprompt_text)
):
reprompts += 1
last_reprompt_text = visible_answer
last_reprompt_text = replayable_answer
stalled_hosted = turn.hosted_replay_text()
if stalled_hosted:
# A hosted tool did run, the model just did not go on to ask
# for a local one. The replay below never happens on this
# path, so the reprompted request would be told to continue
# from output it can no longer see.
stalled_content = (
f"{replayable_answer}\n\n{stalled_hosted}"
if replayable_answer and stalled_hosted
else replayable_answer or stalled_hosted
)
if stalled_content:
# The retry must see the assistant turn it is being asked to
# continue from. Without this, plain prose stalls are replayed
# as user -> user and the provider answers from scratch.
stalled_message: dict[str, Any] = {
"role": "assistant",
"content": (
f"{visible_answer}\n\n{stalled_hosted}"
if visible_answer
else stalled_hosted
),
"content": stalled_content,
}
if turn.reasoning_extra:
# Gemini 3 stows the text part's thoughtSignature here
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@ async def gen():
)
assert entered["run"].model == "gpt-5.4-codex"
assert entered["policy"].nudge_tool_calls is False
# Codex emits structured calls, so text-form healing stays off while the
# plan-without-action nudge is still governed by the request flag above.
assert entered["policy"].auto_heal is False


def test_the_usage_chunk_falls_back_only_when_no_model_is_known():
Expand Down
35 changes: 35 additions & 0 deletions studio/backend/tests/test_external_tool_nudge_wiring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""Regression guards for external Studio tool-call nudging (#8907 follow-up)."""

import inspect
from pathlib import Path

from core.inference.studio_tool_loop import ToolLoopPolicy, stream_with_studio_tools


def test_external_tool_loop_accepts_and_gates_nudge_flag():
assert "nudge_tool_calls" in ToolLoopPolicy.__dataclass_fields__
src = inspect.getsource(stream_with_studio_tools)
assert "nudge_enabled(policy.nudge_tool_calls)" in src


def test_external_route_forwards_request_nudge_flag():
from routes import inference as routes_inference

external_src = inspect.getsource(routes_inference._proxy_to_external_provider)
assert external_src.count("nudge_tool_calls = payload.nudge_tool_calls") == 2
codex_policy = external_src.split("CodexToolPolicy(", 1)[1].split("if studio_tool_payloads", 1)[
0
]
assert "nudge_tool_calls = payload.nudge_tool_calls" in codex_policy


def test_frontend_forwards_nudge_setting_to_external_tools():
studio = Path(__file__).resolve().parents[2]
adapter = (studio / "frontend/src/features/chat/api/chat-adapter.ts").read_text(
encoding = "utf-8"
)
# One local-model request and one external local-tool request.
assert adapter.count("nudge_tool_calls: runtime.nudgeToolCalls") >= 2
80 changes: 75 additions & 5 deletions studio/backend/tests/test_studio_tool_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,13 +774,17 @@ def test_a_stalled_model_is_nudged_to_act(executed):
_run(transport, nudge_tool_calls = True)

assert [c["name"] for c in executed] == ["web_search"]
# The nudge is a user turn appended after the stall.
# The retry sees the assistant stall before the nudge, not user -> user.
second = transport.requests[1]["messages"]
assert second[-1]["role"] == "user"
assert [message["role"] for message in second] == ["user", "assistant", "user"]
assert second[-2]["content"] == "I'll search for that now."


def test_a_stalled_model_is_not_nudged_by_default(executed):
"""The external loop must not invent a retry for an omitted opt-in flag."""
def test_a_stalled_model_is_not_nudged_by_default(executed, monkeypatch):
"""An API caller that omits the opt-in must not get a hidden retry."""
from core.inference import passthrough_healing

monkeypatch.setattr(passthrough_healing, "_NUDGE_DEFAULT", False)
transport = FakeTransport(
[
[_sse({"content": "I'll search for that now."}), _sse(finish = "stop"), _DONE],
Expand All @@ -794,14 +798,41 @@ def test_a_stalled_model_is_not_nudged_by_default(executed):
assert "SHOULD NOT APPEAR" not in _visible_text(lines)


def test_a_stalled_model_respects_explicit_nudge_off(executed):
transport = FakeTransport(
[
[_sse({"content": "I'll search for that now."}), _sse(finish = "stop"), _DONE],
[_sse({"content": "SHOULD NOT APPEAR"}), _sse(finish = "stop"), _DONE],
]
)
_run(transport, nudge_tool_calls = False)

assert executed == []
assert len(transport.requests) == 1


def test_nudging_is_independent_of_text_form_healing(executed):
"""Codex emits structured calls, but still needs plan-without-action recovery."""
transport = FakeTransport(
[
[_sse({"content": "I'll search for that now."}), _sse(finish = "stop"), _DONE],
[_sse({"content": "done"}), _sse(finish = "stop"), _DONE],
],
heals = False,
)
_run(transport, auto_heal = False, nudge_tool_calls = True)

assert len(transport.requests) == 2


def test_a_finished_answer_is_not_nudged(executed):
"""A real answer must never be re-prompted into calling a tool."""
answer = (
"The capital of France is Paris, which has been the seat of government "
"since the tenth century and remains the largest city in the country."
)
transport = FakeTransport([[_sse({"content": answer}), _sse(finish = "stop"), _DONE]])
_run(transport)
_run(transport, nudge_tool_calls = True)

assert executed == []
assert len(transport.requests) == 1
Expand Down Expand Up @@ -913,6 +944,45 @@ def test_replayed_assistant_content_carries_no_markup(executed):
assert assistant.get("tool_calls")


def test_truncated_tool_markup_is_not_replayed_during_a_nudge(executed):
"""A length-truncated call is visible to the user, but not provider context."""
markup = '<tool_call>{"name": "web_search", "arguments": {"query": "x"}}</tool_call>'
transport = FakeTransport(
[
[_sse({"content": f"I'll search now. {markup}"}), _sse(finish = "length"), _DONE],
[_sse({"content": "done"}), _sse(finish = "stop"), _DONE],
]
)
_run(transport, nudge_tool_calls = True)

replayed = transport.requests[1]["messages"]
assistant = [m for m in replayed if m.get("role") == "assistant"][-1]
assert assistant["content"] == "I'll search now."


def test_a_markup_only_stall_is_not_nudged(executed):
"""Nothing to continue from, so the retry would have been user -> user.

An intent phrase buried in an unpromotable call block reads as a stall to the
classifier but strips to nothing, so there is no assistant turn to append and
the nudge would merge into the user's own message. Easiest to reach on a
transport that does not heal text-form calls, which is the Codex shape.
"""
markup = (
'<tool_call>{"name": "nope", "arguments": {"q": "I will look this up now"}}</tool_call>'
)
transport = FakeTransport(
[
[_sse({"content": markup}), _sse(finish = "stop"), _DONE],
[_sse({"content": "SHOULD NOT APPEAR"}), _sse(finish = "stop"), _DONE],
],
heals = False,
)
_run(transport, auto_heal = False, nudge_tool_calls = True)

assert len(transport.requests) == 1


def test_conversation_roles_stay_alternating_for_a_strict_server(executed):
"""A no-op only turn must not leave two user turns in a row."""
transport = FakeTransport(
Expand Down
4 changes: 2 additions & 2 deletions studio/frontend/src/features/chat/chat-settings-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1814,8 +1814,8 @@ function NudgeToolCallsToggle() {
Nudge Tool Calls
</span>
<InfoHint>
When a tool call cannot be repaired, re-ask the model once so the
intended tool still runs. API requests stay opt-in.
When a model stops after promising to use a tool, or its tool call
cannot be repaired, ask it to continue. API requests stay opt-in.
</InfoHint>
</div>
<Switch
Expand Down
5 changes: 3 additions & 2 deletions unsloth_cli/commands/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,9 @@ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
None,
"--enable-tool-call-nudging/--disable-tool-call-nudging",
rich_help_panel = _PANEL_SERVER,
help = "Retry once with a nudge when a non-streaming passthrough tool call can't be healed. "
"On by default; when the flag is omitted an inherited UNSLOTH_TOOL_CALL_NUDGE is kept.",
help = "Allow nudge retries when a tool-enabled model stops after promising to act, or a "
"passthrough tool signal can't be healed. On by default; when the flag is omitted an "
"inherited UNSLOTH_TOOL_CALL_NUDGE is kept, and an explicit request value wins.",
)
_REASONING_OPTION = typer.Option(
None,
Expand Down
6 changes: 3 additions & 3 deletions unsloth_cli/commands/studio.py
Original file line number Diff line number Diff line change
Expand Up @@ -2311,9 +2311,9 @@ def run(
"--enable-tool-call-nudging/--disable-tool-call-nudging",
rich_help_panel = _RUN_PANEL_TOOLS,
help = (
"On the non-streaming client-tool passthrough, retry once with a short "
"nudge when the model emitted a tool signal that healing could not repair. "
"Default: on. No effect on streaming requests or the server-side agentic loop."
"Allow nudge retries when a tool-enabled model stops after promising to act, "
"or when a passthrough tool signal cannot be repaired. Default: on. This sets "
"the process default; an explicit request value wins."
),
),
temperature: Optional[float] = typer.Option(
Expand Down
Loading