The runtime footer (`/footer`) shows what model ran and how full the context
is, but not how long the turn took. On a messaging platform there is no
progress bar and no shell timer — a turn that took 4 seconds and one that took
four minutes produce visually identical replies. Users comparing models,
providers, or reasoning levels have no at-a-glance signal for the one
dimension they most often care about, and "was that slow or did I imagine it?"
is unanswerable after the fact.
Adds a `latency` field to the existing footer machinery, rendering the
wall-clock duration of the agent run: `<1s`, `22s`, `1m05s`.
`gateway/run.py` measures with `time.monotonic()` immediately around the
`self._run_agent(...)` await in `_handle_message_with_agent` — the same
function that already builds the footer, so the value is the user-perceived
turn duration (monotonic, so it is immune to wall-clock/NTP adjustment).
`latency` is deliberately NOT in `_DEFAULT_FIELDS`. It is opt-in via
`display.runtime_footer.fields`. Every existing footer — and every footer a
user has today without touching config — renders byte-identically.
This is enforced by tests, not just asserted:
- `test_latency_not_in_default_fields` pins the default tuple.
- `test_resolve_footer_config_default_fields_exclude_latency` pins what
config resolution produces for an untouched config.
- `test_default_footer_renders_byte_identically` pins five exact output
strings for default-config renders **while supplying `turn_seconds`** —
proving that even when the caller measures timing, a default-configured
footer does not show it.
- `test_default_build_footer_line_ignores_turn_seconds` asserts
`build_footer_line(...) == build_footer_line(..., turn_seconds=125.0)`
under default fields.
Adding `latency` to `_DEFAULT_FIELDS` fails 11 of these tests.
No new config surface (reuses `display.runtime_footer.fields`), no new env
vars, no new core tool, no new model-facing schema. One new module-private
helper (`_format_latency`), one new keyword argument threaded through the two
existing footer functions, and 3 lines in `gateway/run.py`.
`turn_seconds` defaults to `None` and the field is skipped when it is `None`
or negative, so any call site that does not measure timing keeps working
unchanged.
`tests/gateway/test_runtime_footer.py` (+185): `_format_latency` boundary
table (sub-second, rounding at 59.4/59.6, the `m{:02d}s` zero-pad, 60m), the
render/skip/opt-in matrix, field-order placement, `build_footer_line`
threading, and the byte-stability block above.
RED-proved by mutation — each of these breaks tests:
- `latency` added to `_DEFAULT_FIELDS` → 11 failures
- dropping the `turn_seconds is not None and >= 0` guard → 2 failures
- `{sec:02d}` → `{sec}` → 6 failures
- `build_footer_line` not threading `turn_seconds` → 1 failure
51 passed in `tests/gateway/test_runtime_footer.py`; 54 passed across the
footer blast radius. `ruff check` clean.
182 lines
6.3 KiB
Python
182 lines
6.3 KiB
Python
"""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
|
|
|
|
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.<platform>.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 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.<platform_key>.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_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.
|
|
|
|
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 == "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:
|
|
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,
|
|
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"):
|
|
return ""
|
|
return format_runtime_footer(
|
|
model=model,
|
|
context_tokens=context_tokens,
|
|
context_length=context_length,
|
|
cwd=cwd,
|
|
turn_seconds=turn_seconds,
|
|
fields=cfg.get("fields") or _DEFAULT_FIELDS,
|
|
)
|