diff --git a/cli.py b/cli.py index da7a745cbc..5cef4068bb 100644 --- a/cli.py +++ b/cli.py @@ -6232,6 +6232,8 @@ class HermesCLI: self._console_print(f" Status bar {state}") elif canonical == "verbose": self._toggle_verbose() + elif canonical == "footer": + self._handle_footer_command(cmd_original) elif canonical == "yolo": self._toggle_yolo() elif canonical == "reasoning": @@ -6859,6 +6861,58 @@ class HermesCLI: if self._apply_tui_skin_style(): print(" Prompt + TUI colors updated.") + def _handle_footer_command(self, cmd_original: str) -> None: + """Toggle or inspect ``display.runtime_footer.enabled`` from the CLI. + + Usage: + /footer → toggle + /footer on|off → explicit + /footer status → show current state + """ + from hermes_cli.config import load_config + from hermes_cli.colors import Colors as _Colors + + # Parse arg + arg = "" + try: + parts = (cmd_original or "").strip().split(None, 1) + if len(parts) > 1: + arg = parts[1].strip().lower() + except Exception: + arg = "" + + cfg = load_config() or {} + footer_cfg = ((cfg.get("display") or {}).get("runtime_footer") or {}) + current = bool(footer_cfg.get("enabled", False)) + fields = footer_cfg.get("fields") or ["model", "context_pct", "cwd"] + + if arg in ("status", "?"): + state = "ON" if current else "OFF" + _cprint( + f" {_Colors.BOLD}Runtime footer:{_Colors.RESET} {state}\n" + f" Fields: {', '.join(fields)}" + ) + return + + if arg in ("on", "enable", "true", "1"): + new_state = True + elif arg in ("off", "disable", "false", "0"): + new_state = False + elif arg == "": + new_state = not current + else: + _cprint(" Usage: /footer [on|off|status]") + return + + if save_config_value("display.runtime_footer.enabled", new_state): + state = ( + f"{_Colors.GREEN}ON{_Colors.RESET}" if new_state + else f"{_Colors.DIM}OFF{_Colors.RESET}" + ) + _cprint(f" Runtime footer: {state}") + else: + _cprint(" Failed to save runtime_footer setting to config.yaml") + def _toggle_verbose(self): """Cycle tool progress mode: off → new → all → verbose → off.""" cycle = ["off", "new", "all", "verbose"] diff --git a/gateway/run.py b/gateway/run.py index aa2ac75e84..886c39d1e9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3905,6 +3905,8 @@ class GatewayRunner: return await self._handle_yolo_command(event) if _cmd_def_inner.name == "verbose": return await self._handle_verbose_command(event) + if _cmd_def_inner.name == "footer": + return await self._handle_footer_command(event) # Gateway-handled info/control commands with dedicated # running-agent handlers. @@ -4125,6 +4127,9 @@ class GatewayRunner: if canonical == "verbose": return await self._handle_verbose_command(event) + if canonical == "footer": + return await self._handle_footer_command(event) + if canonical == "yolo": return await self._handle_yolo_command(event) @@ -5224,6 +5229,27 @@ class GatewayRunner: display_reasoning = last_reasoning.strip() response = f"💭 **Reasoning:**\n```\n{display_reasoning}\n```\n\n{response}" + # Runtime-metadata footer — only on the FINAL message of the turn. + # Off by default (display.runtime_footer.enabled=false). When + # streaming already delivered the body, we can't mutate the sent + # text, so we fire a separate trailing send below. + _footer_line = "" + try: + from gateway.runtime_footer import build_footer_line as _bfl + _footer_line = _bfl( + user_config=_load_gateway_config(), + platform_key=_platform_config_key(source.platform), + model=agent_result.get("model"), + context_tokens=agent_result.get("last_prompt_tokens", 0) or 0, + context_length=agent_result.get("context_length") or None, + cwd=os.environ.get("TERMINAL_CWD", ""), + ) + except Exception as _footer_err: + logger.debug("runtime_footer build failed: %s", _footer_err) + _footer_line = "" + if _footer_line and response and not agent_result.get("already_sent"): + response = f"{response}\n\n{_footer_line}" + # Emit agent:end hook await self.hooks.emit("agent:end", { **hook_ctx, @@ -5394,6 +5420,17 @@ class GatewayRunner: await self._deliver_media_from_response( response, event, _media_adapter, ) + # Streaming already delivered the body text, but the footer was + # intentionally held back (see the `not already_sent` gate above). + # Send it now as a small trailing message so Telegram/Discord/etc. + # still surface the runtime metadata on the final reply. + if _footer_line: + try: + _foot_adapter = self.adapters.get(source.platform) + if _foot_adapter: + await _foot_adapter.send(source.chat_id, _footer_line) + except Exception as _e: + logger.debug("trailing footer send failed: %s", _e) return None return response @@ -7451,6 +7488,98 @@ class GatewayRunner: logger.warning("Failed to save tool_progress mode: %s", e) return f"{descriptions[new_mode]}\n_(could not save to config: {e})_" + async def _handle_footer_command(self, event: MessageEvent) -> str: + """Handle /footer command — toggle the runtime-metadata footer. + + Usage: + /footer → toggle on/off + /footer on → enable globally + /footer off → disable globally + /footer status → show current state + fields + + The footer is saved to ``display.runtime_footer.enabled`` (global). + Per-platform overrides under ``display.platforms..runtime_footer`` + are respected but not modified here — edit config.yaml directly for + per-platform control. + """ + import yaml + from gateway.runtime_footer import resolve_footer_config + + config_path = _hermes_home / "config.yaml" + platform_key = _platform_config_key(event.source.platform) + + # --- parse argument ------------------------------------------------- + arg = "" + try: + text = (getattr(event, "message", None) or "").strip() + if text.startswith("/"): + parts = text.split(None, 1) + if len(parts) > 1: + arg = parts[1].strip().lower() + except Exception: + arg = "" + + # --- load config ---------------------------------------------------- + user_config: dict = {} + try: + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + user_config = yaml.safe_load(f) or {} + except Exception as e: + return f"⚠️ Could not read config.yaml: {e}" + + effective = resolve_footer_config(user_config, platform_key) + + if arg in ("status", "?"): + state = "ON" if effective["enabled"] else "OFF" + fields = ", ".join(effective.get("fields") or []) + return ( + f"📎 Runtime footer: **{state}**\n" + f"Fields: `{fields}`\n" + f"Platform: `{platform_key}`" + ) + + if arg in ("on", "enable", "true", "1"): + new_state = True + elif arg in ("off", "disable", "false", "0"): + new_state = False + elif arg == "": + new_state = not effective["enabled"] + else: + return "Usage: `/footer [on|off|status]`" + + # --- write global flag --------------------------------------------- + try: + if not isinstance(user_config.get("display"), dict): + user_config["display"] = {} + display = user_config["display"] + if not isinstance(display.get("runtime_footer"), dict): + display["runtime_footer"] = {} + display["runtime_footer"]["enabled"] = new_state + atomic_yaml_write(config_path, user_config) + except Exception as e: + logger.warning("Failed to save runtime_footer.enabled: %s", e) + return f"⚠️ Could not save config: {e}" + + state = "ON" if new_state else "OFF" + example = "" + if new_state: + # Show a preview using current agent state if available. + from gateway.runtime_footer import format_runtime_footer + preview = format_runtime_footer( + model=_resolve_gateway_model(user_config) or None, + context_tokens=0, + context_length=None, + fields=effective.get("fields") or ["model", "context_pct", "cwd"], + ) + if preview: + example = f"\nExample: `{preview}`" + return ( + f"📎 Runtime footer: **{state}**" + f"{example}\n" + f"_(saved globally — takes effect on next message)_" + ) + async def _handle_compress_command(self, event: MessageEvent) -> str: """Handle /compress command -- manually compress conversation context. @@ -10810,11 +10939,13 @@ class GatewayRunner: _last_prompt_toks = 0 _input_toks = 0 _output_toks = 0 + _context_length = 0 _agent = agent_holder[0] if _agent and hasattr(_agent, "context_compressor"): _last_prompt_toks = getattr(_agent.context_compressor, "last_prompt_tokens", 0) _input_toks = getattr(_agent, "session_prompt_tokens", 0) _output_toks = getattr(_agent, "session_completion_tokens", 0) + _context_length = getattr(_agent.context_compressor, "context_length", 0) or 0 _resolved_model = getattr(_agent, "model", None) if _agent else None if not final_response: @@ -10831,6 +10962,7 @@ class GatewayRunner: "input_tokens": _input_toks, "output_tokens": _output_toks, "model": _resolved_model, + "context_length": _context_length, } # Scan tool results for MEDIA: tags that need to be delivered @@ -10935,6 +11067,7 @@ class GatewayRunner: "input_tokens": _input_toks, "output_tokens": _output_toks, "model": _resolved_model, + "context_length": _context_length, "session_id": effective_session_id, "response_previewed": result.get("response_previewed", False), } diff --git a/gateway/runtime_footer.py b/gateway/runtime_footer.py new file mode 100644 index 0000000000..9d3fea2523 --- /dev/null +++ b/gateway/runtime_footer.py @@ -0,0 +1,150 @@ +"""Gateway runtime-metadata footer. + +Renders a compact footer showing runtime state (model, context %, cwd) and +appends it to the FINAL message of an agent turn when enabled. Off by default +to keep replies minimal. + +Config (``~/.hermes/config.yaml``):: + + display: + runtime_footer: + enabled: true # off by default + fields: [model, context_pct, cwd] # order shown; drop any to hide + +Per-platform overrides live under ``display.platforms..runtime_footer``. +Users can toggle the global setting with ``/footer on|off`` from both the CLI +and any gateway platform. + +The footer is appended to the final response text in ``gateway/run.py`` right +before returning the response to the adapter send path — so it only lands on +the final message a user sees, not on tool-progress updates or streaming +partials. When streaming is on and the final text has already been delivered +piecemeal, the footer is sent as a separate trailing message via +``send_trailing_footer()``. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Iterable, Optional + +_DEFAULT_FIELDS: tuple[str, ...] = ("model", "context_pct", "cwd") +_SEP = " · " + + +def _home_relative_cwd(cwd: str) -> str: + """Return *cwd* with ``$HOME`` collapsed to ``~``. Empty string if unset.""" + if not cwd: + return "" + try: + home = os.path.expanduser("~") + p = os.path.abspath(cwd) + if home and (p == home or p.startswith(home + os.sep)): + return "~" + p[len(home):] + return p + except Exception: + return cwd + + +def _model_short(model: Optional[str]) -> str: + """Drop ``vendor/`` prefix for readability (``openai/gpt-5.4`` → ``gpt-5.4``).""" + if not model: + return "" + return model.rsplit("/", 1)[-1] + + +def resolve_footer_config( + user_config: dict[str, Any] | None, + platform_key: str | None = None, +) -> dict[str, Any]: + """Resolve effective runtime-footer config for *platform_key*. + + Merge order (later wins): + 1. Built-in defaults (enabled=False) + 2. ``display.runtime_footer`` + 3. ``display.platforms..runtime_footer`` + """ + resolved = {"enabled": False, "fields": list(_DEFAULT_FIELDS)} + cfg = (user_config or {}).get("display") or {} + + global_cfg = cfg.get("runtime_footer") + if isinstance(global_cfg, dict): + if "enabled" in global_cfg: + resolved["enabled"] = bool(global_cfg.get("enabled")) + if isinstance(global_cfg.get("fields"), list) and global_cfg["fields"]: + resolved["fields"] = [str(f) for f in global_cfg["fields"]] + + if platform_key: + platforms = cfg.get("platforms") or {} + plat_cfg = platforms.get(platform_key) + if isinstance(plat_cfg, dict): + plat_footer = plat_cfg.get("runtime_footer") + if isinstance(plat_footer, dict): + if "enabled" in plat_footer: + resolved["enabled"] = bool(plat_footer.get("enabled")) + if isinstance(plat_footer.get("fields"), list) and plat_footer["fields"]: + resolved["fields"] = [str(f) for f in plat_footer["fields"]] + + return resolved + + +def format_runtime_footer( + *, + model: Optional[str], + context_tokens: int, + context_length: Optional[int], + cwd: Optional[str] = None, + fields: Iterable[str] = _DEFAULT_FIELDS, +) -> str: + """Render the footer line, or return "" if no fields have data. + + Fields are skipped silently when their underlying data is missing — a + partially-populated footer is better than a line with ``?%`` or empty slots. + """ + parts: list[str] = [] + for field in fields: + if field == "model": + m = _model_short(model) + if m: + parts.append(m) + elif field == "context_pct": + if context_length and context_length > 0 and context_tokens >= 0: + pct = max(0, min(100, round((context_tokens / context_length) * 100))) + parts.append(f"{pct}%") + elif field == "cwd": + rel = _home_relative_cwd(cwd or os.environ.get("TERMINAL_CWD", "")) + if rel: + parts.append(rel) + # Unknown field names are silently ignored. + + if not parts: + return "" + return _SEP.join(parts) + + +def build_footer_line( + *, + user_config: dict[str, Any] | None, + platform_key: str | None, + model: Optional[str], + context_tokens: int, + context_length: Optional[int], + cwd: Optional[str] = None, +) -> str: + """Top-level entry point used by gateway/run.py. + + Returns the footer text (empty string when disabled or no data). Callers + append this to the final response themselves, preserving a single blank + line of separation. + """ + cfg = resolve_footer_config(user_config, platform_key) + if not cfg.get("enabled"): + return "" + return format_runtime_footer( + model=model, + context_tokens=context_tokens, + context_length=context_length, + cwd=cwd, + fields=cfg.get("fields") or _DEFAULT_FIELDS, + ) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index f001cf726a..d83f2ac9eb 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -115,6 +115,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose", "Configuration", cli_only=True, gateway_config_gate="display.tool_progress_command"), + CommandDef("footer", "Toggle gateway runtime-metadata footer on final replies", + "Configuration", args_hint="[on|off|status]", + subcommands=("on", "off", "status")), CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)", "Configuration"), CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8cec3f72ce..e94441373d 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -707,6 +707,14 @@ DEFAULT_CONFIG = { "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) "platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}} + # Gateway runtime-metadata footer appended to the FINAL message of a turn + # (disabled by default to keep replies minimal). When enabled, renders + # e.g. `model · 68% · ~/projects/hermes`. Per-platform overrides go under + # display.platforms..runtime_footer. + "runtime_footer": { + "enabled": False, + "fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide + }, }, # Web dashboard settings diff --git a/tests/gateway/test_runtime_footer.py b/tests/gateway/test_runtime_footer.py new file mode 100644 index 0000000000..9c36706f71 --- /dev/null +++ b/tests/gateway/test_runtime_footer.py @@ -0,0 +1,262 @@ +"""Unit tests for gateway.runtime_footer — the opt-in runtime-metadata footer +appended to final gateway replies.""" + +from __future__ import annotations + +import os + +import pytest + +from gateway.runtime_footer import ( + _home_relative_cwd, + _model_short, + build_footer_line, + format_runtime_footer, + resolve_footer_config, +) + + +# --------------------------------------------------------------------------- +# _model_short + _home_relative_cwd +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "model,expected", + [ + ("openai/gpt-5.4", "gpt-5.4"), + ("anthropic/claude-sonnet-4.6", "claude-sonnet-4.6"), + ("gpt-5.4", "gpt-5.4"), + ("", ""), + (None, ""), + ], +) +def test_model_short_drops_vendor_prefix(model, expected): + assert _model_short(model) == expected + + +def test_home_relative_cwd_collapses_home(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + sub = tmp_path / "projects" / "hermes" + sub.mkdir(parents=True) + result = _home_relative_cwd(str(sub)) + assert result == "~/projects/hermes" + + +def test_home_relative_cwd_leaves_abs_path_alone(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "other")) + result = _home_relative_cwd(str(tmp_path / "outside" / "dir")) + assert result == str(tmp_path / "outside" / "dir") + + +def test_home_relative_cwd_empty_returns_empty(): + assert _home_relative_cwd("") == "" + + +# --------------------------------------------------------------------------- +# format_runtime_footer +# --------------------------------------------------------------------------- + +def test_format_footer_all_fields(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "projects" / "hermes")) + (tmp_path / "projects" / "hermes").mkdir(parents=True) + out = format_runtime_footer( + model="openrouter/openai/gpt-5.4", + context_tokens=68000, + context_length=100000, + cwd=None, # falls back to TERMINAL_CWD env var + fields=("model", "context_pct", "cwd"), + ) + assert out == "gpt-5.4 · 68% · ~/projects/hermes" + + +def test_format_footer_skips_missing_context_length(): + out = format_runtime_footer( + model="openai/gpt-5.4", + context_tokens=500, + context_length=None, + cwd="/tmp/wd", + fields=("model", "context_pct", "cwd"), + ) + # context_pct dropped silently; no "?%" artifact + assert "%" not in out + assert "gpt-5.4" in out + assert "/tmp/wd" in out + + +def test_format_footer_context_pct_clamped_to_100(): + out = format_runtime_footer( + model="m", + context_tokens=500_000, # way over + context_length=100_000, + cwd="", + fields=("context_pct",), + ) + assert out == "100%" + + +def test_format_footer_context_pct_never_negative(): + out = format_runtime_footer( + model="m", + context_tokens=-50, + context_length=100, + cwd="", + fields=("context_pct",), + ) + # Negative input => no field emitted (we require context_tokens >= 0) + assert out == "" + + +def test_format_footer_empty_fields_returns_empty(): + out = format_runtime_footer( + model="m", context_tokens=0, context_length=100, + cwd="/x", fields=(), + ) + assert out == "" + + +def test_format_footer_drops_cwd_when_empty(monkeypatch): + monkeypatch.delenv("TERMINAL_CWD", raising=False) + out = format_runtime_footer( + model="openai/gpt-5.4", + context_tokens=50, context_length=100, + cwd="", + fields=("model", "context_pct", "cwd"), + ) + # cwd silently dropped; model + pct remain + assert out == "gpt-5.4 · 50%" + + +def test_format_footer_custom_field_order(): + out = format_runtime_footer( + model="openai/gpt-5.4", + context_tokens=50, context_length=100, + cwd="/opt/project", + fields=("context_pct", "model"), # swapped + no cwd + ) + assert out == "50% · gpt-5.4" + + +def test_format_footer_unknown_field_silently_ignored(): + out = format_runtime_footer( + model="openai/gpt-5.4", + context_tokens=50, context_length=100, + cwd="/x", + fields=("model", "bogus", "context_pct"), + ) + assert out == "gpt-5.4 · 50%" + + +# --------------------------------------------------------------------------- +# resolve_footer_config +# --------------------------------------------------------------------------- + +def test_resolve_defaults_off_empty_config(): + cfg = resolve_footer_config({}, "telegram") + assert cfg == {"enabled": False, "fields": ["model", "context_pct", "cwd"]} + + +def test_resolve_global_enable(): + user = {"display": {"runtime_footer": {"enabled": True}}} + cfg = resolve_footer_config(user, "telegram") + assert cfg["enabled"] is True + assert cfg["fields"] == ["model", "context_pct", "cwd"] + + +def test_resolve_platform_override_wins(): + user = { + "display": { + "runtime_footer": {"enabled": True, "fields": ["model"]}, + "platforms": { + "slack": {"runtime_footer": {"enabled": False}}, + }, + }, + } + # Telegram picks up the global enable + assert resolve_footer_config(user, "telegram")["enabled"] is True + # Slack overrides to off + assert resolve_footer_config(user, "slack")["enabled"] is False + + +def test_resolve_platform_can_add_fields_only(): + user = { + "display": { + "runtime_footer": {"enabled": True}, + "platforms": { + "discord": {"runtime_footer": {"fields": ["context_pct"]}}, + }, + }, + } + tg = resolve_footer_config(user, "telegram") + assert tg["enabled"] is True + assert tg["fields"] == ["model", "context_pct", "cwd"] + dc = resolve_footer_config(user, "discord") + assert dc["enabled"] is True + assert dc["fields"] == ["context_pct"] + + +def test_resolve_ignores_malformed_config(): + # Non-dict runtime_footer shouldn't crash + user = {"display": {"runtime_footer": "on"}} + cfg = resolve_footer_config(user, "telegram") + assert cfg["enabled"] is False + + +# --------------------------------------------------------------------------- +# build_footer_line — top-level entry point used by gateway/run.py +# --------------------------------------------------------------------------- + +def test_build_footer_empty_when_disabled(): + out = build_footer_line( + user_config={}, + platform_key="telegram", + model="openai/gpt-5.4", + context_tokens=10, context_length=100, + cwd="/tmp", + ) + assert out == "" + + +def test_build_footer_returns_rendered_when_enabled(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + out = build_footer_line( + user_config={"display": {"runtime_footer": {"enabled": True}}}, + platform_key="telegram", + model="openai/gpt-5.4", + context_tokens=25, context_length=100, + cwd=str(tmp_path / "proj"), + ) + (tmp_path / "proj").mkdir(exist_ok=True) + assert "gpt-5.4" in out + assert "25%" in out + + +def test_build_footer_per_platform_off_suppresses(): + user = { + "display": { + "runtime_footer": {"enabled": True}, + "platforms": {"slack": {"runtime_footer": {"enabled": False}}}, + }, + } + out = build_footer_line( + user_config=user, + platform_key="slack", + model="openai/gpt-5.4", + context_tokens=10, context_length=100, + cwd="/tmp", + ) + assert out == "" + + +def test_build_footer_no_data_returns_empty_even_when_enabled(): + # Enabled, but context_length is None AND cwd empty AND model empty ⇒ no fields + out = build_footer_line( + user_config={"display": {"runtime_footer": {"enabled": True}}}, + platform_key="telegram", + model="", + context_tokens=0, context_length=None, + cwd="", + ) + # With no TERMINAL_CWD env either + if not os.environ.get("TERMINAL_CWD"): + assert out == ""