feat(gateway): add opt-in 'latency' runtime footer field - #71990
Conversation
## Symptom
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.
## Change
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).
## Byte stability
`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.
## Footprint
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
`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.
|
Thanks for the focused, opt-in implementation. Current Automated hermes-sweeper review. |
|
Thanks @Kyzcreig — reviewed and salvaged as-is in #77611 (your commits, authorship preserved; only positional conflict resolution against current main, zero content changes). The opt-in design (latency NOT in the default fields) matches the footer byte-stability doctrine exactly, and the measurement placement (wrapping only the _run_agent await) is correct. Closing in favor of the salvage. |
Symptom
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 dimension they most often care about, and "was that slow, or did I imagine it?" is unanswerable after the fact.Change
Adds a
latencyfield to the existing footer machinery, rendering the wall-clock duration of the agent run:Format:
<1s/22s/1m05s.gateway/run.pymeasures withtime.monotonic()immediately around theself._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(nottime.time()) so it is immune to wall-clock/NTP adjustment mid-turn.Byte stability — the part I'd most like reviewed
latencyis deliberately NOT in_DEFAULT_FIELDS. It is opt-in viadisplay.runtime_footer.fields. Every footer that renders today — including for users who never touchfields— renders byte-identically after this change.Per
AGENTS.md("a system prompt that is byte-stable for the life of a conversation" / "per-conversation prompt caching is sacred"), I treated that as a hard constraint rather than a nicety, and enforced it with tests rather than asserting it in prose:test_latency_not_in_default_fieldstest_resolve_footer_config_default_fields_exclude_latencytest_default_footer_renders_byte_identicallyturn_secondstest_default_build_footer_line_ignores_turn_secondsbuild_footer_line(...) == build_footer_line(..., turn_seconds=125.0)The third one is the load-bearing one: it passes a real measured duration and asserts the default-configured footer still doesn't show it. So the guarantee is "opt-in by construction," not "opt-in because no caller happens to pass the value yet."
Adding
latencyto_DEFAULT_FIELDSfails 11 of these tests. I verified that by actually doing it, not by inspection.Footprint
display.runtime_footer.fieldslist._format_latency), one keyword arg threaded through the two existing footer functions, 3 lines ingateway/run.py.turn_secondsdefaults toNone, and the field is skipped when it isNoneor negative — so any call site that doesn't measure timing keeps working unchanged, and a future call site can opt in by passing one argument.On "sibling call paths included"
I checked for other places that render this footer, since fixing a whole class rather than one site is the standard here. On current
mainbuild_footer_line/format_runtime_footerhave exactly two consumers:gateway/run.py:14175— the real per-turn render. Wired.gateway/slash_commands.py:3426— the/footer ontoggle preview, which passescontext_tokens=0, context_length=Nonebecause no turn has run. Deliberately not wired: there is no turn to time, and inventing a duration for a preview line would be misleading.hermes_cli/cli_commands_mixin.pyonly readsenabledfor the CLI/footertoggle; it doesn't render a footer. So this is the complete set — there is no third path silently missing the field.Tests
tests/gateway/test_runtime_footer.py(+185): a_format_latencyboundary table (sub-second, rounding either side of 59.4/59.6, them{:02d}szero-pad, 60m), the render/skip/opt-in matrix, field-order placement,build_footer_linethreading, and the byte-stability block above.RED-proved by mutation — each of these was applied and the listed tests failed:
latencyadded to_DEFAULT_FIELDSturn_seconds is not None and >= 0guardf"{m}m{sec:02d}s"→f"{m}m{sec}s"build_footer_linestops threadingturn_secondsRun hermetically against a temp
HERMES_HOME.Docs
website/docs/user-guide/configuration.md— replaced the stale inline# supported fields: model, context_pct, cwdcomment with a real field table, and stated explicitly that the default set is["model", "context_pct", "cwd"]and thatlatencyis opt-in.(Drive-by while there: the existing example footer in that section was
— claude-opus-4.7 · 12 tool calls · 2m 14s · $0.042, which doesn't match anything the code can render — notool callsor cost field exists. Replaced with real output. Happy to split that into its own commit if you'd prefer.)Relationship to #47600
This PR stands alone against
mainand does not depend on #47600. They can merge in either order.#47600 (
provider_model,context_full,reasoning) touches the same file and the same two functions, so expect a small textual conflict if both land — specifically in three spots: the module docstring's field list, theformat_runtime_footersignature's keyword block, and theelif field == ...chain. All three are additive; the resolution is a union in every case (keep both sets of fields), with no semantic decision to make.I kept them separate on purpose rather than stacking:
latencyis self-contained and reviewable in a couple of minutes, and stacking would have made it un-mergeable until #47600 is resolved. If you'd rather review them as one unit, say so and I'll rebase this onto #47600 and re-push.Both PRs share the same byte-stability property — after the review round on #47600 I reverted its
_DEFAULT_FIELDSchange too, so all new footer fields across both PRs are opt-in and no existing footer's bytes move.