Files
hermes-agent/hermes_state_prompt_refresh.py
Victor Kyriazakos e240d617e8 feat(sessions): refresh-prompts lets continuing sessions adopt updated prompt sources
A continuing session replays its stored system prompt verbatim on every turn
so the provider prefix cache stays warm. The gateway restores that prompt from
the session row on each fresh agent, and warm cached agents keep it in memory,
so SOUL.md edits, new or edited skills and project-context changes never
reach a long-running conversation until /new, which also drops the history.
Operators who push profile updates and restart the gateway had no way to make
existing conversations pick them up.

`hermes sessions refresh-prompts [--session ID] [--json]` records a refresh
epoch in the profile's state.db (state_meta, one counter orders profile-wide
and per-session requests). At the start of a continuing session's next turn,
_restore_or_build_system_prompt compares that epoch with the one stamped in
the session's model_config when its prompt was built. When the stored prompt
is older it follows the Bot Chat capability-refresh precedent in the same
function: tools[] pin restored first, skills prompt cache and snapshot
cleared, prompt rebuilt through _build_system_prompt (memory reloaded and
plugin sections re-rendered, as at a compaction boundary), cache break
recorded, on_session_start not fired, new bytes and epoch persisted so the
following turns restore them verbatim. One cache break per request.

Warm agents (gateway agent cache, TUI/Desktop) run the same check from
build_turn_context against the epoch they saw when their prompt was set, so
the verb works with the gateway running. A session first built after a
request is stamped with it and does not rebuild again.

With no request recorded, the stored prompt is reused byte-identically. The
only added work per continuing turn is one state_meta point read. No file
scanning, no config key, no watcher, no mid-turn mutation.

The tools[] pin is kept on purpose: the refresh is about instructions, and
tool changes already reach long sessions at the compaction boundary. The
cache break is recorded as the existing system_prompt_rebuild cause rather
than a new one, which would change the shared-metrics contract and schemas.

Related: #141154 (per-turn source hashing, alternative design), #74622.
2026-10-28 19:30:48 -07:00

55 lines
2.5 KiB
Python

"""Operator-requested system prompt refresh epochs (``hermes sessions refresh-prompts``).
A continuing session replays its stored system prompt every turn so the provider prefix cache
stays warm. After an operator updates the profile's prompt sources (SOUL.md, skills, project
context files) the verb records a refresh epoch here; a session whose stored prompt predates it
rebuilds once on its next turn (``agent/prompt_refresh.py``). Epochs live in ``state_meta`` of
the profile's own ``state.db``: durable across restarts, writable while the gateway runs, and
never seen by another profile. No row means no request, and the restore path behaves as before.
"""
from __future__ import annotations
from typing import Optional
PROFILE_KEY = "prompt_refresh.epoch"
SESSION_KEY_PREFIX = "prompt_refresh.epoch:"
class SessionPromptRefreshMixin:
"""Record and read refresh epochs. One counter orders profile-wide and per-session requests."""
def request_prompt_refresh(self, session_id: Optional[str] = None) -> int:
"""Record a refresh request for every session (or just ``session_id``); returns the epoch."""
key = PROFILE_KEY if session_id is None else SESSION_KEY_PREFIX + session_id
def _do(conn) -> int:
row = conn.execute(
"SELECT MAX(CAST(value AS INTEGER)) FROM state_meta WHERE key = ? OR substr(key, 1, ?) = ?",
(PROFILE_KEY, len(SESSION_KEY_PREFIX), SESSION_KEY_PREFIX),
).fetchone()
epoch = int(row[0] or 0) + 1
conn.execute(
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, str(epoch)),
)
return epoch
return self._execute_write(_do)
def prompt_refresh_epoch(self, session_id: str) -> int:
"""The newest refresh epoch that applies to ``session_id`` (0 = never requested)."""
row = self._read_one(
"SELECT MAX(CAST(value AS INTEGER)) FROM state_meta WHERE key IN (?, ?)",
(PROFILE_KEY, SESSION_KEY_PREFIX + session_id),
)
return int(row[0] or 0) if row else 0
def count_sessions_with_stored_prompt(self) -> int:
"""Sessions whose next turn would replay a stored prompt (the ones a refresh rebuilds)."""
row = self._read_one(
"SELECT COUNT(*) FROM sessions WHERE system_prompt_hash IS NOT NULL OR system_prompt IS NOT NULL"
)
return int(row[0]) if row else 0