Studio: surface the tool-call nudge in the chat UI (#7559)

* Studio: show a Nudging tool calls badge while the tool-call re-prompt runs

* Guard the nudge status ordering assertion against index 0

* Tighten the nudge status comments

* Announce the nudge text instead of the generic spinner label

* Trim the nudge status comments

Collapse the multi-line notes to fewer lines and drop one that restated the assert below it. The blank-before-badge ordering reason and the keep-in-sync contract are preserved.

---------

Co-authored-by: danielhanchen <unslothai@gmail.com>
This commit is contained in:
Daniel Han 2026-07-28 18:20:50 -07:00 committed by GitHub
commit 570c804785
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 243 additions and 6 deletions

View file

@ -92,6 +92,7 @@ from utils.subprocess_compat import (
from utils.process_lifetime import child_popen_kwargs as _child_popen_kwargs from utils.process_lifetime import child_popen_kwargs as _child_popen_kwargs
from core.inference.tool_call_parser import ( from core.inference.tool_call_parser import (
MAX_ACT_REPROMPTS as _MAX_REPROMPTS, MAX_ACT_REPROMPTS as _MAX_REPROMPTS,
NUDGE_TOOL_CALLS_STATUS as _NUDGE_TOOL_CALLS_STATUS,
REPROMPT_MAX_CHARS as _REPROMPT_MAX_CHARS, REPROMPT_MAX_CHARS as _REPROMPT_MAX_CHARS,
is_short_intent_without_action as _is_short_intent_without_action, is_short_intent_without_action as _is_short_intent_without_action,
reprompt_to_act_message as _reprompt_to_act_message, reprompt_to_act_message as _reprompt_to_act_message,
@ -12419,7 +12420,10 @@ class LlamaCppBackend:
_it_r = _iter_timings or {} _it_r = _iter_timings or {}
_accumulated_predicted_ms += _it_r.get("predicted_ms", 0) _accumulated_predicted_ms += _it_r.get("predicted_ms", 0)
_accumulated_predicted_n += _it_r.get("predicted_n", 0) _accumulated_predicted_n += _it_r.get("predicted_n", 0)
# Blank first (the route resets its text cursor only on an
# empty status), then the badge so the retry is not a hang.
yield {"type": "status", "text": ""} yield {"type": "status", "text": ""}
yield {"type": "status", "text": _NUDGE_TOOL_CALLS_STATUS}
continue continue
if _forced_tool_call_pending: if _forced_tool_call_pending:

View file

@ -35,6 +35,7 @@ from core.inference.tool_call_parser import (
_strip_mistral_reasoning, _strip_mistral_reasoning,
BUDGET_EXHAUSTED_NUDGE, BUDGET_EXHAUSTED_NUDGE,
MAX_ACT_REPROMPTS, MAX_ACT_REPROMPTS,
NUDGE_TOOL_CALLS_STATUS,
RAG_MAX_SEARCHES_PER_TURN, RAG_MAX_SEARCHES_PER_TURN,
RAG_SEARCH_CAP_NUDGE, RAG_SEARCH_CAP_NUDGE,
TOOL_XML_SIGNALS, TOOL_XML_SIGNALS,
@ -1032,9 +1033,10 @@ def run_safetensors_tool_loop(
"content": reprompt_to_act_message(tool_hint), "content": reprompt_to_act_message(tool_hint),
} }
) )
# Empty status clears the badge and resets the route's # Blank first: it clears the badge and resets the route's per-turn
# per-turn text cursor before the re-prompted turn streams. # text cursor. The badge then shows the pause is a re-prompt, not a stall.
yield {"type": "status", "text": ""} yield {"type": "status", "text": ""}
yield {"type": "status", "text": NUDGE_TOOL_CALLS_STATUS}
continue continue
# Final answer. If a literal tool marker in prose was buffered but # Final answer. If a literal tool marker in prose was buffered but

View file

@ -183,6 +183,9 @@ INTENT_SIGNAL = re.compile(
# times since #5620); safetensors and MLX inherit the same cap from here. # times since #5620); safetensors and MLX inherit the same cap from here.
MAX_ACT_REPROMPTS = 3 MAX_ACT_REPROMPTS = 3
REPROMPT_MAX_CHARS = 2000 REPROMPT_MAX_CHARS = 2000
# Composer badge while a hidden re-prompted turn regenerates, else the UI looks
# hung. Matched exactly by the frontend (utils/tool-status.ts); keep in sync.
NUDGE_TOOL_CALLS_STATUS = "Nudging tool calls"
def is_short_intent_without_action(text: str) -> bool: def is_short_intent_without_action(text: str) -> bool:

View file

@ -26,6 +26,7 @@ from core.inference.llama_cpp import (
_PROVISIONAL_ARGS_MIN_CHARS, _PROVISIONAL_ARGS_MIN_CHARS,
LlamaCppBackend, LlamaCppBackend,
) )
from core.inference.tool_call_parser import NUDGE_TOOL_CALLS_STATUS
from state import tool_approvals from state import tool_approvals
from state.tool_approvals import TOOL_REJECTED_MESSAGE, resolve_tool_decision from state.tool_approvals import TOOL_REJECTED_MESSAGE, resolve_tool_decision
@ -1841,6 +1842,140 @@ def test_reprompted_tool_call_still_streams_final_answer(monkeypatch):
assert len(payloads) == 3 assert len(payloads) == 3
def _status_texts(events: list[dict]) -> list[str]:
return [event["text"] for event in events if event.get("type") == "status"]
_WEB_SEARCH_TOOL = {
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}
def _nudge_then_search_streams() -> list[list[str]]:
"""Stall, then a re-prompted turn that finally searches, then the answer."""
return [
[_sse({"content": "I will search the web now."}), _done()],
[
_sse(
{
"tool_calls": [
{
"index": 0,
"id": "call_search",
"type": "function",
"function": {
"name": "web_search",
"arguments": json.dumps({"query": "red square"}),
},
}
]
}
),
_done(),
],
[_sse({"content": "Final answer: the square is red."}), _done()],
]
def test_plan_without_action_nudge_is_announced_on_the_status_channel(monkeypatch):
"""The re-prompted turn is hidden, so without a badge the UI looks frozen."""
payloads: list[dict] = []
backend = _make_backend(monkeypatch, _nudge_then_search_streams(), payloads)
monkeypatch.setattr(
"core.inference.tools.execute_tool",
lambda *_a, **_k: "Search results: red is #f00.",
)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "What colour is the square?"}],
tools = [_WEB_SEARCH_TOOL],
max_tool_iterations = 2,
)
)
statuses = _status_texts(events)
assert NUDGE_TOOL_CALLS_STATUS in statuses
index = statuses.index(NUDGE_TOOL_CALLS_STATUS)
# Blank first: the route resets its text cursor only on an empty status.
# index > 0 matters: at 0, statuses[-1] wraps to the terminal clear.
assert index > 0 and statuses[index - 1] == ""
assert statuses[index + 1].startswith("Searching:")
assert statuses[-1] == ""
def test_plan_without_action_nudge_status_clears_when_the_retry_just_answers(monkeypatch):
streams = [
[_sse({"content": "I will search the web now."}), _done()],
[_sse({"content": "No search needed. Final answer: the square is red."}), _done()],
]
payloads: list[dict] = []
backend = _make_backend(monkeypatch, streams, payloads)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "What colour is the square?"}],
tools = [_WEB_SEARCH_TOOL],
max_tool_iterations = 2,
)
)
statuses = _status_texts(events)
assert NUDGE_TOOL_CALLS_STATUS in statuses
assert statuses[-1] == ""
def test_direct_answer_never_shows_the_nudge_status(monkeypatch):
payloads: list[dict] = []
backend = _make_backend(
monkeypatch,
[[_sse({"content": "The square is red."}), _done()]],
payloads,
)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "What colour is the square?"}],
tools = [_WEB_SEARCH_TOOL],
max_tool_iterations = 2,
)
)
assert NUDGE_TOOL_CALLS_STATUS not in _status_texts(events)
def test_nudge_status_absent_when_nudging_is_disabled(monkeypatch):
payloads: list[dict] = []
backend = _make_backend(monkeypatch, _nudge_then_search_streams(), payloads)
monkeypatch.setattr(
"core.inference.tools.execute_tool",
lambda *_a, **_k: "Search results: red is #f00.",
)
events = list(
backend.generate_chat_completion_with_tools(
messages = [{"role": "user", "content": "What colour is the square?"}],
tools = [_WEB_SEARCH_TOOL],
max_tool_iterations = 2,
nudge_tool_calls = False,
)
)
assert NUDGE_TOOL_CALLS_STATUS not in _status_texts(events)
assert len(payloads) == 1
def test_confirm_tool_calls_allow_executes_gguf_tool(monkeypatch): def test_confirm_tool_calls_allow_executes_gguf_tool(monkeypatch):
streams = [ streams = [
_structured_tool_call("python", {"code": "print(1)"}, "call_py"), _structured_tool_call("python", {"code": "print(1)"}, "call_py"),

View file

@ -24,6 +24,7 @@ from core.inference.safetensors_agentic import (
strip_tool_markup_streaming, strip_tool_markup_streaming,
) )
from core.inference.tool_call_parser import ( from core.inference.tool_call_parser import (
NUDGE_TOOL_CALLS_STATUS,
RAG_MAX_SEARCHES_PER_TURN, RAG_MAX_SEARCHES_PER_TURN,
has_tool_signal, has_tool_signal,
parse_tool_calls_from_text, parse_tool_calls_from_text,
@ -2231,6 +2232,24 @@ def test_reprompt_names_only_active_tools_not_hardcoded():
assert "python" not in reprompt["content"] assert "python" not in reprompt["content"]
def test_reprompt_is_announced_on_the_status_channel():
# The re-prompted turn is hidden, so the badge is the only sign of life.
# Blank still comes first: the route resets its text cursor only on that.
_captured, events = _reprompt_loop(auto_heal_tool_calls = True)
statuses = [e["text"] for e in events if e["type"] == "status"]
assert NUDGE_TOOL_CALLS_STATUS in statuses
index = statuses.index(NUDGE_TOOL_CALLS_STATUS)
# index > 0 matters: at 0, statuses[-1] wraps to the terminal clear.
assert index > 0 and statuses[index - 1] == ""
assert statuses[-1] == ""
def test_reprompt_status_absent_without_a_nudge():
_captured, events = _reprompt_loop(auto_heal_tool_calls = False)
statuses = [e["text"] for e in events if e["type"] == "status"]
assert NUDGE_TOOL_CALLS_STATUS not in statuses
def test_reprompt_suppressed_when_auto_heal_disabled(): def test_reprompt_suppressed_when_auto_heal_disabled():
# With Auto-Heal off the safetensors nudge must stay silent for backend parity # With Auto-Heal off the safetensors nudge must stay silent for backend parity
# with the GGUF loop, so only the single initial generation runs. # with the GGUF loop, so only the single initial generation runs.

View file

@ -91,6 +91,7 @@ import {
useResearchRunStore, useResearchRunStore,
} from "@/features/chat/stores/research-run-store"; } from "@/features/chat/stores/research-run-store";
import { parseExternalModelId } from "@/features/chat/external-providers"; import { parseExternalModelId } from "@/features/chat/external-providers";
import { toolStatusKind } from "@/features/chat/utils/tool-status";
import { McpComposerButton } from "@/features/chat/mcp-composer-button"; import { McpComposerButton } from "@/features/chat/mcp-composer-button";
import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities"; import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities";
import { useRagToolDisabled } from "@/features/chat/hooks/use-rag-tool-disabled"; import { useRagToolDisabled } from "@/features/chat/hooks/use-rag-tool-disabled";
@ -2847,15 +2848,28 @@ const ToolStatusDisplay: FC = () => {
} }
// From the store's start time, so returning to the conversation resumes rather than restarting. // From the store's start time, so returning to the conversation resumes rather than restarting.
const elapsed = Math.max(0, Math.floor((now - startedAt) / 1000)); const elapsed = Math.max(0, Math.floor((now - startedAt) / 1000));
const isRunning = toolStatus.startsWith("Running"); const kind = toolStatusKind(toolStatus);
const StatusIcon = isRunning ? TerminalIcon : GlobeIcon; const isNudging = kind === "nudge";
const StatusIcon = kind === "terminal" ? TerminalIcon : GlobeIcon;
return ( return (
<div <div
data-testid="composer-tool-status" data-testid="composer-tool-status"
className="mb-2 flex w-full flex-row items-center gap-2 px-1.5 pt-0.5 pb-1" className="mb-2 flex w-full flex-row items-center gap-2 px-1.5 pt-0.5 pb-1"
> >
<div className="flex animate-pulse items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1.5 text-xs text-primary"> <div
<StatusIcon className="size-3.5" /> className={cn(
"flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1.5 text-xs text-primary",
// The spinner is its own motion cue; pulsing too just fades it mid-spin.
!isNudging && "animate-pulse",
)}
>
{isNudging ? (
// label, not the default "Loading": the spinner is the badge's only
// role="status" region, so its name is what gets announced.
<Spinner className="size-3.5" label={toolStatus} />
) : (
<StatusIcon className="size-3.5" />
)}
<span>{toolStatus}</span> <span>{toolStatus}</span>
<span className="tabular-nums opacity-60">{elapsed}s</span> <span className="tabular-nums opacity-60">{elapsed}s</span>
</div> </div>

View file

@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/** Mirrors NUDGE_TOOL_CALLS_STATUS in backend core/inference/tool_call_parser.py; keep in sync. */
export const NUDGE_TOOL_CALLS_STATUS = "Nudging tool calls";
export type ToolStatusKind = "nudge" | "terminal" | "web";
/** Which glyph the badge shows: exact match for the nudge, "Running" prefix for sandbox tools, globe otherwise. */
export function toolStatusKind(status: string): ToolStatusKind {
if (status === NUDGE_TOOL_CALLS_STATUS) {
return "nudge";
}
return status.startsWith("Running") ? "terminal" : "web";
}

View file

@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import assert from "node:assert/strict";
import test from "node:test";
import {
NUDGE_TOOL_CALLS_STATUS,
toolStatusKind,
} from "../src/features/chat/utils/tool-status.ts";
test("the nudge status is the exact string the backend sends", () => {
// Mirrors tool_call_parser.py, so a reword on either side must break here.
assert.equal(NUDGE_TOOL_CALLS_STATUS, "Nudging tool calls");
assert.equal(toolStatusKind(NUDGE_TOOL_CALLS_STATUS), "nudge");
});
test("sandbox tools keep the terminal glyph", () => {
for (const status of [
"Running Python: print(1)",
"Running Python...",
"Running: ls -la",
"Running command...",
]) {
assert.equal(toolStatusKind(status), "terminal", status);
}
});
test("every other status keeps the globe", () => {
for (const status of [
"Searching: red square",
"Reading: unsloth.ai",
"Reading page...",
"Searching documents: quarterly report",
"Calling: get_weather",
]) {
assert.equal(toolStatusKind(status), "web", status);
}
});
test("a status that merely mentions nudging is not the nudge itself", () => {
// Exact match only: a tool named after the phrase must not steal the spinner.
assert.equal(toolStatusKind("Calling: Nudging tool calls"), "web");
assert.equal(toolStatusKind("Nudging tool calls again"), "web");
});