Studio chat: tool-call nudging on by default (API stays opt-in) (#6883)

* Studio chat: tool-call nudging on by default (API stays opt-in)

Healing is already default-on everywhere and the nudge retry from the
client-tool passthrough is opt-in on the API. Studio chat had neither
signal: the frontend never sent nudge_tool_calls, and the safetensors
and MLX server-side loop lacked the GGUF loop's plan-without-action
re-prompt entirely.

Backend: the re-prompt helpers move from llama_cpp.py into
tool_call_parser.py (shared, cycle-free; the GGUF loop imports them
under its old names with zero behavior change) and
run_safetensors_tool_loop now re-prompts once at the streaming
no-tool-call exit, gated on Auto-Heal, active tools, nothing executed
yet, and short forward-looking text. Re-prompts do not consume tool
iterations.

Frontend: the chat adapter sends nudge_tool_calls from a new
nudgeToolCalls runtime setting (default true) with the same
persistence, hydration, and settings toggle plumbing as Auto-Heal.
Request-model defaults are untouched, so raw API callers stay opt-in.

* Address review: persist the nudge setting, consume the flag in the loops, skip the re-prompt after RAG autoinject

ChatSettingsPayload uses extra forbid, so a settings patch containing
nudgeToolCalls failed to persist any settings; the field is now typed
and round-trips. nudge_tool_calls now plumbs into both server-side tool
loops and gates the plan-without-action re-prompt with None meaning on,
so API callers keep today's behavior, explicit false disables it, and
Studio's default-on flag actually controls the path Studio chat runs.
The safetensors loop no longer re-prompts after RAG autoinject: the
injected retrieval bypasses the tool controller, so the nothing-executed
gate saw an empty history and re-asked after a successful retrieval.

* Safetensors loop: the plan-without-action retry requires an explicit nudge flag

The retry is new on this loop, so an omitted nudge_tool_calls must not
change existing API behavior; Studio opts in explicitly. The GGUF loop
keeps None as on because its re-prompt predates the flag.

* Suppress the plan-without-action re-prompt after a denied tool confirmation

A denial appends TOOL_REJECTED_MESSAGE but records nothing in the tool
controller history, so the nothing-executed gate re-prompted the model
to call the tool the user had just rejected, producing another
confirmation prompt. A denial now suppresses the re-prompt for the rest
of the request, mirroring the RAG autoinject handling.

* Tighten plan-without-action re-prompt comments

* Tighten plan-without-action re-prompt comments

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: match unified plan-without-action nudge cap to GGUF default of 3

The shared MAX_ACT_REPROMPTS was set to 1, but GGUF's established default
(llama_cpp.py) has re-prompted a stalling model up to 3 times since #5620.
Restore the GGUF-matched cap so safetensors and MLX inherit the same
behavior, and update the safetensors cap test to assert the cap dynamically.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-07-06 19:41:19 -07:00 committed by GitHub
parent c2a7b78f6b
commit 9dabe96786
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 507 additions and 83 deletions

View file

@ -828,6 +828,7 @@ class InferenceBackend:
preserve_thinking: Optional[bool] = None,
max_tool_iterations: int = 25,
auto_heal_tool_calls: bool = True,
nudge_tool_calls: Optional[bool] = None,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
@ -877,6 +878,7 @@ class InferenceBackend:
execute_tool = execute_tool,
cancel_event = cancel_event,
auto_heal_tool_calls = auto_heal_tool_calls,
nudge_tool_calls = nudge_tool_calls,
max_tool_iterations = max_tool_iterations,
tool_call_timeout = tool_call_timeout,
session_id = session_id,

View file

@ -75,6 +75,12 @@ from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
from utils.process_lifetime import child_popen_kwargs as _child_popen_kwargs
from core.inference.tool_call_parser import (
MAX_ACT_REPROMPTS as _MAX_REPROMPTS,
REPROMPT_MAX_CHARS as _REPROMPT_MAX_CHARS,
is_short_intent_without_action as _is_short_intent_without_action,
reprompt_to_act_message as _reprompt_to_act_message,
)
from core.inference.tool_loop_controller import (
ToolLoopController,
tool_event_provenance,
@ -223,25 +229,8 @@ def _wsl_system_rocm_lib_dirs() -> "list[str]":
return out
# ── Pre-compiled patterns for plan-without-action re-prompt ──
# Forward-looking intent signals: the model is describing what it *will*
# do rather than giving a final answer.
_INTENT_SIGNAL = re.compile(
r"(?i)("
# Direct intent ("I'll ...", "Let me ...", straight + curly apostrophes).
# Excludes "I can"/"I should"/"I want to"/"let's" (common in answers).
# Negative lookahead drops negated forms ("I will not") so a refusal
# doesn't trigger a re-prompt.
r"\b(i['\u2019](ll|m going to|m gonna)|i am (going to|gonna)|i will|i shall|let me|allow me)\b(?!\s+(?:not|never)\b)"
r"|"
# Step/plan framing: "First ...", "Step 1:", "Here's my plan"
r"\b(?:first\b|step \d+:?|here['\u2019]?s (?:my |the |a )?(?:plan|approach))"
r"|"
# "Now I" / "Next I" patterns
r"\b(?:now i|next i)\b"
r")"
)
_MAX_REPROMPTS = 3
# Plan-without-action re-prompt state (intent signal, caps, message) now lives
# in tool_call_parser, imported above under its old aliases.
# Default max_tokens to the effective context when known. The floor is high
# enough for reasoning-heavy GGUFs and max_tokens-omitting API clients.
@ -252,7 +241,6 @@ _DEFAULT_FIRST_TOKEN_TIMEOUT_S = 1200.0 # 20 min
# is exempt because it needs immediate artifact feedback.
_PROVISIONAL_ARGS_MIN_CHARS = 256
_DEFAULT_STREAM_STALL_TIMEOUT_S = 120.0 # 2 min
_REPROMPT_MAX_CHARS = 2000
# Cap tool calls from a single TEXTUAL-fallback turn (mirrors the safetensors
# loop). Structured delta.tool_calls are grammar-bounded by llama-server; text
# parsed from content is not, so one runaway turn could fan out unbounded.
@ -333,11 +321,6 @@ def _held_rehearsal_tail_len(text: str, active_tools: list[dict]) -> int:
return len(tail) if tail and _is_rehearsal_prefix(tail, active_tools) else 0
def _is_short_intent_without_action(text: str) -> bool:
stripped = text.strip()
return 0 < len(stripped) < _REPROMPT_MAX_CHARS and _INTENT_SIGNAL.search(stripped) is not None
def _should_suppress_forced_no_tool_output(text: str) -> bool:
"""Suppress only repeated forced-turn planning text, not final answers."""
stripped = text.strip()
@ -8456,6 +8439,7 @@ class LlamaCppBackend:
preserve_thinking: Optional[bool] = None,
max_tool_iterations: int = 25,
auto_heal_tool_calls: bool = True,
nudge_tool_calls: Optional[bool] = None,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
@ -8626,11 +8610,9 @@ class LlamaCppBackend:
_kb_search_count = 0
# ── Re-prompt on plan-without-action ─────────────────
# When the model describes what it intends to do (forward-looking
# language) without calling a tool, re-prompt once. Only triggers on
# responses signaling intent/planning -- a direct answer like "4" or
# "Hello!" won't match. Pattern compiled at module level
# (_INTENT_SIGNAL).
# Model describes intent without calling a tool: re-prompt once. A
# direct answer ("4", "Hello!") won't match. Pattern shared with the
# safetensors loop (tool_call_parser.INTENT_SIGNAL).
_reprompt_count = 0
# Gates ``max_tool_iterations`` on real tool turns (not the enlarged range) so reserved
# re-prompt slots don't extend the budget. Mirrors the safetensors guard.
@ -9153,8 +9135,10 @@ class LlamaCppBackend:
r"(?i)\brender[_\s-]?html\b",
_stripped,
)
# None keeps the default-on re-prompt; False disables it.
if (
auto_heal_tool_calls
and (nudge_tool_calls is None or nudge_tool_calls)
and active_tools
and not _render_html_already_done_intent
and _reprompt_count < _MAX_REPROMPTS
@ -9183,12 +9167,7 @@ class LlamaCppBackend:
conversation.append(
{
"role": "user",
"content": (
"You have access to enabled tools. If a tool is needed to satisfy "
"the user's request or complete the action you described, call "
f"{tool_hint} now. If no tool is needed, provide the final answer "
"and follow the user's requested format."
),
"content": _reprompt_to_act_message(tool_hint),
}
)
# Accumulate tokens and timing from this iteration.

View file

@ -945,6 +945,7 @@ class InferenceOrchestrator:
preserve_thinking: Optional[bool] = None,
max_tool_iterations: int = 25,
auto_heal_tool_calls: bool = True,
nudge_tool_calls: Optional[bool] = None,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
@ -1007,6 +1008,7 @@ class InferenceOrchestrator:
execute_tool = execute_tool,
cancel_event = cancel_event,
auto_heal_tool_calls = auto_heal_tool_calls,
nudge_tool_calls = nudge_tool_calls,
max_tool_iterations = max_tool_iterations,
tool_call_timeout = tool_call_timeout,
session_id = session_id,

View file

@ -33,10 +33,13 @@ from core.inference.tool_call_parser import (
_strip_mistral_closed_calls,
_strip_mistral_reasoning,
BUDGET_EXHAUSTED_NUDGE,
MAX_ACT_REPROMPTS,
RAG_MAX_SEARCHES_PER_TURN,
RAG_SEARCH_CAP_NUDGE,
TOOL_XML_SIGNALS,
is_short_intent_without_action,
parse_tool_calls_from_text,
reprompt_to_act_message,
strip_leading_bare_json_call,
strip_llama3_leading_sentinels,
strip_tool_markup,
@ -75,21 +78,6 @@ _MAX_BUFFER_CHARS = 32
# Memory bound for holding a leading bare-JSON object whose top-level "{" never balances.
_MAX_BARE_JSON_BUFFER = 16384
# Forward-looking intent ("I'll", "First,", "Step 1:") = planning, not answering; nudge a call.
# Negative lookahead drops negated forms ("I will not") so a refusal doesn't trigger it. Mirrors GGUF.
_INTENT_SIGNAL = re.compile(
r"(?i)("
r"\b(i['](ll|m going to|m gonna)|i am (going to|gonna)|i will|i shall|let me|allow me)\b(?!\s+(?:not|never)\b)"
r"|\b(?:first\b|step \d+:?|here[']?s (?:my |the |a )?(?:plan|approach))"
r"|\b(?:now i|next i)\b"
r")"
)
_MAX_REPROMPTS = 3
_REPROMPT_MAX_CHARS = 2000
# Templated so the nudge names the caller's enabled tools, not a hardcoded set. Mirrors GGUF tool_hint.
_REPROMPT_INSTRUCTION_TEMPLATE = (
"STOP. Do NOT write code or explain. You MUST call a tool NOW. Call {tool_hint} immediately."
)
# No grammar constraint here (unlike llama-server's lazy grammar): collapse
# exact-duplicate calls and cap the count so a runaway turn cannot fan out.
@ -432,6 +420,7 @@ def run_safetensors_tool_loop(
execute_tool: Callable[..., str],
cancel_event: Optional[threading.Event] = None,
auto_heal_tool_calls: bool = True,
nudge_tool_calls: Optional[bool] = None,
max_tool_iterations: int = 25,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
@ -471,6 +460,9 @@ def run_safetensors_tool_loop(
for _ev in _auto["events"]:
yield _ev
conversation.extend(_auto["messages"])
# Autoinject ran a KB search outside the controller, so it counts as an
# executed tool for the plan-without-action gate.
rag_autoinjected = bool(_auto)
unrestricted_tools = not tools
# Gate telling a genuine NAME[ARGS] rehearsal from inactive-name prose; built from the
@ -488,6 +480,9 @@ def run_safetensors_tool_loop(
final_attempt_done = False
next_call_id = 0
reprompt_count = 0
# A denied tool confirmation must not be answered with a plan-without-action
# re-prompt (which would raise the confirmation gate again).
tool_denied = False
# Real tool-call turns completed. Only turns that actually executed a tool count
# against ``max_tool_iterations``; a duplicate/disabled no-op correction turn (and a
# plan-without-action re-prompt) must not consume budget, matching the GGUF loop.
@ -510,7 +505,7 @@ def run_safetensors_tool_loop(
_state_draining = 2
# Reserve re-prompt slots so they don't eat the caller's tool budget.
_extra_iters = _MAX_REPROMPTS if max_tool_iterations > 0 else 0
_extra_iters = MAX_ACT_REPROMPTS if max_tool_iterations > 0 else 0
for iteration in range(max_tool_iterations + _extra_iters + 1):
if cancel_event is not None and cancel_event.is_set():
return
@ -869,33 +864,39 @@ def run_safetensors_tool_loop(
enabled_tool_names = _enabled_tool_names,
)
if not safety_tc:
# Re-prompt only when the model planned without acting (intent
# signal); "4" / "Hello!" never trigger. Mirrors GGUF.
_stripped = content_accum.strip()
# Re-prompt once on plan-without-action, before any tool runs
# (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()
if (
tools
and auto_heal_tool_calls
and reprompt_count < _MAX_REPROMPTS
and 0 < len(_stripped) < _REPROMPT_MAX_CHARS
and _INTENT_SIGNAL.search(_stripped)
and not final_attempt_done
auto_heal_tool_calls
and nudge_tool_calls
and active_tools
and reprompt_count < MAX_ACT_REPROMPTS
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)
):
reprompt_count += 1
logger.info(
"Safetensors re-prompt %d/%d: model planned without "
"Safetensors re-prompt %d/%d: model responded without "
"calling tools (%d chars)",
reprompt_count,
_MAX_REPROMPTS,
len(_stripped),
MAX_ACT_REPROMPTS,
len(stripped_answer),
)
conversation.append({"role": "assistant", "content": stripped_answer})
tool_hint = " or ".join(_active_tool_names(active_tools)) or "an available tool"
conversation.append({"role": "assistant", "content": _stripped})
conversation.append(
{
"role": "user",
"content": _REPROMPT_INSTRUCTION_TEMPLATE.format(tool_hint = tool_hint),
"content": reprompt_to_act_message(tool_hint),
}
)
# Empty status clears the badge and resets the route's
# per-turn text cursor before the re-prompted turn streams.
yield {"type": "status", "text": ""}
continue
@ -1085,6 +1086,7 @@ def run_safetensors_tool_loop(
"result": TOOL_REJECTED_MESSAGE,
"provenance": decision.provenance,
}
tool_denied = True
denied_message = {
"role": "tool",
"name": decision.tool_name,

View file

@ -163,6 +163,41 @@ RAG_SEARCH_CAP_NUDGE = (
)
# ── Plan-without-action re-prompt (shared by the GGUF and safetensors loops) ──
# Forward-looking intent: the model says what it *will* do, not a final answer.
INTENT_SIGNAL = re.compile(
r"(?i)("
# Direct intent ("I'll", "Let me"); lookahead drops negated forms
# ("I will not") so a refusal does not re-prompt.
r"\b(i['\u2019](ll|m going to|m gonna)|i am (going to|gonna)|i will|i shall|let me|allow me)\b(?!\s+(?:not|never)\b)"
r"|"
# Step/plan framing: "First ...", "Step 1:", "Here's my plan"
r"\b(?:first\b|step \d+:?|here['\u2019]?s (?:my |the |a )?(?:plan|approach))"
r"|"
r"\b(?:now i|next i)\b"
r")"
)
# Matches GGUF's established default (llama_cpp.py has re-prompted up to 3
# times since #5620); safetensors and MLX inherit the same cap from here.
MAX_ACT_REPROMPTS = 3
REPROMPT_MAX_CHARS = 2000
def is_short_intent_without_action(text: str) -> bool:
stripped = text.strip()
return 0 < len(stripped) < REPROMPT_MAX_CHARS and INTENT_SIGNAL.search(stripped) is not None
def reprompt_to_act_message(tool_hint: str) -> str:
"""The user message appended when re-prompting a plan-without-action turn."""
return (
"You have access to enabled tools. If a tool is needed to satisfy "
"the user's request or complete the action you described, call "
f"{tool_hint} now. If no tool is needed, provide the final answer "
"and follow the user's requested format."
)
# Qwen / Hermes ``<tool_call>{json}``.
_TC_JSON_START_RE = re.compile(r"<tool_call>\s*\{")
# Qwen3.5 ``<function=name>`` and the attribute form ``<function name="name">``

View file

@ -177,6 +177,7 @@ class ChatSettingsPayload(BaseModel):
collapseHtmlArtifacts: Optional[bool] = None
allowArtifactNetworkAccess: Optional[bool] = None
autoHealToolCalls: Optional[bool] = None
nudgeToolCalls: Optional[bool] = None
maxToolCallsPerMessage: Optional[int] = Field(default = None, ge = 1)
toolCallTimeout: Optional[int] = Field(default = None, ge = 1)

View file

@ -6162,6 +6162,7 @@ async def openai_chat_completions(
reasoning_effort = payload.reasoning_effort,
preserve_thinking = payload.preserve_thinking,
auto_heal_tool_calls = _gguf_auto_heal_tool_calls,
nudge_tool_calls = payload.nudge_tool_calls,
max_tool_iterations = payload.max_tool_calls_per_message
if payload.max_tool_calls_per_message is not None
else 25,
@ -6888,6 +6889,7 @@ async def openai_chat_completions(
reasoning_effort = payload.reasoning_effort,
preserve_thinking = payload.preserve_thinking,
auto_heal_tool_calls = _sf_auto_heal_tool_calls,
nudge_tool_calls = payload.nudge_tool_calls,
max_tool_iterations = _sf_tool_budget,
tool_call_timeout = payload.tool_call_timeout
if payload.tool_call_timeout is not None
@ -10074,6 +10076,7 @@ async def anthropic_messages(
cancel_event = cancel_event,
max_tool_iterations = 25,
auto_heal_tool_calls = True,
nudge_tool_calls = payload.nudge_tool_calls,
tool_call_timeout = 300,
session_id = payload.session_id,
# Anthropic passthrough has no rag_scope field (RAG is local-only).

View file

@ -91,6 +91,17 @@ def test_chat_settings_payload_accepts_fast_mode_presets():
assert dumped["customPresets"][0]["params"]["fastMode"] is True
def test_chat_settings_payload_accepts_nudge_tool_calls():
# extra="forbid" 400s PUT /api/chat/settings on unknown keys, so the
# frontend's persisted nudgeToolCalls needs a payload field (like
# autoHealToolCalls).
payload = chat_history.ChatSettingsPayload.model_validate(
{"autoHealToolCalls": True, "nudgeToolCalls": False}
)
dumped = payload.model_dump(exclude_unset = True)
assert dumped == {"autoHealToolCalls": True, "nudgeToolCalls": False}
def test_chat_inference_settings_covers_frontend_persisted_fields():
# Drift guard: every InferenceParams field the UI persists (all but
# checkpoint) must exist on ChatInferenceSettings, else extra="forbid"

View file

@ -1168,6 +1168,48 @@ def test_internal_reprompt_disabled_when_auto_heal_disabled(monkeypatch):
assert len(payloads) == 1
def test_internal_reprompt_disabled_when_nudge_tool_calls_false(monkeypatch):
# Explicit nudge_tool_calls=False disables the plan-without-action
# re-prompt even with Auto-Heal on (None keeps the default-on behavior).
streams = [[_sse({"content": "I will use render_html now."}), _done()]]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, streams, payloads)
def fake_execute_tool(name, arguments, **_kwargs):
raise AssertionError(f"unexpected tool execution: {name} {arguments}")
monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
tools = [
{
"type": "function",
"function": {
"name": "render_html",
"description": "Render HTML.",
"parameters": {
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
},
},
}
]
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "Make a red square."}],
tools = tools,
max_tool_iterations = 1,
auto_heal_tool_calls = True,
nudge_tool_calls = False,
)
)
content_texts = [event.get("text", "") for event in events if event.get("type") == "content"]
assert content_texts == ["I will use render_html now."]
assert len(payloads) == 1
def test_auto_heal_disabled_parses_well_formed_xml_when_tools_enabled(monkeypatch):
streams = [
[

View file

@ -0,0 +1,88 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Wiring guard for the plan-without-action ``nudge_tool_calls`` policy.
Decided policy: the re-prompt is ALWAYS ON for the Studio inference paths
(safetensors, GGUF/llama_cpp, MLX) and OPT-IN for the API (/v1 OpenAI-compat +
Anthropic-compat, controlled by the request's ``nudge_tool_calls``, default off).
Mechanism (verified here without loading a model):
* every backend tool-loop entry point accepts and forwards ``nudge_tool_calls``
(safetensors -> ``InferenceBackend``; MLX -> ``InferenceOrchestrator``; both
call the shared ``run_safetensors_tool_loop``; GGUF -> ``LlamaCppBackend``);
* the safetensors/MLX loop gates the retry on a truthy flag (new retry ->
opt-in), while the GGUF loop keeps its pre-existing default-on behaviour
(``None`` keeps nudging) so an omitted flag never disables GGUF;
* the API request models default the flag to ``None`` (opt-in / off);
* the Studio-facing routes forward the request's flag, and the Studio frontend
sends ``nudge_tool_calls: true`` -- exercised behaviourally in
``test_safetensors_tool_loop.py`` and ``test_llama_cpp_tool_loop.py``.
"""
import inspect
from core.inference.inference import InferenceBackend
from core.inference.llama_cpp import LlamaCppBackend
from core.inference.orchestrator import InferenceOrchestrator
from core.inference.safetensors_agentic import run_safetensors_tool_loop
def _params(fn):
return inspect.signature(fn).parameters
def test_shared_loop_accepts_nudge_flag():
assert "nudge_tool_calls" in _params(run_safetensors_tool_loop)
def test_all_three_backends_accept_the_flag():
for method in (
InferenceBackend.generate_chat_completion_with_tools,
InferenceOrchestrator.generate_chat_completion_with_tools,
LlamaCppBackend.generate_chat_completion_with_tools,
):
assert "nudge_tool_calls" in _params(method), method.__qualname__
def test_delegating_backends_forward_the_flag_to_the_shared_loop():
# safetensors (in-process transformers) and MLX (parent-process orchestrator)
# both delegate to run_safetensors_tool_loop; GGUF runs its own in-file loop
# and consumes the flag directly (asserted separately by the gate test).
for method in (
InferenceBackend.generate_chat_completion_with_tools,
InferenceOrchestrator.generate_chat_completion_with_tools,
):
src = inspect.getsource(method)
assert "nudge_tool_calls = nudge_tool_calls" in src, method.__qualname__
def test_safetensors_loop_is_opt_in_while_gguf_stays_default_on():
# Safetensors/MLX: the retry is new here, so it requires a truthy flag.
sf_src = inspect.getsource(run_safetensors_tool_loop)
assert "and nudge_tool_calls" in sf_src
# GGUF: pre-existing nudge must not be accidentally disabled -- an omitted
# (None) flag keeps nudging; only an explicit False turns it off.
gguf_src = inspect.getsource(LlamaCppBackend.generate_chat_completion_with_tools)
assert "nudge_tool_calls is None or nudge_tool_calls" in gguf_src
def test_api_request_models_default_the_flag_off():
from models.inference import AnthropicMessagesRequest, ChatCompletionRequest
for model in (ChatCompletionRequest, AnthropicMessagesRequest):
field = model.model_fields["nudge_tool_calls"]
assert field.default is None, model.__name__
def test_studio_routes_forward_the_request_flag():
# The Studio chat frontend posts to /v1/chat/completions and /v1/messages
# with nudge_tool_calls=true; the route handlers forward the request value
# (external API clients that omit it fall back to the opt-in default).
from routes import inference as routes_inference
for handler in (
routes_inference.openai_chat_completions,
routes_inference.anthropic_messages,
):
src = inspect.getsource(handler)
assert "nudge_tool_calls = payload.nudge_tool_calls" in src, handler.__name__

View file

@ -2229,6 +2229,9 @@ def _reprompt_loop(*, auto_heal_tool_calls):
tools = [{"type": "function", "function": {"name": "search_knowledge_base"}}],
execute_tool = exec_fn,
auto_heal_tool_calls = auto_heal_tool_calls,
# Studio always nudges (always-on for the Studio inference paths); the
# API opts in per request. Model the Studio caller here.
nudge_tool_calls = True,
max_tool_iterations = 3,
)
)
@ -3100,7 +3103,7 @@ class TestLoopBehaviour:
class TestLoopRePrompt:
"""Plan-without-action re-prompt parity with GGUF: nudge instead of terminating, up to ``_MAX_REPROMPTS`` extra slots."""
"""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_intent_signal_triggers_reprompt(self):
# Turn 1: intent signal, no tool call.
@ -3116,6 +3119,7 @@ class TestLoopRePrompt:
["The sky is blue."],
],
exec_results = ["Blue (Rayleigh scattering)"],
nudge_tool_calls = True,
)
events = _collect_events(loop)
# web_search must have been called once (after the re-prompt).
@ -3159,13 +3163,14 @@ class TestLoopRePrompt:
contents = [e for e in events if e["type"] == "content"]
assert contents and contents[-1]["text"].strip() == "4"
def test_max_reprompts_capped_at_three(self):
# Model keeps stalling with intent -- after 3 re-prompts the
# loop must give up rather than burn forever.
def test_max_reprompts_capped(self):
# Model keeps stalling with intent -- after MAX_ACT_REPROMPTS re-prompts
# the loop must give up rather than burn forever.
turns = [["Let me search for that."]] * 6 # well over the cap
loop, exec_fn = _make_loop(
turns = turns,
exec_results = [],
nudge_tool_calls = True,
)
events = _collect_events(loop, max_events = 500)
# No tool ever ran, but the loop terminated cleanly.
@ -3184,6 +3189,7 @@ class TestLoopRePrompt:
["found"],
],
exec_results = ["..."],
nudge_tool_calls = True,
)
events = _collect_events(loop)
assert exec_fn.calls == [("web_search", {"query": "x"})]
@ -3194,7 +3200,7 @@ class TestLoopRePrompt:
# re-prompt ate the slot the tool call would never run.
loop, exec_fn = _make_loop(
turns = [
# 1. Intent stall (re-prompt 1/3).
# 1. Intent stall (re-prompt).
["Let me search for that."],
# 2. Real tool call (uses the budget slot).
['<tool_call>{"name":"web_search","arguments":{"query":"weather"}}</tool_call>'],
@ -3203,6 +3209,7 @@ class TestLoopRePrompt:
],
exec_results = ["sunny"],
max_tool_iterations = 1,
nudge_tool_calls = True,
)
events = _collect_events(loop)
assert exec_fn.calls == [("web_search", {"query": "weather"})]
@ -3305,13 +3312,22 @@ class TestGGUFSafetensorsHealingParity:
}
def test_intent_regex_matches_same_phrases_as_gguf(self):
# The intent re-prompt regex must match the SAME forward-looking
# phrases on both backends so behaviour is the same on Mac (MLX
# / safetensors) and on Linux (GGUF).
from core.inference.llama_cpp import _INTENT_SIGNAL as gguf_re
from core.inference.safetensors_agentic import (
_INTENT_SIGNAL as sf_re,
# The intent re-prompt regex is now a single shared source of truth
# (tool_call_parser.INTENT_SIGNAL) consumed by both the GGUF and the
# safetensors/MLX loops, so behaviour is identical on Mac and Linux.
# Both backends must resolve to that one shared helper.
from core.inference.llama_cpp import (
_is_short_intent_without_action as gguf_fn,
)
from core.inference.safetensors_agentic import (
is_short_intent_without_action as sf_fn,
)
from core.inference.tool_call_parser import (
INTENT_SIGNAL as shared_re,
is_short_intent_without_action as shared_fn,
)
assert gguf_fn is shared_fn and sf_fn is shared_fn
for phrase in (
"I'll search for that",
@ -3322,8 +3338,8 @@ class TestGGUFSafetensorsHealingParity:
"Here's my plan",
"Now I need to call web_search",
):
assert gguf_re.search(phrase), f"GGUF missed {phrase!r}"
assert sf_re.search(phrase), f"safetensors missed {phrase!r}"
assert shared_re.search(phrase), f"missed {phrase!r}"
assert shared_fn(phrase), f"helper missed {phrase!r}"
for plain in (
"4",
@ -3337,13 +3353,16 @@ class TestGGUFSafetensorsHealingParity:
"I will not search the web for that.",
"I'll never call that tool.",
):
assert not gguf_re.search(plain), f"GGUF wrongly fired on {plain!r}"
assert not sf_re.search(plain), f"safetensors wrongly fired on {plain!r}"
assert not shared_re.search(plain), f"wrongly fired on {plain!r}"
assert not shared_fn(plain), f"helper wrongly fired on {plain!r}"
def test_max_reprompts_equal_on_both_backends(self):
# Both loops draw the cap from the shared constant, so they stay equal.
from core.inference.llama_cpp import _MAX_REPROMPTS as gguf_cap
from core.inference.safetensors_agentic import _MAX_REPROMPTS as sf_cap
assert gguf_cap == sf_cap == 3
from core.inference.safetensors_agentic import MAX_ACT_REPROMPTS as sf_cap
from core.inference.tool_call_parser import MAX_ACT_REPROMPTS as shared_cap
assert gguf_cap == sf_cap == shared_cap
class TestLoopControl:
@ -3822,6 +3841,193 @@ class TestGptOssNameDetection:
assert is_gpt_oss_model_name(cast(str, None)) is False
# ────────────────────────────────────────────────────────────────────
# Plan-without-action re-prompt (GGUF loop parity)
# ────────────────────────────────────────────────────────────────────
class TestPlanWithoutActionReprompt:
def test_short_intent_is_reprompted_and_tool_executes(self):
loop, exec_fn = _make_loop(
turns = [
["I'll search the web for that."],
['<tool_call>{"name":"web_search","arguments":{"query":"cats"}}</tool_call>'],
["Here is the final answer."],
],
exec_results = ["result-1"],
nudge_tool_calls = True,
)
events = _collect_events(loop)
assert [c[0] for c in exec_fn.calls] == ["web_search"]
texts = [e["text"] for e in events if e["type"] == "content"]
assert any("Here is the final answer." in t for t in texts)
def test_reprompt_fires_up_to_the_cap(self):
# GGUF parity: a persistently stalling model is re-prompted up to
# MAX_ACT_REPROMPTS times, then the last stall is surrendered as the
# final answer and no further turn is generated.
from core.inference.tool_call_parser import MAX_ACT_REPROMPTS
stall = "Let me look into it first."
turns = [["I'll search the web for that."]]
turns += [[stall]] * MAX_ACT_REPROMPTS
turns += [["SHOULD NOT APPEAR"]]
generations = {"count": 0}
turn_iter = iter(turns)
def _gen(_messages):
generations["count"] += 1
try:
chunks = next(turn_iter)
except StopIteration:
return
acc = ""
for c in chunks:
acc += c
yield acc
exec_fn = FakeExecuteTool([])
loop = run_safetensors_tool_loop(
single_turn = _gen,
messages = [{"role": "user", "content": "hi"}],
tools = [{"type": "function", "function": {"name": "web_search"}}],
execute_tool = exec_fn,
nudge_tool_calls = True,
)
events = _collect_events(loop)
assert exec_fn.calls == []
# One initial turn plus exactly MAX_ACT_REPROMPTS re-prompted turns.
assert generations["count"] == MAX_ACT_REPROMPTS + 1
texts = [e["text"] for e in events if e["type"] == "content"]
assert any(stall in t for t in texts)
assert not any("SHOULD NOT APPEAR" in t for t in texts)
def test_long_prose_answer_is_not_reprompted(self):
long_answer = "I'll keep explaining the details of the topic. " * 60
loop, exec_fn = _make_loop(
turns = [
[long_answer],
["SHOULD NOT APPEAR"],
],
nudge_tool_calls = True,
)
events = _collect_events(loop)
assert exec_fn.calls == []
texts = [e["text"] for e in events if e["type"] == "content"]
assert not any("SHOULD NOT APPEAR" in t for t in texts)
def test_disabled_auto_heal_is_not_reprompted(self):
loop, exec_fn = _make_loop(
turns = [
["I'll search the web for that."],
["SHOULD NOT APPEAR"],
],
auto_heal_tool_calls = False,
nudge_tool_calls = True,
)
events = _collect_events(loop)
assert exec_fn.calls == []
texts = [e["text"] for e in events if e["type"] == "content"]
assert any("I'll search the web for that." in t for t in texts)
assert not any("SHOULD NOT APPEAR" in t for t in texts)
def test_explicit_nudge_off_is_not_reprompted(self):
loop, exec_fn = _make_loop(
turns = [
["I'll search the web for that."],
["SHOULD NOT APPEAR"],
],
nudge_tool_calls = False,
)
events = _collect_events(loop)
assert exec_fn.calls == []
texts = [e["text"] for e in events if e["type"] == "content"]
assert any("I'll search the web for that." in t for t in texts)
assert not any("SHOULD NOT APPEAR" in t for t in texts)
def test_omitted_nudge_flag_is_not_reprompted(self):
# The retry is new on this loop: API callers who do not send the flag
# must keep today's behavior. Studio opts in explicitly.
loop, exec_fn = _make_loop(
turns = [
["I'll search the web for that."],
["SHOULD NOT APPEAR"],
],
)
events = _collect_events(loop)
assert exec_fn.calls == []
texts = [e["text"] for e in events if e["type"] == "content"]
assert any("I'll search the web for that." in t for t in texts)
assert not any("SHOULD NOT APPEAR" in t for t in texts)
def test_rag_autoinject_counts_as_executed_tool(self, monkeypatch):
# Autoinject already ran a KB search outside the controller; a short
# post-retrieval intent must not trigger a spurious re-prompt.
import core.inference.tools as tools_mod
def fake_autoinject(conversation, rag_scope):
return {
"events": [
{"type": "tool_start", "tool_name": "search_knowledge_base"},
{"type": "tool_end", "tool_name": "search_knowledge_base"},
],
"messages": [{"role": "tool", "content": "kb result"}],
}
monkeypatch.setattr(tools_mod, "build_rag_autoinject", fake_autoinject)
loop, exec_fn = _make_loop(
turns = [
["I'll search the docs."],
["SHOULD NOT APPEAR"],
],
nudge_tool_calls = True,
)
events = _collect_events(loop)
assert exec_fn.calls == []
assert any(e.get("type") == "tool_start" for e in events)
texts = [e["text"] for e in events if e["type"] == "content"]
assert any("I'll search the docs." in t for t in texts)
assert not any("SHOULD NOT APPEAR" in t for t in texts)
def test_no_reprompt_after_a_denied_tool_confirmation(self, monkeypatch):
# An explicit user denial must not be answered with a nudge to call
# the tool again (which would raise another confirmation prompt).
monkeypatch.setattr(safetensors_agentic, "new_approval_id", lambda: "appr-1")
monkeypatch.setattr(safetensors_agentic, "begin_tool_decision", lambda *_a, **_k: object())
monkeypatch.setattr(safetensors_agentic, "wait_tool_decision", lambda *_a, **_k: "deny")
loop, exec_fn = _make_loop(
turns = [
['<tool_call>{"name":"web_search","arguments":{"query":"cats"}}</tool_call>'],
["I'll search again."],
["SHOULD NOT APPEAR"],
],
confirm_tool_calls = True,
session_id = "sess",
nudge_tool_calls = True,
)
events = _collect_events(loop)
assert exec_fn.calls == []
texts = [e["text"] for e in events if e["type"] == "content"]
assert any("I'll search again." in t for t in texts)
assert not any("SHOULD NOT APPEAR" in t for t in texts)
def test_no_reprompt_after_a_tool_already_executed(self):
loop, exec_fn = _make_loop(
turns = [
['<tool_call>{"name":"web_search","arguments":{"query":"cats"}}</tool_call>'],
["Now I'll refine the search."],
["SHOULD NOT APPEAR"],
],
exec_results = ["result-1"],
nudge_tool_calls = True,
)
events = _collect_events(loop)
assert [c[0] for c in exec_fn.calls] == ["web_search"]
texts = [e["text"] for e in events if e["type"] == "content"]
assert not any("SHOULD NOT APPEAR" in t for t in texts)
# Routes-level python_tag strip (multi-line; stop on next sentinel)
class TestRoutesPythonTagStrip:
"""``_TOOL_XML_RE`` must consume multi-line code, embedded JSON, and bare ``<`` (earlier ``[^\n<]*`` / ``[^\n]*`` revisions leaked tails); the streaming route-level strip is the regression-prone path."""

View file

@ -2739,6 +2739,7 @@ export function createOpenAIStreamAdapter(
: {}),
auto_heal_tool_calls:
useChatRuntimeStore.getState().autoHealToolCalls,
nudge_tool_calls: useChatRuntimeStore.getState().nudgeToolCalls,
max_tool_calls_per_message:
useChatRuntimeStore.getState().maxToolCallsPerMessage,
tool_call_timeout: (() => {

View file

@ -26,6 +26,7 @@ export interface PersistedChatSettings {
collapseHtmlArtifacts?: boolean;
allowArtifactNetworkAccess?: boolean;
autoHealToolCalls?: boolean;
nudgeToolCalls?: boolean;
maxToolCallsPerMessage?: number;
toolCallTimeout?: number;
}

View file

@ -1732,6 +1732,7 @@ export function ChatSettingsPanel({
<CollapsibleSection label="Tools">
<div className="flex flex-col gap-5 pt-1">
<AutoHealToolCallsToggle />
<NudgeToolCallsToggle />
<ConfirmToolCallsToggle />
<BypassPermissionsToggle />
<MaxToolCallsSlider />
@ -2006,6 +2007,30 @@ function AutoHealToolCallsToggle() {
);
}
function NudgeToolCallsToggle() {
const nudgeToolCalls = useChatRuntimeStore((s) => s.nudgeToolCalls);
const setNudgeToolCalls = useChatRuntimeStore((s) => s.setNudgeToolCalls);
return (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
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.
</InfoHint>
</div>
<Switch
className="panel-switch"
checked={nudgeToolCalls}
onCheckedChange={setNudgeToolCalls}
/>
</div>
);
}
function ConfirmToolCallsToggle() {
const confirmToolCalls = useChatRuntimeStore((s) => s.confirmToolCalls);
const setConfirmToolCalls = useChatRuntimeStore((s) => s.setConfirmToolCalls);

View file

@ -646,6 +646,7 @@ type ChatRuntimeStore = {
toolStatus: string | null;
generatingStatus: string | null;
autoHealToolCalls: boolean;
nudgeToolCalls: boolean;
maxToolCallsPerMessage: number;
toolCallTimeout: number;
kvCacheDtype: string | null;
@ -780,6 +781,7 @@ type ChatRuntimeStore = {
setGeneratingStatus: (status: string | null) => void;
setActiveDiffusionCanvas: (canvas: DiffusionCanvasFrame | null) => void;
setAutoHealToolCalls: (enabled: boolean) => void;
setNudgeToolCalls: (enabled: boolean) => void;
setMaxToolCallsPerMessage: (value: number) => void;
setToolCallTimeout: (value: number) => void;
setKvCacheDtype: (dtype: string | null) => void;
@ -832,6 +834,7 @@ type ScalarSettingKey =
| "collapseHtmlArtifacts"
| "allowArtifactNetworkAccess"
| "autoHealToolCalls"
| "nudgeToolCalls"
| "maxToolCallsPerMessage"
| "toolCallTimeout";
@ -869,6 +872,7 @@ const SCALAR_SETTING_KEYS = [
"collapseHtmlArtifacts",
"allowArtifactNetworkAccess",
"autoHealToolCalls",
"nudgeToolCalls",
"maxToolCallsPerMessage",
"toolCallTimeout",
] as const satisfies readonly ScalarSettingKey[];
@ -1103,6 +1107,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
generatingStatus: null,
activeDiffusionCanvas: null,
autoHealToolCalls: true,
nudgeToolCalls: true,
maxToolCallsPerMessage: 25,
toolCallTimeout: 5,
kvCacheDtype: null,
@ -1544,6 +1549,15 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
);
return { autoHealToolCalls };
}),
setNudgeToolCalls: (nudgeToolCalls) =>
set((state) => {
setScalarSettingVersion(
"nudgeToolCalls",
nudgeToolCalls,
state.nudgeToolCalls,
);
return { nudgeToolCalls };
}),
setMaxToolCallsPerMessage: (maxToolCallsPerMessage) =>
set((state) => {
setScalarSettingVersion(

View file

@ -357,6 +357,7 @@ export interface OpenAIChatCompletionsRequest {
context_length?: number;
};
auto_heal_tool_calls?: boolean;
nudge_tool_calls?: boolean;
max_tool_calls_per_message?: number;
tool_call_timeout?: number;
session_id?: string;

View file

@ -21,6 +21,7 @@ import type { ReasoningEffort } from "../stores/chat-runtime-store";
const AUTO_TITLE_KEY = "unsloth_chat_auto_title";
const AUTO_HEAL_TOOL_CALLS_KEY = "unsloth_auto_heal_tool_calls";
const NUDGE_TOOL_CALLS_KEY = "unsloth_nudge_tool_calls";
const MAX_TOOL_CALLS_KEY = "unsloth_max_tool_calls_per_message";
const TOOL_CALL_TIMEOUT_KEY = "unsloth_tool_call_timeout";
const INFERENCE_PARAMS_KEY = "unsloth_chat_inference_params";
@ -223,6 +224,7 @@ function sanitizeChatSettings(value: unknown): PersistedChatSettings {
value.allowArtifactNetworkAccess,
);
const autoHealToolCalls = sanitizeBool(value.autoHealToolCalls);
const nudgeToolCalls = sanitizeBool(value.nudgeToolCalls);
const maxToolCallsPerMessage = sanitizeInt(value.maxToolCallsPerMessage, 1);
const toolCallTimeout = sanitizeInt(value.toolCallTimeout, 1);
@ -245,6 +247,9 @@ function sanitizeChatSettings(value: unknown): PersistedChatSettings {
if (autoHealToolCalls !== undefined) {
settings.autoHealToolCalls = autoHealToolCalls;
}
if (nudgeToolCalls !== undefined) {
settings.nudgeToolCalls = nudgeToolCalls;
}
if (maxToolCallsPerMessage !== undefined) {
settings.maxToolCallsPerMessage = maxToolCallsPerMessage;
}
@ -305,6 +310,7 @@ export function isEmptyChatSettings(settings: PersistedChatSettings): boolean {
settings.collapseHtmlArtifacts === undefined &&
settings.allowArtifactNetworkAccess === undefined &&
settings.autoHealToolCalls === undefined &&
settings.nudgeToolCalls === undefined &&
settings.maxToolCallsPerMessage === undefined &&
settings.toolCallTimeout === undefined
);
@ -335,6 +341,7 @@ export function loadLegacyChatSettings(): PersistedChatSettings {
const collapseHtmlArtifacts = loadBool(COLLAPSE_HTML_ARTIFACTS_KEY);
const allowArtifactNetworkAccess = loadBool(ALLOW_ARTIFACT_NETWORK_ACCESS_KEY);
const autoHealToolCalls = loadBool(AUTO_HEAL_TOOL_CALLS_KEY);
const nudgeToolCalls = loadBool(NUDGE_TOOL_CALLS_KEY);
const maxToolCallsPerMessage = loadInt(MAX_TOOL_CALLS_KEY, 1);
const toolCallTimeout = loadInt(TOOL_CALL_TIMEOUT_KEY, 1);
const allCustomPresets = sanitizeCustomPresets([
@ -361,6 +368,9 @@ export function loadLegacyChatSettings(): PersistedChatSettings {
if (autoHealToolCalls !== undefined) {
settings.autoHealToolCalls = autoHealToolCalls;
}
if (nudgeToolCalls !== undefined) {
settings.nudgeToolCalls = nudgeToolCalls;
}
if (maxToolCallsPerMessage !== undefined) {
settings.maxToolCallsPerMessage = maxToolCallsPerMessage;
}

View file

@ -78,6 +78,7 @@ const PREFS_KEYS: string[] = [
"unsloth_chat_auto_title",
"unsloth_hf_token",
"unsloth_auto_heal_tool_calls",
"unsloth_nudge_tool_calls",
"unsloth_max_tool_calls_per_message",
"unsloth_tool_call_timeout",
"unsloth_chat_inference_params",