diff --git a/MANIFEST.in b/MANIFEST.in index 5d5a1b1b271..159c215ff6b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,5 +7,7 @@ graft locales # built from the sdist (e.g. Homebrew, downstream packagers). package-data # below covers the wheel; this covers the sdist. See #34034 / #28149. recursive-include plugins plugin.yaml plugin.yml +# Gateway assets include images plus YAML catalogs such as status_phrases.yaml. +recursive-include gateway/assets * global-exclude __pycache__ global-exclude *.py[cod] diff --git a/cli.py b/cli.py index 6091e3907bd..e88f9312f3c 100644 --- a/cli.py +++ b/cli.py @@ -3698,7 +3698,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self.console = Console() self.config = CLI_CONFIG self.compact = compact if compact is not None else CLI_CONFIG["display"].get("compact", False) - # tool_progress: "off", "new", "all", "verbose" (from config.yaml display section) + # tool_progress: "off", "generic", "new", "all", "verbose" (from config.yaml display section) # YAML 1.1 parses bare `off` as boolean False — normalise to string. _raw_tp = CLI_CONFIG["display"].get("tool_progress", "all") self.tool_progress_mode = "off" if _raw_tp is False else str(_raw_tp) @@ -9191,7 +9191,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): explicit ``-v``/``--verbose`` flag and the ``/verbose-logging`` toggle. See PR #6a1aa420e for the history that decoupled them. """ - cycle = ["off", "new", "all", "verbose"] + cycle = ["off", "generic", "new", "all", "verbose"] try: idx = cycle.index(self.tool_progress_mode) except ValueError: @@ -9212,6 +9212,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): from hermes_cli.colors import Colors as _Colors labels = { "off": f"{_Colors.DIM}Tool progress: OFF{_Colors.RESET} — silent mode, just the final response.", + "generic": f"{_Colors.YELLOW}Tool progress: GENERIC{_Colors.RESET} — show friendly placeholders instead of tool details.", "new": f"{_Colors.YELLOW}Tool progress: NEW{_Colors.RESET} — show each new tool (skip repeats).", "all": f"{_Colors.GREEN}Tool progress: ALL{_Colors.RESET} — show every tool call.", "verbose": f"{_Colors.BOLD}{_Colors.GREEN}Tool progress: VERBOSE{_Colors.RESET} — full args, results, and think blocks.", diff --git a/gateway/assets/status_phrases.yaml b/gateway/assets/status_phrases.yaml new file mode 100644 index 00000000000..3aed7859968 --- /dev/null +++ b/gateway/assets/status_phrases.yaml @@ -0,0 +1,198 @@ +# Default phrases for generic gateway visibility surfaces. +# +# These are Hermes UI/status surfaces, not app/vendor/domain buckets. Keep them +# generic: do not mention Jira, Confluence, Gmail, local paths, model names, +# secrets, or tool arguments. User config can append/replace these via: +# +# display: +# status_phrases: +# mode: append # append | replace +# thinking: +# - one sec, thinking this through +# platforms: +# whatsapp: +# status_phrases: +# status: +# - still on it + +thinking: + - one sec, thinking it through + - thinking this through + - hmm, working through it + - one sec, lining it up + - checking the shape of it + - following the thread + - sorting the pieces + - thinking through the tradeoffs + - one sec, making sure this fits + - working out the clean version + - holding the thought for a second + - checking the logic + - one sec, there’s a detail here + - turning it over quickly + - making sure I’m not missing a bit + - mapping the moving pieces + - letting the idea settle for a sec + - checking the edge cases + - narrowing the answer down + - one sec, this has a wrinkle + - tracing the dependency chain + - sorting signal from noise + - making the answer less wrong + - checking the assumption + - building the mental model + - sanity-checking it + - working through the awkward part + - finding the clean path + - one sec, connecting the dots + - checking whether that actually follows + +tool: + - checking that now + - looking into it + - one sec, checking the details + - working through the lookup + - checking the actual thing + - pulling the thread + - one sec, doing the lookup + - checking the source + - looking at the live data + - grabbing the relevant bit + - checking what’s there + - one sec, verifying it + - reading through it + - checking the current state + - looking for the signal + - checking the record + - digging through the source + - fetching the useful bit + - seeing what it says + - looking under the hood + - checking the real state + - pulling current context + - scanning the relevant output + - checking the available info + - one sec, comparing the pieces + - checking the latest version + - confirming against the source + - looking for the exact bit + - checking where this lands + - one sec, validating it + +command: + - running it now + - checking what the machine says + - one sec, running the command + - running the check + - asking the machine directly + - letting it run for a sec + - checking the output + - running the useful bit + - one sec, getting a real result + - testing it locally + - checking the logs + - running the verification + - waiting on the command + - checking the system state + - letting the process finish + - running the test + - asking the shell + - waiting for the output + - checking the result + - letting the command complete + - running the boring bit + - checking the terminal output + - one sec, the machine is thinking + - running the local check + - waiting on the process + - checking whether it passes + - letting the job finish + - watching the output + - verifying it directly + - checking the exit status + +interim: + - one sec, there’s a bit more here + - still shaping the answer + - stitching this together + - almost got the useful version + - one sec, connecting the pieces + - working this into a clean answer + - still narrowing it down + - pulling this into shape + - one sec, checking the last part + - getting the answer into a useful form + - almost there + - one more bit to check + - turning this into something readable + - still pulling it together + - one sec, making this less messy + - checking the last thread + - shaping the final answer + - joining the dots + - just tightening the answer + - one sec, cleaning this up + - still organizing it + - almost got the shape + - one more pass + - making the useful part clearer + - still stitching + - checking the last detail + - making sure this lands cleanly + - one sec, summarizing the useful bit + - pulling the answer together + - final bit of cleanup + +status: + - still on it + - still working through it + - this is taking a bit, still running + - still checking + - not stuck, just still running + - waiting on the slow part + - still alive, still working + - letting it finish + - still processing this + - one sec, this is still going + - still making progress + - waiting for the result + - still here, checking + - the slow bit is still running + - still working on the useful answer + - no result yet, still waiting + - still running the check + - waiting on the backend bit + - this one needs a minute + - still moving + - still going, not frozen + - waiting for the useful output + - still chewing through it + - one sec, long bit is still running + - still waiting on the result + - working through the slow step + - this is taking longer than usual + - still checking, no panic + - still in progress + - letting the slow thing finish + +generic: + - on it + - one sec + - checking that now + - give me a sec + - looking into it + - working through it + - checking + - still working + - one moment + - taking a look + - got it, checking + - give me a moment + - looking now + - sorting it + - checking the useful bit + - one sec, working on it + - taking a proper look + - checking the details + - working it out + - still looking diff --git a/gateway/display_config.py b/gateway/display_config.py index 0d8b5699516..088d4381d81 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -233,16 +233,25 @@ def _normalise(setting: str, value: Any) -> Any: return "off" if value is True: return "all" - return str(value).lower() + val = str(value).lower() + return val if val in {"off", "generic", "new", "all", "verbose"} else "all" if setting in { "show_reasoning", "streaming", "interim_assistant_messages", "long_running_notifications", "busy_ack_detail", + "thinking_progress", }: if isinstance(value, str): - return value.lower() in {"true", "1", "yes", "on"} + val = value.strip().lower() + if val == "generic" and setting in { + "thinking_progress", + "interim_assistant_messages", + "long_running_notifications", + }: + return "generic" + return val in {"true", "1", "yes", "on", "raw", "verbose"} return bool(value) if setting == "cleanup_progress": if isinstance(value, str): diff --git a/gateway/run.py b/gateway/run.py index 9381f0eab0f..9b5de5cf478 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -16490,6 +16490,46 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) # Tool progress grouping: "accumulate" (edit one bubble) or "separate" (one msg per tool) progress_grouping = resolve_display_setting(user_config, platform_key, "tool_progress_grouping") or "accumulate" + from gateway.status_phrases import choose_status_phrase, resolve_status_phrase_catalog + _generic_status_recent: List[str] = [] + _generic_status_catalog = resolve_status_phrase_catalog(user_config, platform_key) + + def _display_surface_mode( + setting: str, + *, + default: bool = False, + require_platform_override_for: set[Any] | None = None, + ) -> str: + """Return off|raw|generic for a gateway visibility surface.""" + if require_platform_override_for: + current_platform = _gateway_platform_value(source.platform) + platform_only = { + _gateway_platform_value(item) + for item in require_platform_override_for + } + if ( + current_platform in platform_only + and not _has_platform_display_override(user_config, platform_key, setting) + ): + return "off" + value = resolve_display_setting(user_config, platform_key, setting, default) + if isinstance(value, str) and value.strip().lower() == "generic": + return "generic" + return "raw" if bool(value) else "off" + + def _generic_status_phrase(kind: str, *, tool_name: str | None = None, preview: str | None = None, args: Any = None) -> str: + try: + return choose_status_phrase( + kind, + tool_name=tool_name, + preview=preview, + args=args, + recent=_generic_status_recent, + catalog=_generic_status_catalog, + ) + except Exception as _phrase_err: + logger.debug("generic status phrase selection failed: %s", _phrase_err) + return "still on it" if kind in {"heartbeat", "waiting", "long_running", "status"} else "one sec" # Disable tool progress for webhooks - they don't support message editing, # so each progress line would be sent as a separate message. from gateway.config import Platform @@ -16501,29 +16541,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Natural assistant status messages are intentionally independent from # tool progress and token streaming. Users can keep tool_progress quiet # in chat platforms while opting into concise mid-turn updates. + interim_assistant_messages_mode = _display_surface_mode( + "interim_assistant_messages", + default=True, + require_platform_override_for={Platform.MATTERMOST}, + ) interim_assistant_messages_enabled = ( source.platform != Platform.WEBHOOK - and _resolve_gateway_display_bool( - user_config, - platform_key, - "interim_assistant_messages", - default=True, - platform=source.platform, - require_platform_override_for={Platform.MATTERMOST}, - ) + and interim_assistant_messages_mode != "off" ) # thinking_progress is independent — if enabled, we need the progress # queue even when tool_progress is off (thinking relay uses same infra). # Mattermost requires a per-platform opt-in: global scratch-text display # is too easy to leak into busy public threads. - _thinking_enabled = _resolve_gateway_display_bool( - user_config, - platform_key, + _thinking_mode = _display_surface_mode( "thinking_progress", default=False, - platform=source.platform, require_platform_override_for={Platform.MATTERMOST}, ) + _thinking_enabled = _thinking_mode != "off" needs_progress_queue = tool_progress_enabled or _thinking_enabled @@ -16653,7 +16689,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if not _thinking_enabled: return thinking_text = preview if tool_name == "_thinking" else tool_name - msg = f"💬 {thinking_text}" if thinking_text else None + if _thinking_mode == "generic": + msg = _generic_status_phrase("thinking", preview=str(thinking_text or "")) + else: + msg = f"💬 {thinking_text}" if thinking_text else None if msg: progress_queue.put(msg) return @@ -16687,6 +16726,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if progress_mode == "new" and tool_name == last_tool[0]: return last_tool[0] = tool_name + + if tool_progress_generic: + progress_queue.put( + _generic_status_phrase( + "tool", + tool_name=str(tool_name or ""), + preview=str(preview or ""), + args=args, + ) + ) + return # Build progress message with primary argument preview from agent.display import get_tool_emoji @@ -17487,18 +17537,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: if not _run_still_current(): return + display_text = ( + _generic_status_phrase("interim", preview=str(text or "")) + if interim_assistant_messages_mode == "generic" + else text + ) if _stream_consumer is not None: if already_streamed: _stream_consumer.on_segment_break() else: - _stream_consumer.on_commentary(text) + _stream_consumer.on_commentary(display_text) return - if already_streamed or not _status_adapter or not str(text or "").strip(): + if already_streamed or not _status_adapter or not str(display_text or "").strip(): return safe_schedule_threadsafe( _status_adapter.send( _status_chat_id, - text, + display_text, metadata=_status_thread_metadata, ), _loop_for_step, @@ -18669,14 +18724,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # 0 = disable notifications. _NOTIFY_INTERVAL_RAW = _float_env("HERMES_AGENT_NOTIFY_INTERVAL", 180) _NOTIFY_INTERVAL = _NOTIFY_INTERVAL_RAW if _NOTIFY_INTERVAL_RAW > 0 else None - if not bool( - resolve_display_setting( - user_config, - platform_key, - "long_running_notifications", - True, - ) - ): + _long_running_mode = _display_surface_mode( + "long_running_notifications", + default=True, + ) + if _long_running_mode == "off": _NOTIFY_INTERVAL = None _notify_start = time.time() @@ -18738,7 +18790,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _status_detail = " — " + ", ".join(_parts) except Exception: pass - _heartbeat_text = f"⏳ Working — {_elapsed_mins} min{_status_detail}" + _heartbeat_text = ( + _generic_status_phrase("status") + if _long_running_mode == "generic" + else f"⏳ Working — {_elapsed_mins} min{_status_detail}" + ) try: _notify_res = None if _heartbeat_msg_id: diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 0d7be83cf9e..5732d445e6a 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2936,6 +2936,7 @@ class GatewaySlashCommandsMixin: cycle = ["off", "new", "all", "verbose", "log"] descriptions = { "off": t("gateway.verbose.mode_off"), + "generic": "GENERIC — show human-friendly placeholders instead of tool details.", "new": t("gateway.verbose.mode_new"), "all": t("gateway.verbose.mode_all"), "verbose": t("gateway.verbose.mode_verbose"), diff --git a/gateway/status_phrases.py b/gateway/status_phrases.py new file mode 100644 index 00000000000..3c3b2f92991 --- /dev/null +++ b/gateway/status_phrases.py @@ -0,0 +1,241 @@ +"""Human-friendly generic gateway status phrases. + +These helpers deliberately avoid relaying raw model scratch text. They turn +Hermes' own gateway visibility surfaces (thinking/tool/command/interim/status) +into short status lines suitable for chat surfaces. + +Built-in defaults live in ``gateway/assets/status_phrases.yaml``. Users can add +portable, profile-relative phrase catalogs under ``HERMES_HOME`` either by using +conventional paths:: + + ~/.hermes/status_phrases.yaml + ~/.hermes/status_phrases/*.yaml + +or by pointing config at a relative file/directory:: + + display: + status_phrases: + path: status_phrases/whatsapp.yaml # relative to HERMES_HOME + mode: append # append (default) or replace + +Absolute paths and ``..`` escapes are ignored on purpose so config stays +profile-portable and does not accidentally read arbitrary files. + +Only configured phrase strings are used; raw tool args, commands, previews, and +reasoning text are never interpolated into the returned phrase. +""" + +from __future__ import annotations + +import random as _random +from collections.abc import Mapping, MutableSequence +from pathlib import Path +from typing import Any + +import yaml + +from hermes_constants import get_hermes_home + +# These are Hermes UI surfaces, not app/vendor/domain buckets. Do not add +# Jira/Confluence/Gmail/etc. categories here; tool names are only used to choose +# between the built-in "tool" and "command" surfaces. +_STATUS_SURFACES = ("thinking", "tool", "command", "interim", "status", "generic") +_COMMAND_TOOL_NAMES = {"terminal", "execute_code", "process"} +_MAX_CUSTOM_PHRASES_PER_SURFACE = 80 +_MAX_PHRASE_CHARS = 160 +_CONVENTIONAL_RELATIVE_PATHS = ("status_phrases.yaml", "status_phrases") + +_FALLBACK_PHRASES: dict[str, list[str]] = { + "thinking": ["one sec, thinking it through", "thinking this through", "checking the logic"], + "tool": ["checking that now", "looking into it", "checking the source"], + "command": ["running it now", "checking what the machine says", "checking the output"], + "interim": ["still shaping the answer", "stitching this together", "almost there"], + "status": ["still on it", "still working through it", "waiting for the result"], + "generic": ["on it", "one sec", "checking that now"], +} + + +def _clean_phrase_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + cleaned: list[str] = [] + seen: set[str] = set() + for item in value[:_MAX_CUSTOM_PHRASES_PER_SURFACE]: + phrase = str(item or "").strip() + if not phrase or len(phrase) > _MAX_PHRASE_CHARS or phrase in seen: + continue + cleaned.append(phrase) + seen.add(phrase) + return cleaned + + +def _merge_phrase_mapping(catalog: dict[str, list[str]], section: Mapping[str, Any], *, inherited_mode: str | None = None) -> None: + mode = str(section.get("mode") or inherited_mode or "append").strip().lower() + replace = mode == "replace" + phrase_map = section.get("phrases") if isinstance(section.get("phrases"), Mapping) else section + for surface in _STATUS_SURFACES: + phrases = _clean_phrase_list(phrase_map.get(surface) if isinstance(phrase_map, Mapping) else None) + if not phrases: + continue + catalog[surface] = phrases if replace else [*catalog.get(surface, []), *phrases] + + +def _merge_phrase_file(catalog: dict[str, list[str]], path: Path, *, inherited_mode: str | None = None) -> None: + try: + loaded = yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception: + return + if isinstance(loaded, Mapping): + _merge_phrase_mapping(catalog, loaded, inherited_mode=inherited_mode) + + +def _relative_path_under(base_dir: Path, raw_path: Any) -> Path | None: + raw = str(raw_path or "").strip() + if not raw: + return None + candidate = Path(raw).expanduser() + if candidate.is_absolute() or ".." in candidate.parts: + return None + base = base_dir.resolve() + resolved = (base / candidate).resolve() + try: + resolved.relative_to(base) + except ValueError: + return None + return resolved + + +def _iter_phrase_files(path: Path) -> list[Path]: + if path.is_file() and path.suffix.lower() in {".yaml", ".yml"}: + return [path] + if path.is_dir(): + return sorted( + child for child in path.iterdir() + if child.is_file() and child.suffix.lower() in {".yaml", ".yml"} + ) + return [] + + +def _merge_phrase_paths( + catalog: dict[str, list[str]], + paths: Any, + *, + base_dir: Path, + inherited_mode: str | None = None, +) -> None: + if paths is None: + return + raw_paths = paths if isinstance(paths, list) else [paths] + for raw_path in raw_paths: + resolved = _relative_path_under(base_dir, raw_path) + if resolved is None: + continue + for phrase_file in _iter_phrase_files(resolved): + _merge_phrase_file(catalog, phrase_file, inherited_mode=inherited_mode) + + +def _load_builtin_catalog() -> dict[str, list[str]]: + catalog = {surface: list(phrases) for surface, phrases in _FALLBACK_PHRASES.items()} + catalog_path = Path(__file__).resolve().parent / "assets" / "status_phrases.yaml" + _merge_phrase_file(catalog, catalog_path, inherited_mode="replace") + return catalog + + +_DEFAULT_PHRASES: dict[str, list[str]] = _load_builtin_catalog() + + +def _copy_default_catalog() -> dict[str, list[str]]: + return {surface: list(phrases) for surface, phrases in _DEFAULT_PHRASES.items()} + + +def _merge_phrase_config(catalog: dict[str, list[str]], section: Any, *, base_dir: Path | None = None) -> None: + """Merge one display.status_phrases-style section into ``catalog``.""" + if not isinstance(section, Mapping): + return + mode = str(section.get("mode") or "append").strip().lower() + if base_dir is not None: + _merge_phrase_paths(catalog, section.get("path"), base_dir=base_dir, inherited_mode=mode) + _merge_phrase_paths(catalog, section.get("paths"), base_dir=base_dir, inherited_mode=mode) + _merge_phrase_mapping(catalog, section) + + +def resolve_status_phrase_catalog(user_config: Mapping[str, Any] | None, platform_key: str | None = None) -> dict[str, list[str]]: + """Resolve built-in + user-configured generic status phrases. + + Resolution order mirrors gateway display settings: built-ins, conventional + profile-relative user files, global ``display.status_phrases`` (or legacy + alias ``generic_status_phrases``), then + ``display.platforms..status_phrases``. + """ + catalog = _copy_default_catalog() + hermes_home = get_hermes_home() + _merge_phrase_paths(catalog, list(_CONVENTIONAL_RELATIVE_PATHS), base_dir=hermes_home) + + display = (user_config or {}).get("display") if isinstance(user_config, Mapping) else None + if not isinstance(display, Mapping): + return catalog + + _merge_phrase_config(catalog, display.get("generic_status_phrases"), base_dir=hermes_home) + _merge_phrase_config(catalog, display.get("status_phrases"), base_dir=hermes_home) + + platforms = display.get("platforms") + if platform_key and isinstance(platforms, Mapping): + platform_display = platforms.get(platform_key) + if isinstance(platform_display, Mapping): + _merge_phrase_config(catalog, platform_display.get("generic_status_phrases"), base_dir=hermes_home) + _merge_phrase_config(catalog, platform_display.get("status_phrases"), base_dir=hermes_home) + return catalog + + +def classify_status_context( + kind: str, + *, + tool_name: str | None = None, + preview: str | None = None, + args: Any = None, +) -> str: + """Classify an internal gateway event into a Hermes UI-surface bucket.""" + normalized = str(kind or "").strip().lower() + if normalized in {"heartbeat", "waiting", "long_running", "status"}: + return "status" + if normalized in {"thinking", "_thinking", "reasoning"}: + return "thinking" + if normalized in {"interim", "interim_assistant"}: + return "interim" + if normalized in {"command", "terminal"}: + return "command" + if normalized == "tool": + name = (tool_name or "").strip().lower() + return "command" if name in _COMMAND_TOOL_NAMES else "tool" + return "generic" + + +def choose_status_phrase( + kind: str, + *, + tool_name: str | None = None, + preview: str | None = None, + args: Any = None, + recent: MutableSequence[str] | None = None, + rng: Any = None, + catalog: Mapping[str, list[str]] | None = None, +) -> str: + """Pick a short generic status phrase, avoiding recent repeats. + + ``preview`` and ``args`` are accepted for callback compatibility, but their + raw contents are never embedded in the returned phrase. + """ + phrase_catalog = catalog or _DEFAULT_PHRASES + category = classify_status_context(kind, tool_name=tool_name, preview=preview, args=args) + candidates = list(phrase_catalog.get(category) or phrase_catalog.get("generic") or _DEFAULT_PHRASES["generic"]) + if recent: + recent_set = set(recent) + fresh = [phrase for phrase in candidates if phrase not in recent_set] + if fresh: + candidates = fresh + picker = rng or _random + phrase = picker.choice(candidates) + if recent is not None: + recent.append(phrase) + del recent[:-6] + return phrase diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f7fb4c51b12..b8e3bc1cebd 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1773,6 +1773,15 @@ DEFAULT_CONFIG = { # applies where tool_progress is already enabled. Per-platform override # via display.platforms..tool_progress_grouping. "tool_progress_grouping": "accumulate", + # Optional custom phrases for display.* generic visibility modes. + # Built-in defaults live in gateway/assets/status_phrases.yaml. Users + # can set `path`/`paths` to HERMES_HOME-relative YAML files/directories + # (or rely on conventional status_phrases.yaml / status_phrases/*.yaml). + # Keys: thinking, tool, command, interim, status, generic. Use + # mode: "append" (default) to add phrases, or "replace" to fully + # replace configured surfaces. Per-platform overrides live under + # display.platforms..status_phrases. + "status_phrases": {}, # How a reasoning/thinking summary renders when show_reasoning is on. # "code" (default) = 💭 fenced code block; "blockquote" = "> " lines; # "subtext" = "-# " lines (Discord small grey metadata text). Discord @@ -5337,7 +5346,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A if old_enabled and old_enabled.lower() in {"false", "0", "no"}: display["tool_progress"] = "off" results["config_added"].append("display.tool_progress=off (from HERMES_TOOL_PROGRESS=false)") - elif old_mode and old_mode.lower() in {"new", "all"}: + elif old_mode and old_mode.lower() in {"generic", "new", "all", "verbose"}: display["tool_progress"] = old_mode.lower() results["config_added"].append(f"display.tool_progress={old_mode.lower()} (from HERMES_TOOL_PROGRESS_MODE)") else: diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index a990f6bf342..35e17251de8 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -99,7 +99,7 @@ class TestVerboseAndToolProgress: def test_tool_progress_mode_is_string(self): cli = _make_cli() assert isinstance(cli.tool_progress_mode, str) - assert cli.tool_progress_mode in {"off", "new", "all", "verbose"} + assert cli.tool_progress_mode in {"off", "generic", "new", "all", "verbose"} class TestFallbackChainInit: diff --git a/tests/gateway/test_display_config.py b/tests/gateway/test_display_config.py index 81bbc912fab..7b54d571315 100644 --- a/tests/gateway/test_display_config.py +++ b/tests/gateway/test_display_config.py @@ -149,6 +149,36 @@ class TestYAMLNormalisation: config = {"display": {"tool_progress": True}} assert resolve_display_setting(config, "telegram", "tool_progress") == "all" + def test_tool_progress_generic_is_first_class_mode(self): + from gateway.display_config import resolve_display_setting + + config = {"display": {"platforms": {"whatsapp": {"tool_progress": "generic"}}}} + assert resolve_display_setting(config, "whatsapp", "tool_progress") == "generic" + + def test_chatter_visibility_surfaces_accept_generic_mode(self): + from gateway.display_config import resolve_display_setting + + config = { + "display": { + "platforms": { + "whatsapp": { + "thinking_progress": "generic", + "interim_assistant_messages": "generic", + "long_running_notifications": "generic", + } + } + } + } + assert resolve_display_setting(config, "whatsapp", "thinking_progress") == "generic" + assert resolve_display_setting(config, "whatsapp", "interim_assistant_messages") == "generic" + assert resolve_display_setting(config, "whatsapp", "long_running_notifications") == "generic" + + def test_thinking_progress_string_false_normalised_to_false(self): + from gateway.display_config import resolve_display_setting + + config = {"display": {"platforms": {"whatsapp": {"thinking_progress": "false"}}}} + assert resolve_display_setting(config, "whatsapp", "thinking_progress") is False + def test_show_reasoning_string_true(self): """String 'true' is normalised to bool True.""" from gateway.display_config import resolve_display_setting diff --git a/tests/gateway/test_status_phrases.py b/tests/gateway/test_status_phrases.py new file mode 100644 index 00000000000..633474c915f --- /dev/null +++ b/tests/gateway/test_status_phrases.py @@ -0,0 +1,173 @@ +import random + +from gateway.status_phrases import ( + classify_status_context, + choose_status_phrase, + resolve_status_phrase_catalog, +) + + +def test_terminal_tool_uses_command_surface_bucket(): + assert classify_status_context("tool", tool_name="terminal") == "command" + assert classify_status_context("command") == "command" + + +def test_regular_tool_uses_tool_surface_bucket_not_domain_bucket(): + assert classify_status_context("tool", tool_name="web_search", preview="weather") == "tool" + assert classify_status_context("tool", tool_name="mcp_atlassian_infohealth_getJiraIssue") == "tool" + + +def test_interim_uses_interim_surface_bucket(): + assert classify_status_context("interim_assistant") == "interim" + + +def test_generic_phrase_does_not_leak_raw_thinking_text(): + msg = choose_status_phrase( + "thinking", + preview="actual private scratch text should not be sent", + rng=random.Random(4), + ) + + assert "actual private scratch" not in msg + assert msg + + +def test_generic_tool_phrase_does_not_leak_vendor_or_args(): + msg = choose_status_phrase( + "tool", + tool_name="mcp_atlassian_infohealth_getJiraIssue", + args={"issueIdOrKey": "SECRET-123"}, + rng=random.Random(1), + ) + + assert "jira" not in msg.lower() + assert "confluence" not in msg.lower() + assert "atlassian" not in msg.lower() + assert "SECRET-123" not in msg + + +def test_generic_phrase_avoids_recent_repetition(): + recent: list[str] = [] + first = choose_status_phrase("tool", tool_name="web_search", rng=random.Random(2), recent=recent) + second = choose_status_phrase("tool", tool_name="web_search", rng=random.Random(2), recent=recent) + + assert first != second + assert recent[-2:] == [first, second] + + +def test_builtin_catalog_is_loaded_from_external_asset_and_is_not_tiny(): + catalog = resolve_status_phrase_catalog({}, "whatsapp") + + for surface in ("thinking", "tool", "command", "interim", "status"): + assert len(catalog[surface]) >= 25, surface + + +def test_relative_status_phrase_path_loads_from_hermes_home(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + phrase_file = tmp_path / "phrases.yaml" + phrase_file.write_text("mode: replace\ntool:\n - relative safe tool text\n", encoding="utf-8") + + catalog = resolve_status_phrase_catalog( + {"display": {"status_phrases": {"path": "phrases.yaml"}}}, + "whatsapp", + ) + + assert catalog["tool"] == ["relative safe tool text"] + + +def test_status_phrase_path_can_load_relative_directory(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + phrase_dir = tmp_path / "phrase-catalog" + phrase_dir.mkdir() + (phrase_dir / "01-status.yaml").write_text("status:\n - relative dir status text\n", encoding="utf-8") + + catalog = resolve_status_phrase_catalog( + {"display": {"status_phrases": {"path": "phrase-catalog"}}}, + "whatsapp", + ) + + assert "relative dir status text" in catalog["status"] + + +def test_absolute_or_parent_phrase_paths_are_ignored(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + outside = tmp_path.parent / "outside-phrases.yaml" + outside.write_text("mode: replace\ntool:\n - should not load\n", encoding="utf-8") + + catalog = resolve_status_phrase_catalog( + {"display": {"status_phrases": {"path": str(outside)}}}, + "whatsapp", + ) + escaped = resolve_status_phrase_catalog( + {"display": {"status_phrases": {"path": "../outside-phrases.yaml"}}}, + "whatsapp", + ) + + assert catalog["tool"] != ["should not load"] + assert escaped["tool"] != ["should not load"] + + +def test_conventional_relative_status_phrase_file_is_loaded(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "status_phrases.yaml").write_text( + "mode: replace\nstatus:\n - conventional status text\n", + encoding="utf-8", + ) + + catalog = resolve_status_phrase_catalog({}, "whatsapp") + + assert catalog["status"] == ["conventional status text"] + + +def test_global_custom_phrase_catalog_appends_to_builtin(): + catalog = resolve_status_phrase_catalog( + { + "display": { + "status_phrases": { + "thinking": ["custom thinking placeholder"], + } + } + }, + "whatsapp", + ) + + assert "custom thinking placeholder" in catalog["thinking"] + assert len(catalog["thinking"]) > 1 + + +def test_platform_custom_phrase_catalog_can_replace_surface(): + catalog = resolve_status_phrase_catalog( + { + "display": { + "platforms": { + "whatsapp": { + "status_phrases": { + "mode": "replace", + "tool": ["custom tool placeholder"], + } + } + } + } + }, + "whatsapp", + ) + + assert catalog["tool"] == ["custom tool placeholder"] + assert len(catalog["thinking"]) > 1 + + +def test_choose_status_phrase_uses_custom_catalog_without_leaking_args(): + catalog = resolve_status_phrase_catalog( + {"display": {"status_phrases": {"mode": "replace", "tool": ["custom safe tool text"]}}}, + "whatsapp", + ) + + msg = choose_status_phrase( + "tool", + tool_name="web_search", + args={"query": "SECRET SEARCH"}, + catalog=catalog, + ) + + assert msg == "custom safe tool text" + assert "SECRET" not in msg diff --git a/tests/gateway/test_verbose_command.py b/tests/gateway/test_verbose_command.py index a5da0e11438..9340d7e1a90 100644 --- a/tests/gateway/test_verbose_command.py +++ b/tests/gateway/test_verbose_command.py @@ -105,7 +105,7 @@ class TestVerboseCommand: @pytest.mark.asyncio async def test_cycles_through_all_modes(self, tmp_path, monkeypatch): - """Calling /verbose repeatedly cycles through all four modes.""" + """Calling /verbose repeatedly cycles through all tool-progress visibility modes.""" hermes_home = tmp_path / "hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" @@ -132,8 +132,7 @@ class TestVerboseCommand: Telegram's tier-1 preset overrides ``tool_progress`` to ``"off"`` so the platform stays final-answer-first by default on mobile inboxes. The - first ``/verbose`` invocation therefore cycles ``off → new``, not - ``all → ...``. + first ``/verbose`` invocation therefore cycles ``off → generic``. """ hermes_home = tmp_path / "hermes" hermes_home.mkdir() @@ -148,10 +147,10 @@ class TestVerboseCommand: runner = _make_runner() result = await runner._handle_verbose_command(_make_event()) - # Telegram platform default is "off" → cycles to "new" - assert "NEW" in result + # Telegram platform default is "off" → cycles to "generic" + assert "GENERIC" in result saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) - assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "new" + assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "generic" @pytest.mark.asyncio async def test_per_platform_isolation(self, tmp_path, monkeypatch): @@ -159,7 +158,7 @@ class TestVerboseCommand: Without a global tool_progress, each platform uses its built-in default — Telegram = 'off' (tier-1 inbox override), Slack = 'off' - (quiet Slack default). Both cycle to 'new' on first /verbose. + (quiet Slack default). Both cycle to 'generic' on first /verbose. """ hermes_home = tmp_path / "hermes" hermes_home.mkdir() @@ -184,10 +183,10 @@ class TestVerboseCommand: saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) platforms = saved["display"]["platforms"] - # Telegram: off -> new (platform default = off, tier-1 inbox override) - assert platforms["telegram"]["tool_progress"] == "new" - # Slack: off -> new (first /verbose cycle from quiet default) - assert platforms["slack"]["tool_progress"] == "new" + # Telegram: off -> generic (platform default = off, tier-1 inbox override) + assert platforms["telegram"]["tool_progress"] == "generic" + # Slack: off -> generic (first /verbose cycle from quiet default) + assert platforms["slack"]["tool_progress"] == "generic" @pytest.mark.asyncio async def test_no_config_file_returns_disabled(self, tmp_path, monkeypatch): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8b2ab195b02..25f3706b0fe 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2385,7 +2385,7 @@ def _load_memory_notifications() -> str: def _load_tool_progress_mode() -> str: env = os.environ.get("HERMES_TUI_TOOL_PROGRESS", "").strip().lower() - if env in {"off", "new", "all", "verbose"}: + if env in {"off", "generic", "new", "all", "verbose"}: return env raw = (_load_cfg().get("display") or {}).get("tool_progress", "all") if raw is False: @@ -2393,7 +2393,7 @@ def _load_tool_progress_mode() -> str: if raw is True: return "all" mode = str(raw or "all").strip().lower() - return mode if mode in {"off", "new", "all", "verbose"} else "all" + return mode if mode in {"off", "generic", "new", "all", "verbose"} else "all" def _load_enabled_toolsets() -> list[str] | None: @@ -9930,7 +9930,7 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"key": key, "value": raw}) if key == "verbose": - cycle = ["off", "new", "all", "verbose"] + cycle = ["off", "generic", "new", "all", "verbose"] cur = ( session.get("tool_progress_mode", _load_tool_progress_mode()) if session