From a725a0f71c95a8bb6f1cb14e880f0291b80858c6 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 19:53:43 -0700 Subject: [PATCH] =?UTF-8?q?Release=20p1-batch:=20session=20cache=20cap=203?= =?UTF-8?q?00=E2=86=92100=20(#6362)=20+=20virtualization=20comment=20(#631?= =?UTF-8?q?8)=20(#6375)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(transcript): enable DOM virtualization by default (re-enable #4346 fix) (#6151) (#6155) * fix: revert DOM virtualization default to opt-in, fix gate RED (#6155) * fix(#6351): lower default session cache cap * Release p1-batch: session cache cap 300->100 (#6362) + virtualization comment (#6318) --------- Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: webtecnica Co-authored-by: Rod Boev Co-authored-by: nesquena-hermes --- CHANGELOG.md | 4 ++ README.md | 2 +- api/config.py | 6 ++- static/boot.js | 6 ++- tests/test_issue4765_sessions_lru_eviction.py | 42 +++++++++++++++---- 5 files changed, 46 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aebca53..a6141f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,16 @@ - **Internal: removed a dead `rowIndex` parameter from the settled-scene row projector.** `attachLiveStream`'s `pushRow` helper carried an unused positional index left over from before final-segment eligibility moved to a row-identity `WeakSet`; dropping it is a pure no-op refactor (differential execution confirms byte-identical settled scenes — ordering, dedupe, final-prefix suppression, and sequence assignment all unchanged). Thanks @webtecnica. (#6258) +- **Internal: clarified the transcript-virtualization boot comment (opt-in default retained).** The comment above the boot-time `_virtualizeTranscript` apply now records that the #4346 Phase B footer-jitter fix resolved the original scroll-up flicker root cause, while virtualization stays opt-in (default OFF) until battle-tested further. Zero behavior change — the apply predicate, settings-load fallback, checkbox, and server default all remain opt-in/OFF and in agreement. Thanks @webtecnica. (#6318, #6155) + - **Docs: reconciled the assistant-reply lifecycle RFCs/contracts with shipped behavior.** The Stable Assistant Turn Anchors and Live-to-Final RFCs, `docs/CONTRACTS.md`, and the Phase 0 architecture inventory were updated from "proposed/scaffold" framing to "accepted/implemented" to match what actually ships (single assistant-turn owner projecting one `activity_scene_v1` into Compact Worklog / Transparent Stream / Final answer). Documentation-only — no runtime behavior or CI-gate change; the one code touch is a corrected comment header in `static/assistant_turn_anchors.js`. Remaining hardening stays tracked under #3400. Thanks @franksong2702. (#6144) - **The busy-time send behavior is now called "Default message mode," and new installs default to Steer.** The Settings → Preferences control formerly labeled "Busy input mode" is renamed to "Default message mode," and a fresh install now defaults to **Steer** (inject a mid-turn correction without interrupting) instead of Queue. Your existing choice is preserved — if you ever saved settings, your current mode (Queue/Interrupt/Steer) is migrated as-is and unchanged; only never-configured installs pick up the new Steer default. The saved preference still survives a reload or a brief server outage (the localStorage mirror from the previous release is intact). Thanks @rodboev. (#5162, #5145) ### Fixed +- **Long-running installs use less memory: the default resident-session cache cap is lowered from 300 to 100.** The in-memory `SESSIONS` LRU (compact session objects) is a dominant contributor to WebUI RSS climbing over hours of continuous use; the shipped no-override default is now tuned for the common single-user install. Larger deployments can still raise it via `webui.sessions_cache_max` in `config.yaml` (preferred) or the `HERMES_WEBUI_SESSIONS_MAX` env fallback. Eviction safety is unchanged — only clean, persisted, non-active sessions are ever dropped, and an evicted session lazily reloads from its JSON sidecar on next access (no data loss). Thanks @rodboev. (#6362, #6351) + - **Completed image tool-results are compacted from persisted history, cutting long-session memory and save/load cost.** After a turn finishes, base64 image parts in persisted *tool* results are replaced with a small `[screenshot]` text placeholder, so JSON sidecar saves/loads and session API responses no longer scale with image bytes (the active model still gets the full image while the tool call is running). The transform preserves every non-image part in place (text, documents, provider-specific parts) without flattening or reordering, only touches `role: tool` messages (user attachments are untouched), runs on all live and settled persistence writebacks, and is idempotent. Thanks @sjungwon03. (#6315, #1217) - **Large embedded images no longer slow down API response redaction.** Native raster image data URIs (`data:image/png|jpeg|gif|webp|bmp;base64,…`) are skipped by the credential-redaction pass instead of being scanned byte-by-byte on every response — a multi-megabyte image is opaque binary, not text a credential regex should rewrite. The fast path only applies after the *entire* base64 payload is validated (canonical round-trip + structural walk), so a string that merely starts with an image header but carries trailing or appended text still goes through full redaction — a credential can never ride along in an image's tail. Non-image strings, and sibling secrets in the same payload, are unaffected. Thanks @inch772. (#6311) diff --git a/README.md b/README.md index 0b8b1e6e..e121ec52 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,7 @@ Full list of environment variables: | `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file | | `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable | | `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) | -| `HERMES_WEBUI_SESSIONS_MAX` | `300` | Legacy operator override for the max compact `Session` objects held in the in-memory LRU. Prefer the `webui.sessions_cache_max` key in `config.yaml` (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions — an evicted session lazily reloads from its JSON sidecar on next access | +| `HERMES_WEBUI_SESSIONS_MAX` | `100` | Legacy operator override for the max compact `Session` objects held in the in-memory LRU. Prefer the `webui.sessions_cache_max` key in `config.yaml` (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions; an evicted session lazily reloads from its JSON sidecar on next access | Extension deployments can inspect sanitized, authenticated diagnostics at `GET /api/extensions/status`; see [WebUI Extensions](docs/EXTENSIONS.md#diagnostics). diff --git a/api/config.py b/api/config.py index 8c417ee9..17010116 100644 --- a/api/config.py +++ b/api/config.py @@ -8502,16 +8502,18 @@ _INDEX_HTML_PATH = get_index_html_path() # ── Thread synchronisation ─────────────────────────────────────────────────── LOCK = threading.Lock() -# Max compact Session objects held in the in-memory LRU (issue #3506, #4765). +# Max compact Session objects held in the in-memory LRU (issue #3506, #4765, #6351). # Lighter than the agent cache (no live agent runtime), but still bounded so a # long-running self-hosted install cannot accumulate every session it ever # touched in RAM and eventually segfault (the #4765/#2233/#4633 crash cluster). +# The shipped default is tuned for the common single-user install; larger +# deployments can keep raising it through config.yaml or the legacy env fallback. # # Precedence for the effective cap is resolved by get_sessions_cache_max(): # 1. config.yaml webui.sessions_cache_max (preferred, no new env var) # 2. HERMES_WEBUI_SESSIONS_MAX env var (legacy operator override) # 3. DEFAULT_SESSIONS_CACHE_MAX (sane bounded default) -DEFAULT_SESSIONS_CACHE_MAX = 300 +DEFAULT_SESSIONS_CACHE_MAX = 100 SESSIONS_MAX = _env_int("HERMES_WEBUI_SESSIONS_MAX", DEFAULT_SESSIONS_CACHE_MAX) diff --git a/static/boot.js b/static/boot.js index 3b315435..3abfff6a 100644 --- a/static/boot.js +++ b/static/boot.js @@ -3225,8 +3225,10 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer; window._hideEmptyStateSuggestions=s.hide_empty_state_suggestions===true; applyEmptyStateSuggestionPref(); // #4343: transcript virtualization is EXPERIMENTAL/opt-IN (default OFF). - // It caused scroll-up flicker on long sessions, so it's off for everyone - // unless explicitly opted in; long transcripts render in full by default. + // #4346 Phase B (footer-jitter suppression during virtual-scroll + // measurement re-renders) resolved the scroll-up flicker root cause, + // but virtualization remains opt-in until battle-tested further. + // Users can explicitly enable it via Settings → virtualize_transcript. window._virtualizeTranscript=s.virtualize_transcript===true; window._showTps=!!s.show_tps; window._fadeTextEffect=!!s.fade_text_effect; diff --git a/tests/test_issue4765_sessions_lru_eviction.py b/tests/test_issue4765_sessions_lru_eviction.py index 116395ea..83efcbf7 100644 --- a/tests/test_issue4765_sessions_lru_eviction.py +++ b/tests/test_issue4765_sessions_lru_eviction.py @@ -111,19 +111,43 @@ def _insert(sid_session): # ─────────────────────────── config knob ──────────────────────────────────── +def test_default_sessions_cache_cap_is_100(): + """The shipped no-override session cache default is 100 (#6351).""" + from api import config as _cfg + + assert _cfg.DEFAULT_SESSIONS_CACHE_MAX == 100 + + def test_cache_cap_reads_config_yaml_key(): """The cap is configurable via config.yaml webui.sessions_cache_max (#4765).""" from api import config as _cfg - assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": 42}}) == 42 - # Invalid / missing values must fall back, never disable the bound. - fallback = _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": "nope"}}) - assert isinstance(fallback, int) and fallback >= 1 - assert _cfg.get_sessions_cache_max({"webui": {}}) >= 1 - assert _cfg.get_sessions_cache_max({}) >= 1 - # A zero/negative typo must not disable the cap. - assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": 0}}) >= 1 - assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": -5}}) >= 1 + old_sessions_max = _cfg.SESSIONS_MAX + try: + _cfg.SESSIONS_MAX = 222 + assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": 42}}) == 42 + # Invalid / missing values must fall back, never disable the bound. + assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": "nope"}}) == 222 + assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": 0}}) == 222 + assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": -5}}) == 222 + finally: + _cfg.SESSIONS_MAX = old_sessions_max + + +def test_cache_cap_preserves_environment_fallback(): + """The parsed env fallback still wins when config is absent or invalid (#6351).""" + from api import config as _cfg + + old_sessions_max = _cfg.SESSIONS_MAX + try: + _cfg.SESSIONS_MAX = 222 + assert _cfg.get_sessions_cache_max({"webui": {}}) == 222 + assert _cfg.get_sessions_cache_max({}) == 222 + _cfg.SESSIONS_MAX = _cfg.DEFAULT_SESSIONS_CACHE_MAX + assert _cfg.get_sessions_cache_max({"webui": {}}) == _cfg.DEFAULT_SESSIONS_CACHE_MAX + assert _cfg.get_sessions_cache_max({}) == _cfg.DEFAULT_SESSIONS_CACHE_MAX + finally: + _cfg.SESSIONS_MAX = old_sessions_max # ─────────────────────────── invariant 1: eviction ──────────────────────────