diff --git a/agent/display.py b/agent/display.py index 861d84bc410..060ac1266fa 100644 --- a/agent/display.py +++ b/agent/display.py @@ -537,6 +537,122 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - return preview +# ========================================================================= +# Friendly tool labels (human-phrased verbs for built-in tools) +# +# Turns "web_search " into "Searching the web for " — the +# ChatGPT-style "Searching…/Reading…" surface. Curated and built-in only: +# we know each core tool's semantics, so the verb is fixed, not computed. +# Custom/plugin/MCP tools have no entry and fall back to the raw preview. +# ========================================================================= + +# Each entry maps a built-in tool name to its present-participle verb phrase. +# A trailing space-then-preview is appended by build_tool_label() when the +# tool's argument preview is available (e.g. "Reading docs/api.md"). +_TOOL_VERBS: dict[str, str] = { + "web_search": "Searching the web", + "web_extract": "Reading", + "browser_navigate": "Browsing", + "browser_click": "Clicking", + "browser_type": "Typing", + "read_file": "Reading", + "write_file": "Writing", + "patch": "Editing", + "search_files": "Searching files", + "terminal": "Running", + "execute_code": "Running code", + "image_generate": "Generating image", + "video_generate": "Generating video", + "text_to_speech": "Generating speech", + "vision_analyze": "Looking at the image", + "session_search": "Searching past sessions", + "skill_view": "Reading skill", + "skills_list": "Listing skills", + "skill_manage": "Updating skill", + "delegate_task": "Delegating", + "cronjob": "Scheduling", + "clarify": "Asking", + "memory": "Updating memory", + "todo": "Updating tasks", +} + +# Verbs that read better without the raw argument preview appended. +_TOOL_VERBS_NO_PREVIEW: frozenset[str] = frozenset({ + "skills_list", + "session_search", +}) + +# Verbs that take a "for" connector before the preview (search-style phrasing): +# "Searching the web for " reads better than "Searching the web ". +_TOOL_VERBS_FOR_CONNECTOR: frozenset[str] = frozenset({ + "web_search", + "search_files", +}) + +_friendly_tool_labels: bool = True + + +def set_friendly_tool_labels(enabled: bool) -> None: + """Toggle friendly human-phrased tool labels (display.friendly_tool_labels).""" + global _friendly_tool_labels + _friendly_tool_labels = bool(enabled) + + +def get_friendly_tool_labels() -> bool: + """Return whether friendly tool labels are enabled.""" + return _friendly_tool_labels + + +def get_tool_verb(tool_name: str) -> str | None: + """Return the friendly verb for a built-in tool, or None. + + Returns None when friendly labels are disabled or the tool has no curated + verb (custom/plugin/MCP tools). Callers that already hold a computed + argument preview can compose ``f"{verb} {preview}"`` themselves; use + :func:`tool_verb_connector` to pick the right joiner. + """ + if not _friendly_tool_labels: + return None + return _TOOL_VERBS.get(tool_name) + + +def tool_verb_connector(tool_name: str) -> str: + """Return the connector between a verb and its preview (" for " or " ").""" + return " for " if tool_name in _TOOL_VERBS_FOR_CONNECTOR else " " + + +def verb_drops_preview(tool_name: str) -> bool: + """Whether the verb should render alone, without the argument preview.""" + return tool_name in _TOOL_VERBS_NO_PREVIEW + + +def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> str | None: + """Build a human-phrased status label for a tool call. + + For built-in tools with a known verb (``web_search`` -> "Searching the + web for ..."), returns the verb optionally followed by the argument + preview. For everything else (custom/plugin/MCP tools, or when friendly + labels are disabled) returns the raw preview, so callers can use this as a + drop-in replacement for :func:`build_tool_preview`. + """ + if not _friendly_tool_labels: + return build_tool_preview(tool_name, args, max_len=max_len) + + verb = _TOOL_VERBS.get(tool_name) + if not verb: + return build_tool_preview(tool_name, args, max_len=max_len) + + if tool_name in _TOOL_VERBS_NO_PREVIEW: + return verb + + preview = build_tool_preview(tool_name, args, max_len=max_len) + if not preview: + return verb + if tool_name in _TOOL_VERBS_FOR_CONNECTOR: + return f"{verb} for {preview}" + return f"{verb} {preview}" + + # ========================================================================= # Inline diff previews for write actions # ========================================================================= diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 6845f79195e..167a60946b2 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -24,6 +24,7 @@ from typing import Any, Optional from agent.display import ( KawaiiSpinner, build_tool_preview as _build_tool_preview, + build_tool_label as _build_tool_label, get_cute_tool_message as _get_cute_tool_message_impl, get_tool_emoji as _get_tool_emoji, redact_tool_args_for_display as _redact_tool_args_for_display, @@ -1224,7 +1225,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe face = random.choice(KawaiiSpinner.get_waiting_faces()) emoji = _get_tool_emoji(function_name) display_args = _redact_tool_args_for_display(function_name, function_args) or function_args - preview = _build_tool_preview(function_name, display_args) or function_name + preview = _build_tool_label(function_name, display_args) or function_name spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn) spinner.start() _ce_result = None @@ -1258,7 +1259,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe face = random.choice(KawaiiSpinner.get_waiting_faces()) emoji = _get_tool_emoji(function_name) display_args = _redact_tool_args_for_display(function_name, function_args) or function_args - preview = _build_tool_preview(function_name, display_args) or function_name + preview = _build_tool_label(function_name, display_args) or function_name spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn) spinner.start() _mem_result = None @@ -1290,7 +1291,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe face = random.choice(KawaiiSpinner.get_waiting_faces()) emoji = _get_tool_emoji(function_name) display_args = _redact_tool_args_for_display(function_name, function_args) or function_args - preview = _build_tool_preview(function_name, display_args) or function_name + preview = _build_tool_label(function_name, display_args) or function_name spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn) spinner.start() _spinner_result = None diff --git a/cli.py b/cli.py index e209c48eecf..fc7dd594141 100644 --- a/cli.py +++ b/cli.py @@ -751,6 +751,14 @@ try: except Exception: pass +# Initialize friendly tool labels from config (default on) +try: + from agent.display import set_friendly_tool_labels + _ftl = CLI_CONFIG.get("display", {}).get("friendly_tool_labels", True) + set_friendly_tool_labels(bool(_ftl)) +except Exception: + pass + # Neuter AsyncHttpxClientWrapper.__del__ before any AsyncOpenAI clients are # created. The SDK's __del__ schedules aclose() on asyncio.get_running_loop() # which, during CLI idle time, finds prompt_toolkit's event loop and tries to diff --git a/gateway/run.py b/gateway/run.py index 04b67d2bae2..7254521a4d8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15385,6 +15385,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: pass + # Apply friendly tool labels config (default on) — per-platform aware + try: + from agent.display import set_friendly_tool_labels + _ftl = resolve_display_setting(user_config, platform_key, "friendly_tool_labels", True) + set_friendly_tool_labels(bool(_ftl)) + except Exception: + pass + # Tool progress mode — resolved per-platform with env var fallback _resolved_tp = resolve_display_setting(user_config, platform_key, "tool_progress") _env_tp = os.getenv("HERMES_TOOL_PROGRESS_MODE") @@ -15678,12 +15686,29 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew msg = _code_block_short last_was_terminal_block[0] = True elif preview: - from agent.display import get_tool_preview_max_len + from agent.display import ( + get_tool_preview_max_len, + get_tool_verb, + tool_verb_connector, + verb_drops_preview, + ) _pl = get_tool_preview_max_len() _cap = _pl if _pl > 0 else 40 if len(preview) > _cap: preview = preview[:_cap - 3] + "..." - msg = f"{emoji} {tool_name}: \"{preview}\"" + # Friendly labels: render a human-phrased line for built-in + # tools ("🔍 Searching the web for ...") by prefixing the verb + # onto the preview the callback already computed (so the + # command/url/query is preserved). Custom/plugin/MCP tools + # have no verb and fall back to the raw "tool_name: ..." form. + _verb = get_tool_verb(tool_name) + if _verb: + if verb_drops_preview(tool_name): + msg = f"{emoji} {_verb}" + else: + msg = f"{emoji} {_verb}{tool_verb_connector(tool_name)}{preview}" + else: + msg = f"{emoji} {tool_name}: \"{preview}\"" last_was_terminal_block[0] = False else: msg = f"{emoji} {tool_name}..." diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3474ee35a0e..1c2664e9f76 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1725,6 +1725,11 @@ DEFAULT_CONFIG = { "tool_progress_command": False, # Enable /verbose command in messaging gateway "tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead "tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands) + # Human-phrased tool status labels for built-in tools: "Searching the + # web for ...", "Reading ", "Browsing " instead of the raw + # tool name. Applies to CLI spinner + gateway/desktop tool-progress. + # Custom/plugin/MCP tools always fall back to the raw preview. + "friendly_tool_labels": True, # How gateway tool-progress is grouped on platforms that support message # editing: "accumulate" (default) edits one bubble in place; "separate" # sends one message per tool (the pre-v0.9 behavior, noisier). Only diff --git a/infographic/friendly-tool-labels/infographic.png b/infographic/friendly-tool-labels/infographic.png new file mode 100644 index 00000000000..843bf4bfe46 Binary files /dev/null and b/infographic/friendly-tool-labels/infographic.png differ diff --git a/tests/agent/test_display.py b/tests/agent/test_display.py index 9ff3694d2bd..ab390fac249 100644 --- a/tests/agent/test_display.py +++ b/tests/agent/test_display.py @@ -401,3 +401,82 @@ class TestEditDiffPreview: assert any("a/file2.py" in line for line in rendered) assert not any("a/file7.py" in line for line in rendered) assert "additional file" in rendered[-1] + + +class TestBuildToolLabel: + """Friendly human-phrased tool labels for built-in tools.""" + + @pytest.fixture(autouse=True) + def _enable_friendly(self): + from agent.display import set_friendly_tool_labels + set_friendly_tool_labels(True) + yield + set_friendly_tool_labels(True) + + def test_web_search_uses_for_connector(self): + from agent.display import build_tool_label + label = build_tool_label("web_search", {"query": "weather in NYC"}) + assert label == 'Searching the web for weather in NYC' + + def test_web_extract_reads_url(self): + from agent.display import build_tool_label + label = build_tool_label("web_extract", {"urls": ["https://example.com/page"]}) + assert label is not None + assert label.startswith("Reading ") + assert "example.com/page" in label + + def test_browser_navigate_browses_url(self): + from agent.display import build_tool_label + label = build_tool_label("browser_navigate", {"url": "https://news.site"}) + assert label == "Browsing https://news.site" + + def test_read_file_uses_basename(self): + from agent.display import build_tool_label + label = build_tool_label("read_file", {"path": "/home/u/project/main.py"}) + assert label is not None + assert label.startswith("Reading ") + assert "main.py" in label + + def test_search_files_uses_for_connector(self): + from agent.display import build_tool_label + label = build_tool_label("search_files", {"pattern": "TODO"}) + assert label == "Searching files for TODO" + + def test_verb_only_for_no_preview_tools(self): + from agent.display import build_tool_label + # session_search is verb-only — no redundant query echo + label = build_tool_label("session_search", {"query": "auth refactor"}) + assert label == "Searching past sessions" + + def test_verb_only_when_no_preview_available(self): + from agent.display import build_tool_label + # image_generate with empty args still yields the verb (no preview) + label = build_tool_label("image_generate", {}) + assert label == "Generating image" + + def test_unknown_tool_falls_back_to_preview(self): + from agent.display import build_tool_label, build_tool_preview + args = {"some_arg": "value"} + # A custom/plugin/MCP tool with no verb entry → raw preview behavior + label = build_tool_label("custom_mcp_tool", args) + assert label == build_tool_preview("custom_mcp_tool", args) + + def test_disabled_falls_back_to_preview(self): + from agent.display import ( + build_tool_label, + build_tool_preview, + set_friendly_tool_labels, + ) + set_friendly_tool_labels(False) + args = {"query": "weather in NYC"} + label = build_tool_label("web_search", args) + # With the feature off, must match the raw preview exactly + assert label == build_tool_preview("web_search", args) + assert "Searching the web" not in (label or "") + + def test_every_known_verb_renders_without_error(self): + from agent.display import build_tool_label, _TOOL_VERBS + # Each built-in verb must produce a non-empty label given minimal args. + for tool_name in _TOOL_VERBS: + label = build_tool_label(tool_name, {"query": "x", "path": "x", "url": "x"}) + assert label, f"{tool_name} produced empty label" diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 00c6cce014f..5c151c56ea9 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -308,7 +308,7 @@ async def test_run_agent_progress_stays_in_originating_topic(monkeypatch, tmp_pa assert adapter.sent == [ { "chat_id": "-1001", - "content": '💻 terminal: "pwd"', + "content": '💻 Running pwd', "reply_to": None, "metadata": {"thread_id": "17585"}, } @@ -496,6 +496,27 @@ async def test_run_agent_feishu_progress_replies_inside_existing_thread(monkeypa # --------------------------------------------------------------------------- +def _extract_progress_preview(content: str) -> str | None: + """Extract the argument-preview portion from a tool-progress message. + + Handles both render styles: + - Legacy / custom tools: ``🔧 tool_name: ""`` (quoted) + - Friendly built-in verb: ``💻 Running `` (verb prefix, no quotes) + """ + import re + + # Legacy quoted form takes precedence when present. + match = re.search(r'"(.+)"', content) + if match: + return match.group(1) + # Friendly form: " ". The terminal verb is "Running". + marker = " Running " + idx = content.find(marker) + if idx != -1: + return content[idx + len(marker):].strip() + return None + + def _run_long_preview_helper(monkeypatch, tmp_path, preview_length=0): """Shared setup for long-preview truncation tests. @@ -552,13 +573,10 @@ def test_all_mode_default_truncation_40_chars(monkeypatch, tmp_path): assert result["final_response"] == "done" assert adapter.sent content = adapter.sent[0]["content"] - # The long command should be truncated — total preview <= 40 chars + # The long command should be truncated — the preview portion <= 40 chars. assert "..." in content - # Extract the preview part between quotes - import re - match = re.search(r'"(.+)"', content) - assert match, f"No quoted preview found in: {content}" - preview_text = match.group(1) + preview_text = _extract_progress_preview(content) + assert preview_text is not None, f"No preview found in: {content}" assert len(preview_text) <= 40, f"Preview too long ({len(preview_text)}): {preview_text}" @@ -568,11 +586,9 @@ def test_all_mode_respects_custom_preview_length(monkeypatch, tmp_path): assert result["final_response"] == "done" assert adapter.sent content = adapter.sent[0]["content"] - # With 120-char cap, the command (165 chars) should still be truncated but longer - import re - match = re.search(r'"(.+)"', content) - assert match, f"No quoted preview found in: {content}" - preview_text = match.group(1) + # With 120-char cap, the command (165 chars) should still be truncated but longer. + preview_text = _extract_progress_preview(content) + assert preview_text is not None, f"No preview found in: {content}" # Should be longer than the 40-char default assert len(preview_text) > 40, f"Preview suspiciously short ({len(preview_text)}): {preview_text}" # But still capped at 120 diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 01a0859fd6a..2487e6b95e4 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -854,7 +854,7 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display(): assert server._history_to_messages(history) == [ {"role": "user", "text": "first prompt"}, - {"context": "resume", "name": "search_files", "role": "tool"}, + {"context": "Searching files for resume", "name": "search_files", "role": "tool"}, {"role": "assistant", "text": "first answer"}, {"role": "user", "text": "second prompt"}, ] @@ -5461,13 +5461,23 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch): assert built, "agent build did not complete within timeout" # Build finished without a close race — nothing should have been - # cleaned up by the orphan check. + # cleaned up by the orphan check. Scope the assertions to THIS + # test's own session_key: a daemon build thread leaked from a prior + # session.create test in the same shard process can fire close/ + # unregister against its own (foreign) key after we've patched the + # global hooks, polluting these lists. Filtering by this session's + # key keeps the regression intent (this session's worker/notify must + # survive) while making the test immune to shard composition. + # (flaky under -j 8: foreign key e.g. 20260629_210208_d4f545) + own_key = session["session_key"] + own_closed = [k for k in closed_workers if k == own_key] + own_unregistered = [k for k in unregistered_keys if k == own_key] assert ( - closed_workers == [] - ), f"build thread closed its own worker despite no race: {closed_workers}" + own_closed == [] + ), f"build thread closed its own worker despite no race: {own_closed}" assert ( - unregistered_keys == [] - ), f"build thread unregistered its own notify despite no race: {unregistered_keys}" + own_unregistered == [] + ), f"build thread unregistered its own notify despite no race: {own_unregistered}" # Session should have the live worker installed. assert session.get("slash_worker") is not None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5ac8591f0a7..8a5e778b719 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3160,9 +3160,9 @@ def _session_info(agent, session: dict | None = None) -> dict: def _tool_ctx(name: str, args: dict) -> str: try: - from agent.display import build_tool_preview + from agent.display import build_tool_label - return build_tool_preview(name, args, max_len=80) or "" + return build_tool_label(name, args, max_len=80) or "" except Exception: return ""