mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-13 14:02:16 +00:00
perf(cli): TTFT round 2 — live reasoning by default, partial-line streaming, prompt-build cache, stale budget-warning docs (#59389)
Follow-up to #59332 targeting the remaining PERCEIVED first-token latency
(the wire streaming was already per-token; these fix what the user sees):
1. display.show_reasoning default ON. On thinking models the reasoning
phase streams for tens of seconds; with the display off users stare
at a spinner the whole time and read it as a stall. Flipped in
DEFAULT_CONFIG, load_cli_config defaults, tui_gateway raw-YAML
fallbacks, and the hermes setup status line (all four read sites kept
in sync). Gateway per-platform defaults intentionally stay off —
messaging chats shouldn't fill with thinking text. /reasoning hide
still turns it off and persists.
2. Response box force-flushes long partial lines. _emit_stream_text only
painted on newline, so a response opening with a long paragraph
stayed invisible until the first \n — seconds of blank box. Now
partial lines wrap at terminal width and paint as tokens arrive
(mirrors the reasoning box's 80-char force-flush that existed since
day one). Table blocks remain batch-aligned; no content loss at wrap
boundaries (regression tests added).
3. hermes_time timezone resolution uses read_raw_config (mtime-cached +
libyaml C loader) instead of a raw yaml.safe_load of config.yaml
(~110-140ms measured) inside the FIRST system prompt build. First
build drops 320ms -> ~155ms on a 200-skill install.
4. Stale docs: configuration.md (en+zh) still documented the 70%/90%
[BUDGET WARNING] tool-result injections. Those were removed in April
2026 (c8aff7463) precisely because they hurt task completion; current
behavior is exhaustion-message + one grace call, no mid-loop
injection, no cache impact. Docs now describe reality.
Verified: token-count compression decisions already use API-reported
last_prompt_tokens (rough estimators are preflight-only and cost ~1.7ms
even on 1.7MB histories — not worth touching).
This commit is contained in:
parent
4976d3c38d
commit
0800af0b8a
8 changed files with 163 additions and 34 deletions
|
|
@ -550,7 +550,10 @@ class TestConfigDefault(unittest.TestCase):
|
|||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
display = DEFAULT_CONFIG.get("display", {})
|
||||
self.assertIn("show_reasoning", display)
|
||||
self.assertFalse(display["show_reasoning"])
|
||||
# Default ON (July 2026 TTFT-perception change): thinking models
|
||||
# stream reasoning for tens of seconds; hiding it left users staring
|
||||
# at a spinner. The key must exist and be a bool.
|
||||
self.assertTrue(display["show_reasoning"])
|
||||
|
||||
|
||||
class TestCommandRegistered(unittest.TestCase):
|
||||
|
|
|
|||
94
tests/cli/test_stream_partial_line_flush.py
Normal file
94
tests/cli/test_stream_partial_line_flush.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Streaming display force-flush: long partial lines must paint before the
|
||||
first newline arrives (TTFT-perception fix, July 2026).
|
||||
|
||||
Previously ``_emit_stream_text`` only emitted on ``"\\n"``, so a response
|
||||
opening with a long paragraph stayed invisible until the model produced a
|
||||
newline — seconds of blank box on slow models. Now partial lines are
|
||||
force-flushed at terminal width (mirroring the reasoning box's 80-char rule).
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
|
||||
def _strip_ansi(s: str) -> str:
|
||||
return re.sub(r"\x1b\[[0-9;]*m", "", s)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_stub(monkeypatch):
|
||||
from cli import HermesCLI
|
||||
import cli as climod
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.show_reasoning = False
|
||||
cli.final_response_markdown = "raw"
|
||||
cli.show_timestamps = False
|
||||
cli._reset_stream_state()
|
||||
|
||||
emitted = []
|
||||
monkeypatch.setattr(climod, "_cprint", lambda s: emitted.append(s))
|
||||
# Deterministic width regardless of the test runner's terminal
|
||||
monkeypatch.setattr(climod, "_terminal_width_for_streaming", lambda: 74)
|
||||
return cli, emitted
|
||||
|
||||
|
||||
class TestPartialLineForceFlush:
|
||||
def test_long_paragraph_paints_before_first_newline(self, cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
text = (
|
||||
"This is a long opening paragraph that would previously sit "
|
||||
"invisible in the buffer until the model finally produced a "
|
||||
"newline character, which on a slow model could take seconds. "
|
||||
) * 3
|
||||
for i in range(0, len(text), 12):
|
||||
cli._stream_delta(text[i : i + 12])
|
||||
# Box header + several wrapped lines painted with NO newline seen yet
|
||||
assert len(emitted) > 3
|
||||
|
||||
def test_no_content_lost_across_wraps(self, cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
words = [f"word{i}" for i in range(120)]
|
||||
text = " ".join(words)
|
||||
for i in range(0, len(text), 7):
|
||||
cli._stream_delta(text[i : i + 7])
|
||||
cli._flush_stream()
|
||||
plain = " ".join(_strip_ansi("\n".join(emitted)).split())
|
||||
for w in words:
|
||||
assert w in plain, f"lost {w} at a wrap boundary"
|
||||
|
||||
def test_short_partial_stays_buffered(self, cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
cli._stream_delta("short line, no newline")
|
||||
# Under wrap width: the box header may open, but the text itself
|
||||
# stays buffered until a newline or the width threshold.
|
||||
plain = _strip_ansi("\n".join(emitted))
|
||||
assert "short line" not in plain
|
||||
assert cli._stream_buf == "short line, no newline"
|
||||
|
||||
def test_table_rows_not_force_flushed(self, cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
# A long partial table row must stay buffered for block realignment
|
||||
row = "| " + " | ".join(f"cell{i}" for i in range(20)) + " |"
|
||||
cli._stream_delta(row) # no newline
|
||||
plain = _strip_ansi("\n".join(emitted))
|
||||
assert "cell19" not in plain
|
||||
|
||||
def test_newline_lines_still_emit_normally(self, cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
cli._stream_delta("line one\nline two\n")
|
||||
plain = _strip_ansi("\n".join(emitted))
|
||||
assert "line one" in plain
|
||||
assert "line two" in plain
|
||||
|
||||
def test_unbreakable_run_hard_wraps(self, cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
blob = "x" * 300 # no spaces
|
||||
cli._stream_delta(blob)
|
||||
cli._flush_stream()
|
||||
plain = _strip_ansi("\n".join(emitted))
|
||||
assert plain.count("x") == 300
|
||||
Loading…
Add table
Add a link
Reference in a new issue