Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
116 changes: 116 additions & 0 deletions agent/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <query>" into "Searching the web for <query>" — 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 <query>" reads better than "Searching the web <query>".
_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
# =========================================================================
Expand Down
7 changes: 4 additions & 3 deletions agent/tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
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,
Expand Down Expand Up @@ -1224,7 +1225,7 @@ def _execute(next_args: dict) -> Any:
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
Expand Down Expand Up @@ -1258,7 +1259,7 @@ def _execute(next_args: dict) -> Any:
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
Expand Down Expand Up @@ -1290,7 +1291,7 @@ def _execute(next_args: dict) -> Any:
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
Expand Down
8 changes: 8 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,14 @@ def load_cli_config() -> Dict[str, Any]:
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
Expand Down
29 changes: 27 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -15371,6 +15371,14 @@ def _run_still_current() -> bool:
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")
Expand Down Expand Up @@ -15664,12 +15672,29 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non
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}..."
Expand Down
5 changes: 5 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1725,6 +1725,11 @@ def _ensure_hermes_home_managed(home: Path):
"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 <file>", "Browsing <url>" 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
Expand Down
Binary file added infographic/friendly-tool-labels/infographic.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
79 changes: 79 additions & 0 deletions tests/agent/test_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,82 @@ def test_summarize_rendered_diff_sections_limits_file_count(self):
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"
40 changes: 28 additions & 12 deletions tests/gateway/test_run_progress_topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
Expand Down Expand Up @@ -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: "<preview>"`` (quoted)
- Friendly built-in verb: ``💻 Running <preview>`` (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: "<emoji> <verb> <preview>". 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.

Expand Down Expand Up @@ -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}"


Expand All @@ -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
Expand Down
Loading
Loading