mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
feat: add generic gateway status phrases
This commit is contained in:
parent
b0f2bdbe8b
commit
4bf5b563bd
13 changed files with 764 additions and 45 deletions
|
|
@ -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]
|
||||
|
|
|
|||
5
cli.py
5
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.",
|
||||
|
|
|
|||
198
gateway/assets/status_phrases.yaml
Normal file
198
gateway/assets/status_phrases.yaml
Normal file
|
|
@ -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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
106
gateway/run.py
106
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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
241
gateway/status_phrases.py
Normal file
241
gateway/status_phrases.py
Normal file
|
|
@ -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.<platform>.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
|
||||
|
|
@ -1773,6 +1773,15 @@ DEFAULT_CONFIG = {
|
|||
# applies where tool_progress is already enabled. Per-platform override
|
||||
# via display.platforms.<platform>.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.<platform>.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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
173
tests/gateway/test_status_phrases.py
Normal file
173
tests/gateway/test_status_phrases.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue