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:
parent
a0a3a7b24a
commit
570c804785
8 changed files with 243 additions and 6 deletions
|
|
@ -92,6 +92,7 @@ from utils.subprocess_compat import (
|
|||
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,
|
||||
NUDGE_TOOL_CALLS_STATUS as _NUDGE_TOOL_CALLS_STATUS,
|
||||
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,
|
||||
|
|
@ -12419,7 +12420,10 @@ class LlamaCppBackend:
|
|||
_it_r = _iter_timings or {}
|
||||
_accumulated_predicted_ms += _it_r.get("predicted_ms", 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": _NUDGE_TOOL_CALLS_STATUS}
|
||||
continue
|
||||
|
||||
if _forced_tool_call_pending:
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from core.inference.tool_call_parser import (
|
|||
_strip_mistral_reasoning,
|
||||
BUDGET_EXHAUSTED_NUDGE,
|
||||
MAX_ACT_REPROMPTS,
|
||||
NUDGE_TOOL_CALLS_STATUS,
|
||||
RAG_MAX_SEARCHES_PER_TURN,
|
||||
RAG_SEARCH_CAP_NUDGE,
|
||||
TOOL_XML_SIGNALS,
|
||||
|
|
@ -1032,9 +1033,10 @@ def run_safetensors_tool_loop(
|
|||
"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.
|
||||
# Blank first: it clears the badge and resets the route's per-turn
|
||||
# text cursor. The badge then shows the pause is a re-prompt, not a stall.
|
||||
yield {"type": "status", "text": ""}
|
||||
yield {"type": "status", "text": NUDGE_TOOL_CALLS_STATUS}
|
||||
continue
|
||||
|
||||
# Final answer. If a literal tool marker in prose was buffered but
|
||||
|
|
|
|||
|
|
@ -183,6 +183,9 @@ INTENT_SIGNAL = re.compile(
|
|||
# times since #5620); safetensors and MLX inherit the same cap from here.
|
||||
MAX_ACT_REPROMPTS = 3
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from core.inference.llama_cpp import (
|
|||
_PROVISIONAL_ARGS_MIN_CHARS,
|
||||
LlamaCppBackend,
|
||||
)
|
||||
from core.inference.tool_call_parser import NUDGE_TOOL_CALLS_STATUS
|
||||
from state import tool_approvals
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
streams = [
|
||||
_structured_tool_call("python", {"code": "print(1)"}, "call_py"),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from core.inference.safetensors_agentic import (
|
|||
strip_tool_markup_streaming,
|
||||
)
|
||||
from core.inference.tool_call_parser import (
|
||||
NUDGE_TOOL_CALLS_STATUS,
|
||||
RAG_MAX_SEARCHES_PER_TURN,
|
||||
has_tool_signal,
|
||||
parse_tool_calls_from_text,
|
||||
|
|
@ -2231,6 +2232,24 @@ def test_reprompt_names_only_active_tools_not_hardcoded():
|
|||
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():
|
||||
# With Auto-Heal off the safetensors nudge must stay silent for backend parity
|
||||
# with the GGUF loop, so only the single initial generation runs.
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ import {
|
|||
useResearchRunStore,
|
||||
} from "@/features/chat/stores/research-run-store";
|
||||
import { parseExternalModelId } from "@/features/chat/external-providers";
|
||||
import { toolStatusKind } from "@/features/chat/utils/tool-status";
|
||||
import { McpComposerButton } from "@/features/chat/mcp-composer-button";
|
||||
import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities";
|
||||
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.
|
||||
const elapsed = Math.max(0, Math.floor((now - startedAt) / 1000));
|
||||
const isRunning = toolStatus.startsWith("Running");
|
||||
const StatusIcon = isRunning ? TerminalIcon : GlobeIcon;
|
||||
const kind = toolStatusKind(toolStatus);
|
||||
const isNudging = kind === "nudge";
|
||||
const StatusIcon = kind === "terminal" ? TerminalIcon : GlobeIcon;
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<StatusIcon className="size-3.5" />
|
||||
<div
|
||||
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 className="tabular-nums opacity-60">{elapsed}s</span>
|
||||
</div>
|
||||
|
|
|
|||
15
studio/frontend/src/features/chat/utils/tool-status.ts
Normal file
15
studio/frontend/src/features/chat/utils/tool-status.ts
Normal 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";
|
||||
}
|
||||
45
studio/frontend/tests/tool-status.test.ts
Normal file
45
studio/frontend/tests/tool-status.test.ts
Normal 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");
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue