diff --git a/gateway/run.py b/gateway/run.py index 8c162f7dc9a46..a3c5d8536ef11 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -17365,6 +17365,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # below; a /new or another lifecycle transition may move # session_entry.session_id while the old run is still unwinding. _run_start_session_id = session_entry.session_id + _turn_started_monotonic = time.monotonic() agent_result = await self._run_agent( message=message_text, context_prompt=context_prompt, @@ -17380,6 +17381,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew persist_user_timestamp=persist_user_timestamp, message_type=event.message_type, ) + _turn_seconds = time.monotonic() - _turn_started_monotonic # Stop persistent typing indicator now that the agent is done. # Slack AI status is scoped to a thread/workspace, so preserve the @@ -17595,6 +17597,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew 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", ""), + turn_seconds=_turn_seconds, ) except Exception as _footer_err: logger.debug("runtime_footer build failed: %s", _footer_err) diff --git a/gateway/runtime_footer.py b/gateway/runtime_footer.py index 024cf74d68170..8719524d5a1dd 100644 --- a/gateway/runtime_footer.py +++ b/gateway/runtime_footer.py @@ -11,6 +11,15 @@ Config (``~/.hermes/config.yaml``):: enabled: true # off by default fields: [model, context_pct, cwd] # order shown; drop any to hide +Available fields: + model — bare model id, vendor prefix dropped (``gpt-5.4``) + context_pct — last-call context occupancy as a percent (``5%``) + latency — wall-clock duration of the turn (``22s``, ``1m05s``) + cwd — home-relative working dir (``~``) + +``latency`` is opt-in: it is NOT in the default field set, so a footer whose +``fields`` are unset renders exactly as before. + 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. @@ -88,12 +97,24 @@ def resolve_footer_config( return resolved +def _format_latency(seconds: float) -> str: + """Humanize a turn duration: ``<1s``, ``22s``, ``1m05s``.""" + if seconds < 1: + return "<1s" + total = int(round(seconds)) + if total < 60: + return f"{total}s" + m, sec = divmod(total, 60) + return f"{m}m{sec:02d}s" + + def format_runtime_footer( *, model: Optional[str], context_tokens: int, context_length: Optional[int], cwd: Optional[str] = None, + turn_seconds: Optional[float] = None, fields: Iterable[str] = _DEFAULT_FIELDS, ) -> str: """Render the footer line, or return "" if no fields have data. @@ -111,6 +132,11 @@ def format_runtime_footer( 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 == "latency": + # Wall-clock turn duration. Skipped when the caller supplied no + # timing (call sites that don't measure) or the value is negative. + if turn_seconds is not None and turn_seconds >= 0: + parts.append(_format_latency(turn_seconds)) elif field == "cwd": rel = _home_relative_cwd(cwd or os.environ.get("TERMINAL_CWD", "")) if rel: @@ -130,12 +156,17 @@ def build_footer_line( context_tokens: int, context_length: Optional[int], cwd: Optional[str] = None, + turn_seconds: Optional[float] = 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. + + ``turn_seconds`` is the wall-clock duration of the agent run, measured by + the caller with ``time.monotonic()``. Callers that don't measure it leave + it ``None`` and the ``latency`` field is skipped. """ cfg = resolve_footer_config(user_config, platform_key) if not cfg.get("enabled"): @@ -145,5 +176,6 @@ def build_footer_line( context_tokens=context_tokens, context_length=context_length, cwd=cwd, + turn_seconds=turn_seconds, fields=cfg.get("fields") or _DEFAULT_FIELDS, ) diff --git a/tests/gateway/test_runtime_footer.py b/tests/gateway/test_runtime_footer.py index 3845ffa933c37..1ca63a90c6184 100644 --- a/tests/gateway/test_runtime_footer.py +++ b/tests/gateway/test_runtime_footer.py @@ -133,3 +133,187 @@ def test_build_footer_per_platform_off_suppresses(): assert out == "" + +# --------------------------------------------------------------------------- +# latency — opt-in wall-clock turn duration +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "seconds,expected", + [ + (0.0, "<1s"), + (0.4, "<1s"), + (0.999, "<1s"), + (1.0, "1s"), + (22.0, "22s"), + (22.4, "22s"), + (59.4, "59s"), + (59.6, "1m00s"), + (60.0, "1m00s"), + (65.0, "1m05s"), + (125.0, "2m05s"), + (3600.0, "60m00s"), + ], +) +def test_format_latency(seconds, expected): + from gateway.runtime_footer import _format_latency + + assert _format_latency(seconds) == expected + + +def test_format_footer_latency_renders(): + out = format_runtime_footer( + model="m", + context_tokens=0, + context_length=None, + cwd="", + turn_seconds=22.0, + fields=("latency",), + ) + assert out == "22s" + + +def test_format_footer_latency_skipped_when_unmeasured(): + """A call site that doesn't measure timing leaves the field out entirely.""" + out = format_runtime_footer( + model="m", + context_tokens=0, + context_length=None, + cwd="", + turn_seconds=None, + fields=("latency",), + ) + assert out == "" + + +def test_format_footer_latency_skipped_when_negative(): + """A nonsensical (negative) duration is dropped rather than rendered.""" + out = format_runtime_footer( + model="m", + context_tokens=0, + context_length=None, + cwd="", + turn_seconds=-1.0, + fields=("latency",), + ) + assert out == "" + + +def test_format_footer_latency_zero_renders_sub_second(): + """Zero is a real measurement (a very fast turn), not missing data.""" + out = format_runtime_footer( + model="m", + context_tokens=0, + context_length=None, + cwd="", + turn_seconds=0.0, + fields=("latency",), + ) + assert out == "<1s" + + +def test_format_footer_latency_in_field_order(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + out = format_runtime_footer( + model="openai/gpt-5.4", + context_tokens=68_000, + context_length=100_000, + cwd=str(tmp_path), + turn_seconds=65.0, + fields=("model", "context_pct", "latency", "cwd"), + ) + assert out == "gpt-5.4 · 68% · 1m05s · ~" + + +def test_build_footer_line_threads_turn_seconds(monkeypatch): + monkeypatch.delenv("TERMINAL_CWD", raising=False) + out = build_footer_line( + user_config={ + "display": { + "runtime_footer": { + "enabled": True, + "fields": ["model", "latency"], + } + } + }, + platform_key="discord", + model="gpt-5.4", + context_tokens=0, + context_length=None, + cwd="", + turn_seconds=22.0, + ) + assert out == "gpt-5.4 · 22s" + + +# --------------------------------------------------------------------------- +# Byte-stability: `latency` is opt-in, so the DEFAULT footer is unchanged. +# +# Upstream doctrine: a system prompt / rendered surface must be byte-stable for +# the life of a conversation. Adding a field to _DEFAULT_FIELDS would silently +# change the footer text of every user who already enabled it. These tests pin +# the default set and the exact default-config output strings. +# --------------------------------------------------------------------------- + +_LEGACY_DEFAULT_FIELDS = ["model", "context_pct", "cwd"] + + +def test_latency_not_in_default_fields(): + from gateway.runtime_footer import _DEFAULT_FIELDS + + assert "latency" not in _DEFAULT_FIELDS + assert list(_DEFAULT_FIELDS) == _LEGACY_DEFAULT_FIELDS + + +def test_resolve_footer_config_default_fields_exclude_latency(): + assert resolve_footer_config({}, "telegram")["fields"] == _LEGACY_DEFAULT_FIELDS + assert resolve_footer_config( + {"display": {"runtime_footer": {"enabled": True}}}, "discord" + )["fields"] == _LEGACY_DEFAULT_FIELDS + + +@pytest.mark.parametrize( + "model,tokens,window,cwd,expected", + [ + ("openai/gpt-5.4", 50_247, 1_000_000, "/var/data", "gpt-5.4 · 5% · /var/data"), + ("claude-opus-4-8", 68_000, 100_000, "/var/data", "claude-opus-4-8 · 68% · /var/data"), + ("m", 0, None, "/var/data", "m · /var/data"), + ("", 10, 100, "/var/data", "10% · /var/data"), + ("m", 10, 100, "", "m · 10%"), + ], +) +def test_default_footer_renders_byte_identically( + monkeypatch, model, tokens, window, cwd, expected +): + """Default-config output is byte-for-byte what it was before `latency`. + + Note `turn_seconds` IS supplied — proving that even when the caller + measures timing, a default-configured footer does not show it. + """ + monkeypatch.delenv("TERMINAL_CWD", raising=False) + out = format_runtime_footer( + model=model, + context_tokens=tokens, + context_length=window, + cwd=cwd, + turn_seconds=22.0, + # fields deliberately NOT passed — exercises the default. + ) + assert out == expected + + +def test_default_build_footer_line_ignores_turn_seconds(monkeypatch): + """build_footer_line with default fields is unaffected by turn_seconds.""" + monkeypatch.delenv("TERMINAL_CWD", raising=False) + common = dict( + user_config={"display": {"runtime_footer": {"enabled": True}}}, + platform_key="discord", + model="openai/gpt-5.4", + context_tokens=50_247, + context_length=1_000_000, + cwd="/var/data", + ) + baseline = build_footer_line(**common) + with_timing = build_footer_line(**common, turn_seconds=125.0) + assert baseline == "gpt-5.4 · 5% · /var/data" + assert with_timing == baseline diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 85d321335fad7..b1fddd57c3fcc 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1732,9 +1732,20 @@ When `display.runtime_footer.enabled: true`, Hermes appends a small runtime-cont display: runtime_footer: enabled: true - fields: ["model", "context_pct", "cwd"] # supported fields: model, context_pct, cwd + fields: ["model", "context_pct", "cwd"] # order shown; drop any to hide ``` +Supported fields: + +| Field | Renders | Example | +| --- | --- | --- | +| `model` | Bare model id, vendor prefix dropped | `gpt-5.4` | +| `context_pct` | Last-call context occupancy as a percent | `5%` | +| `latency` | Wall-clock duration of the turn | `22s`, `1m05s` | +| `cwd` | Home-relative working directory | `~` | + +The default field set is `["model", "context_pct", "cwd"]`. `latency` is opt-in — add it to `fields` to use it. Fields whose data is unavailable are skipped silently rather than rendering an empty slot. + The `/footer` slash command toggles this at runtime in any session. Example footer appended to a Telegram/Discord/Slack reply: