fix(state-sync): repoint to renamed agent SessionDB API + fix agent-coupled tests

The Hermes Agent's state-module refactor (agent commit 53db597201, released
v2026.9.7) renamed SessionDB.set_auto_title_if_empty -> set_auto_title(
session_id, title, *, source) and (agent commit 14791b4d4e) dropped the
tools.approval._ApprovalEntry back-compat facade (class moved, unchanged, to
tools.approval_gateway_wait).

Production impact (real, silent): api.state_sync.sync_session_title called the
removed set_auto_title_if_empty, whose AttributeError was swallowed by the
surrounding `except Exception: logger.debug(...)`, so WebUI auto-generated
session titles stopped persisting to state.db on agent >= v2026.9.7 — hermes
sessions list went blank again (the exact #6892 regression). Now calls
set_auto_title with LLM provenance, which preserves the identical semantics
(only fills NULL/lower-authority title, returns False untouched on a manual
rename, ValueError -> get_next_title_in_lineage #N de-dup).

Test fixes (agent-coupled; skip on GitHub CI which installs no agent, so they
only surface on an agent-equipped box like our gate):
 1. tools.approval._ApprovalEntry -> idempotent autouse conftest compat shim
    backfilling the class from its post-split location only when the installed
    agent lacks the dropped facade. No production code imports it.
 2. test_goal_command_webui::test_profile_goal_falls_back_when_session_db_path_is_frozen
    was missing the monkeypatch.syspath_prepend(...) that every sibling test in
    the file has, so its bare `import hermes_state` failed under full-suite
    ordering after a predecessor stripped the agent dir from sys.path. Added it.

Verified: authoritative 5-shard CI-equivalent split all green (0 failed);
each failure proven pre-existing on clean master (not this diff).
This commit is contained in:
n committed 2026-09-07 23:34:45 +00:00
1 parent bd62772ca5
commit bee7b8a9e4
4 files changed
+58 -5

No files matched your search

+2
View File
@@ -7,6 +7,8 @@
- **Docker: secrets ending in `=` (like a base64 shared secret or password) are no longer truncated when the container reloads its environment after dropping privileges, so shared-secret auth and `HERMES_WEBUI_PASSWORD` matching keep working.** `docker_init.bash`'s `load_env()` reloaded the saved env snapshot with `while IFS='=' read -r key value`, and Bash silently drops a single trailing `=` from the last field — so any value ending in one `=` (e.g. `openssl rand -base64 32` output, which is 44 chars ending in `=`) came back one character short. The reload now reads each line whole and splits at the first `=` only, preserving embedded and trailing `=`; blank/comment lines are skipped and lines without `=` keep their prior empty-value behavior. Thanks @webtecnica. (#7433, closes #7432)
- **WebUI session titles are written to `state.db` again on the latest Hermes Agent, so `hermes sessions list` isn't blank for WebUI sessions.** The agent's `SessionDB.set_auto_title_if_empty` was renamed to `set_auto_title(session_id, title, *, source)` in a state-module refactor (released in agent v2026.9.7); the WebUI's background title sync still called the old name, whose `AttributeError` was silently swallowed — so auto-generated titles stopped persisting. The sync now calls `set_auto_title` with LLM provenance, preserving the exact prior behavior (only fills a NULL/lower-authority title, never clobbers a manual rename, de-duplicates colliding titles with a `#N` lineage suffix). Also repoints the gateway-approval test suite's `tools.approval._ApprovalEntry` reference to its post-split location (`tools.approval_gateway_wait`) via a test-only compatibility shim, so the agent-coupled tests run green across both the pre- and post-split agent.
- **Regenerating a response no longer refetches the entire conversation, so regenerate on a long chat is fast instead of stalling (and silently cancelling).** Clicking "regenerate" used to pull the whole transcript back from the server before it could rebuild the request, which on a large session caused a long UI freeze and could time out and silently drop the regeneration. The regeneration authority now reads only a bounded, sidecar-anchored tail of recent turns instead of the full transcript — and it is exact by construction: it takes the tail's prefix proof, keys, and rows from a single WAL-consistent database snapshot (a `data_version` check forces a full read if the database is written mid-snapshot), reuses the canonical projection and durable ordering so the bounded rows match the full reader byte-for-byte, and falls back to a full read whenever the skipped prefix isn't provably identical or the tail contains a duplicated turn key. If any of those invariants can't be met it reads the whole transcript exactly as before, so a concurrent edit or an unusual session can never produce a stale or reordered regeneration. Thanks @webtecnica. (#7204, closes #6826)
- **Loading the session sidebar is faster on installs with many delegated-subagent sessions — the subagent classification no longer runs a separate database probe per row.** Building `/api/sessions` used to open a `state.db` connection for every session that might be a delegated child, an N+1 pattern that grew with session count. The classification now reads the authoritative `state.db` source for all candidate rows through the existing batched overlay (one read-only connection, chunked 500-ID `IN` queries), and a rich-read failure recovers the remaining IDs in a single bounded source-only pass rather than falling back to per-row queries. Behavior is unchanged — a delegated child whose index row still says `webui`/`fork` while its `state.db` source is `subagent` is classified read-only exactly as before. Thanks @starship-s. (#7231)
+14 -5
View File
@@ -205,9 +205,11 @@ def sync_session_title(session_id: str, title: str, profile: Optional[str] = Non
titles for WebUI sessions. This function bridges that gap and is called
from the background title update/refresh paths after a title is persisted.
Uses ``set_auto_title_if_empty`` so it will only populate a NULL title and
never overwrite a manual rename made via CLI/Gateway/TUI. This means
title refreshes (where state.db already holds the initial auto-title) are
Uses ``set_auto_title`` (LLM provenance) so it will only populate a row that
is NULL or holds a lower-authority auto-title, and never overwrites a manual
rename made via CLI/Gateway/TUI (``set_auto_title`` returns ``False``,
untouched, when a higher-authority title holds the row). This means title
refreshes (where state.db already holds the initial auto-title) are
effectively no-ops at the state.db layer -- acceptable because the primary
goal is ensuring ``hermes sessions list`` is not blank.
@@ -223,8 +225,15 @@ def sync_session_title(session_id: str, title: str, profile: Optional[str] = Non
try:
# Ensure the session row exists (idempotent) so the UPDATE has a target.
db.ensure_session(session_id=session_id, source='webui')
# hermes-agent's SessionDB.set_auto_title_if_empty was renamed to
# set_auto_title(session_id, title, *, source) in the state-module
# split (agent commit 53db597201, released v2026.9.7). set_auto_title
# preserves the same "only populate NULL / never clobber a manual
# rename" semantics (returns False, untouched, when a higher-authority
# title holds the row) and requires an explicit auto source.
_llm_source = getattr(db, "TITLE_SOURCE_LLM", "llm")
try:
db.set_auto_title_if_empty(session_id, title)
db.set_auto_title(session_id, title, source=_llm_source)
except ValueError:
# state.db enforces uniqueness on sessions.title, so a byte-identical
# auto-title generated for two sessions raises ValueError here. Derive
@@ -232,7 +241,7 @@ def sync_session_title(session_id: str, title: str, profile: Optional[str] = Non
# retry instead of leaving the second row blank (#6964).
alt = db.get_next_title_in_lineage(title)
if alt and alt != title:
db.set_auto_title_if_empty(session_id, alt)
db.set_auto_title(session_id, alt, source=_llm_source)
except Exception:
logger.debug("Failed to sync session title to state.db for %s", session_id)
finally:
+35
View File
@@ -1246,6 +1246,41 @@ def _restore_hermes_cli_module():
sys.path[:] = _REAL_SYS_PATH
# ── hermes-agent API-drift compat: tools.approval._ApprovalEntry ─────────────
# The core agent (commit 14791b4d4e, released in v2026.9.7) split tools/approval.py
# into smart/human-wait/gateway-wait modules and dropped 43 back-compat facade
# re-exports — including the module-level ``tools.approval._ApprovalEntry`` alias.
# The class itself still exists, unchanged, at ``tools.approval_gateway_wait._ApprovalEntry``
# (identical ``__init__(self, data)`` contract: threading.Event + data dict + an
# auto-stamped request_id). Several webui gateway-approval tests reference
# ``_ApprovalEntry`` through ``tools.approval`` (its pre-split public location):
# on a box with the NEW agent installed the attribute is gone (AttributeError via
# the module's PEP-562 ``__getattr__``); on an OLD agent it is still present. No
# PRODUCTION webui code imports the dropped facade (verified: api/route_approvals.py
# and api/streaming.py import only surviving names), so this is purely test-side
# coupling to a moved internal symbol.
#
# This runs as an AUTOUSE fixture (not at conftest import time) because the agent
# dir is only added to sys.path by the earlier autouse guards / server fixture —
# ``tools.approval`` is not importable at conftest module load. The backfill is
# idempotent and only aliases the class when the installed agent lacks it, using
# the exact class object the agent uses internally. It keeps the tests working
# across both the pre-split and post-split agent without touching ~22 call sites,
# and is a no-op when tools.approval isn't importable (agent absent → tests skip).
@pytest.fixture(autouse=True)
def _backfill_approval_entry_facade():
try:
import tools.approval as _approval_mod
except Exception:
return # agent not importable here → agent-dependent tests skip anyway
if getattr(_approval_mod, "_ApprovalEntry", None) is None:
try:
from tools.approval_gateway_wait import _ApprovalEntry as _entry_cls
except Exception:
return # unexpected layout → let tests skip/fail loudly, don't fake it
_approval_mod._ApprovalEntry = _entry_cls
# ── Per-test session cleanup ──────────────────────────────────────────────────
@pytest.fixture(autouse=True)
+7
View File
@@ -249,6 +249,13 @@ def test_profile_goal_falls_back_when_session_db_path_is_frozen(monkeypatch, tmp
"""A context API alone cannot make an import-time SessionDB path profile-safe."""
from api import goals as webui_goals
native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed")
# Ensure the agent dir is on sys.path before importing the sibling top-level
# `hermes_state` module. importorskip("hermes_cli.goals") can succeed from a
# cached sys.modules entry even after a prior shard test stripped the agent
# dir from sys.path, but the bare `import hermes_state` below is uncached and
# would then fail with ModuleNotFoundError under full-suite ordering (every
# other test in this file already does this prepend).
monkeypatch.syspath_prepend(str(Path(native_goals.__file__).resolve().parents[1]))
import hermes_state
frozen_db_path = tmp_path / "frozen-home" / "state.db"