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.
This commit is contained in:
Victor Kyriazakos authored and Teknium committed 2026-10-28 19:30:48 -07:00
1 parent 5cba2718c0
commit e240d617e8
11 files changed
+590 -3

No files matched your search

+11
View File
@@ -23,6 +23,7 @@ from agent.message_sanitization import _repair_tool_call_arguments, _sanitize_su
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, _estimate_tools_tokens_rough
from agent.process_bootstrap import _install_safe_stdio
from agent.prompt_builder import RUNTIME_ENVIRONMENT_END, RUNTIME_ENVIRONMENT_HEADING
from agent.prompt_refresh import rebuild_for_refresh, requested_epoch, stamp_prompt_epoch, stored_prompt_refresh_epoch
from agent.prompt_caching import (
build_prompt_cache_plan,
effective_cache_ttl,
@@ -852,6 +853,13 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
persist_tools=True,
)
return
refresh_epoch = stored_prompt_refresh_epoch(agent, session_row)
if refresh_epoch:
# `hermes sessions refresh-prompts` ran after this prompt was built. Pin tools[] first
# (the refresh is about instructions; the frozen tool array stays), then rebuild once.
_restore_pinned_tools(agent, session_row)
rebuild_for_refresh(agent, system_message, conversation_history, refresh_epoch)
return
# Continuing session — reuse the exact system prompt from the
# previous turn so the Anthropic cache prefix matches.
agent._cached_system_prompt = stored_prompt
@@ -902,6 +910,8 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
# surface's own build (the -q footprint, its tool_search catalog) would otherwise be
# persisted over the pin below. Pinned first, so the prompt describes the tools sent.
built_for_this_surface = _restore_pinned_tools(agent, session_row)
# Read before the build: a refresh request landing mid-build still fires next turn.
refresh_epoch = requested_epoch(agent)
agent._cached_system_prompt = agent._build_system_prompt(system_message)
if conversation_history:
record_prompt_rebuild(agent, stored_prompt, stored_state, agent._cached_system_prompt)
@@ -938,6 +948,7 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
"rebuild the system prompt and miss the prefix cache.",
persist_tools=True,
)
stamp_prompt_epoch(agent, refresh_epoch)
def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
+118
View File
@@ -0,0 +1,118 @@
"""Adopt an operator's prompt refresh request on a continuing session's next turn.
A continuing session replays its stored system prompt verbatim so the provider prefix cache stays
warm, which also means SOUL.md, skills and project-context edits never reach it before ``/new``.
``hermes sessions refresh-prompts`` records a refresh epoch in the profile's state.db
(``hermes_state_prompt_refresh``). At the start of the session's next turn this module compares
that epoch with the one stamped when the session's prompt was built (``model_config``) and, when
the prompt is older, rebuilds it once through the normal builder: history, session id and the
tools[] pin are kept, ``on_session_start`` does not fire, and the new bytes plus the epoch are
persisted so every later turn restores them verbatim. One prefix-cache break per request.
No request recorded means the stored prompt is reused exactly as before: the only extra work is
one ``state_meta`` point read. Nothing watches files and nothing changes mid-turn.
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
# model_config key: the refresh epoch the session's stored prompt already satisfies.
SESSION_EPOCH_KEY = "prompt_refresh_epoch"
def requested_epoch(agent: Any) -> int:
"""Newest refresh epoch recorded for the agent's session; 0 without a request (or a store that
keeps none, such as a managed worker's facade)."""
reader = getattr(getattr(agent, "_session_db", None), "prompt_refresh_epoch", None)
session_id = getattr(agent, "session_id", None)
if reader is None or not session_id:
return 0
try:
epoch = reader(session_id)
except Exception:
logger.warning("Prompt refresh epoch read failed (session=%s); reusing the stored prompt.",
session_id, exc_info=True)
return 0
return epoch if isinstance(epoch, int) else 0
def _applied_epoch(session_row: Any) -> int:
from hermes_state_sessions import _parse_model_config
value = _parse_model_config((session_row or {}).get("model_config")).get(SESSION_EPOCH_KEY)
return value if isinstance(value, int) else 0
def stored_prompt_refresh_epoch(agent: Any, session_row: Any) -> int:
"""The epoch to rebuild the stored prompt at, or 0 when the stored prompt is current."""
epoch = requested_epoch(agent)
if epoch and _applied_epoch(session_row) < epoch:
return epoch
agent._prompt_refresh_epoch = epoch
return 0
def stamp_prompt_epoch(agent: Any, epoch: int) -> None:
"""Record that the agent's freshly built prompt satisfies ``epoch`` (read BEFORE the build, so a
request that lands mid-build still fires on the next turn)."""
agent._prompt_refresh_epoch = epoch
if epoch:
_persist_epoch(agent, epoch)
def _persist_epoch(agent: Any, epoch: int) -> bool:
db = getattr(agent, "_session_db", None)
if not db:
return True
try:
db.patch_session_model_config(agent.session_id, {SESSION_EPOCH_KEY: epoch})
except Exception:
logger.warning("Could not record the prompt refresh epoch (session=%s); the refresh re-fires "
"next turn.", agent.session_id, exc_info=True)
return False
return True
def rebuild_for_refresh(agent: Any, system_message: Any, conversation_history: Any, epoch: int) -> None:
"""Rebuild the session's prompt from current sources and persist it with ``epoch``."""
from agent.prompt_builder import clear_skills_system_prompt_cache
from agent.surface_switch import stage_surface_switch_note
from hermes_cli.observability.shared_metrics_efficiency import record_cache_break
logger.info("Operator requested a system prompt refresh (epoch %d) for session %s; rebuilding it "
"from current SOUL.md, skills and context files (one-time prefix-cache break).",
epoch, agent.session_id)
# Same boundary compaction uses: memory reloads from disk, plugin sections re-render.
agent._invalidate_system_prompt()
agent._auto_load_skills_resolved = False # pinned skills (skills.auto_load) re-read too
# As in the Bot Chat capability refresh: build the index from disk, not a cached render.
clear_skills_system_prompt_cache(clear_snapshot=True)
agent._cached_system_prompt = agent._build_system_prompt(system_message)
record_cache_break(agent, "system_prompt_rebuild")
stage_surface_switch_note(agent, agent._cached_system_prompt, conversation_history)
db = getattr(agent, "_session_db", None)
if db:
try:
db.update_system_prompt(agent.session_id, agent._cached_system_prompt)
except Exception:
logger.warning("Could not persist the refreshed system prompt (session=%s); the refresh "
"re-fires next turn.", agent.session_id, exc_info=True)
return
if _persist_epoch(agent, epoch):
agent._prompt_refresh_epoch = epoch
def refresh_warm_prompt(agent: Any, system_message: Any, conversation_history: Any) -> None:
"""An agent kept warm across turns (gateway agent cache, TUI/Desktop) adopts a refresh request
recorded after its prompt was set. Agents whose prompt did not come from the restore/build path
(seeded maintenance agents, forks) carry no epoch and are left alone."""
seen = getattr(agent, "_prompt_refresh_epoch", None)
if agent._cached_system_prompt is None or not isinstance(seen, int) or not conversation_history:
return
epoch = requested_epoch(agent)
if epoch > seen:
rebuild_for_refresh(agent, system_message, conversation_history, epoch)
+4 -1
View File
@@ -25,6 +25,7 @@ from agent.message_content import flatten_message_text
from agent.lsp.deferred import forward_to_agent as forward_lsp_verdicts
from agent.plugin_live_notes import pop_plugin_notices
from agent.skills_index_delta import stage_skills_index_note
from agent.prompt_refresh import refresh_warm_prompt
from agent.message_metadata import PERSISTENCE_ONLY_MESSAGE_FIELDS, append_message, stamp_message_timestamp
from agent.model_metadata import (
estimate_messages_tokens_rough,
@@ -1141,7 +1142,9 @@ def build_turn_context(
f"{'...' if len(_preview_text) > 60 else ''}'"
)
# System prompt is cached per session for prefix caching.
# System prompt is cached per session for prefix caching. A warm agent (kept across turns)
# first adopts any `hermes sessions refresh-prompts` request recorded since it set its prompt.
refresh_warm_prompt(agent, system_message, conversation_history)
if agent._cached_system_prompt is None:
restore_or_build_system_prompt(agent, system_message, conversation_history)
active_system_prompt = agent._cached_system_prompt
+30
View File
@@ -1306,6 +1306,35 @@ def _cmd_repair_prompts(db, args):
return 0
def _cmd_refresh_prompts(db, args):
"""Ask continuing sessions to rebuild their stored system prompt once, on their next turn.
For operators who update SOUL.md, skills or context files under a running profile: the
request is a refresh epoch in this profile's state.db, so it works with the gateway running
and survives a restart. History and session ids are untouched.
"""
target = getattr(args, "session", None)
session_id = None
if target:
session_id = db.resolve_session_id(target)
if not session_id:
print(f"No session matches {target!r}.")
return 1
row = db.get_session(session_id) or {}
count = 1 if row.get("system_prompt") else 0
else:
count = db.count_sessions_with_stored_prompt()
epoch = db.request_prompt_refresh(session_id)
if getattr(args, "json", False):
print(json.dumps({"epoch": epoch, "session_id": session_id, "sessions": count}))
return 0
scope = session_id or "every session in this profile"
print(f"Requested a system prompt refresh for {scope} ({count} session(s) with a stored prompt).")
print("Each rebuilds its prompt from the current SOUL.md, skills and context files on its next turn "
"(one prompt-cache miss per session); conversation history is kept.")
return 0
def _cmd_stats(db, args):
reader = _surface_reader(db)
print(f"Total sessions: {db.session_count()}\nTotal messages: {db.message_count()}")
@@ -1353,6 +1382,7 @@ _DB_HANDLERS = {
"retitle-skills": _cmd_retitle_skills, "browse": _cmd_browse, "optimize": _cmd_optimize,
"clean-markers": _cmd_clean_markers, "optimize-storage": _cmd_optimize_storage,
"repair-routing": _cmd_repair_routing, "repair-prompts": _cmd_repair_prompts, "stats": _cmd_stats,
"refresh-prompts": _cmd_refresh_prompts,
}
+14 -1
View File
@@ -30,6 +30,19 @@ def _add_rename_and_discard(sessions_subparsers) -> None:
add_adopt_parser(sessions_subparsers)
def _add_refresh_prompts(sessions_subparsers) -> None:
refresh = sessions_subparsers.add_parser(
"refresh-prompts", help="Make continuing sessions rebuild their system prompt on their next turn",
description="After updating SOUL.md, skills or context files in this profile, ask every "
"continuing session (or one with --session) to rebuild its stored system prompt once, "
"on its next turn, from the current sources. Conversation history and session ids are "
"kept; each refreshed session pays one prompt-cache miss. Works while the gateway runs "
"and across restarts. Use -p/--profile to target another profile.")
refresh.add_argument("--session", default=None, metavar="SESSION_ID",
help="Refresh only this session (id or unique prefix)")
_flag(refresh, "--json", help="Machine-readable output")
def build_sessions_parser(subparsers, *, cmd_sessions: Callable) -> None:
"""Attach the ``sessions`` subcommand to ``subparsers``."""
sessions_parser = subparsers.add_parser(
@@ -243,7 +256,6 @@ def build_sessions_parser(subparsers, *, cmd_sessions: Callable) -> None:
"switched on), 'move' them to the default store (a default chat that leaked in), or "
"'report' (default) — the rows themselves cannot tell the two cases apart")
sessions_repair_prompts = sessions_subparsers.add_parser(
"repair-prompts", help="Report or clear stored system prompts degraded to a reduced-toolset build",
description="Repair session rows degraded by the pre-#122822 gateway hygiene/compress bug. "
@@ -256,6 +268,7 @@ def build_sessions_parser(subparsers, *, cmd_sessions: Callable) -> None:
help="Machine-readable output; with --apply, apply without an interactive confirmation")
sessions_repair_prompts.add_argument("session_id", nargs="?", default=None,
help="Destructive override: clear this session even when its stored prompt is healthy")
_add_refresh_prompts(sessions_subparsers)
sessions_recover = sessions_subparsers.add_parser(
"recover", help="Rebuild canonical session data into a separate clean database",
+2
View File
@@ -76,6 +76,7 @@ from hermes_state_wal import (
)
from hermes_state_repair import _claim_repair_attempt, preflight_db_writability, repair_state_db_schema
from hermes_state_titles import SessionTitlesMixin
from hermes_state_prompt_refresh import SessionPromptRefreshMixin
from hermes_state_usage import SessionUsageMixin
from hermes_state_maintenance import SessionMaintenanceMixin
from hermes_state_gateway import SessionGatewayMixin
@@ -465,6 +466,7 @@ class SessionDB(
SessionPortabilityMixin, SessionTelegramTopicsMixin, SessionCompressionMixin,
SessionGatewayMixin, SessionMaintenanceMixin, SessionUsageMixin, SessionTitlesMixin,
SessionMessagesMixin, SessionPruneRewriteMixin, SessionApiContentMixin, SessionCoverageMixin, SessionRewindMixin, SessionProfileRepairMixin,
SessionPromptRefreshMixin,
):
"""SQLite-backed session storage with FTS5 search; many reader threads, one writer (WAL)."""
+54
View File
@@ -0,0 +1,54 @@
"""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
+314
View File
@@ -0,0 +1,314 @@
"""``hermes sessions refresh-prompts``: a continuing session adopts updated prompt sources once.
Operators push SOUL.md / skill / AGENTS.md changes into a profile while users keep one long
gateway conversation. The stored system prompt is reused verbatim every turn (prefix cache), so
those changes never reached the session before ``/new``. The verb records a refresh request in the
profile's state.db; the session's next turn rebuilds its prompt once, keeps its history and id,
and persists the new bytes so later turns restore them verbatim.
Everything is real except the model: a real ``AIAgent`` per turn (as the messaging gateway runs
it), the real ``SessionDB`` in a temp HERMES_HOME, the real CLI entrypoint, and a recording
``FakeLLMServer`` whose request bodies are what the model was actually sent.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
from tests.e2e.core.history._helpers import NO_BACKGROUND_REVIEW, OFFLINE_CONFIG
from tests.fakes.fake_llm_provider import FakeLLMServer, write_hermes_home
SOUL_V1 = "You are Wren, the operations assistant. Soul revision one."
SOUL_V2 = "You are Wren, the operations assistant. Soul revision two."
def _skill(home: Path, name: str, description: str) -> None:
skill_dir = home / "skills" / "ops" / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n\nSteps.\n", encoding="utf-8")
def _write_profile(home: Path, base_url: str, project: Path) -> None:
write_hermes_home(home, base_url, extra_config=OFFLINE_CONFIG + NO_BACKGROUND_REVIEW)
(home / "SOUL.md").write_text(SOUL_V1, encoding="utf-8")
_skill(home, "deploy-runbook", "Roll out a release. Description revision one.")
project.mkdir(parents=True, exist_ok=True)
(project / "AGENTS.md").write_text("Project rules revision one.", encoding="utf-8")
def _update_profile(home: Path, project: Path) -> None:
"""What the operator's update script pulls into the profile."""
(home / "SOUL.md").write_text(SOUL_V2, encoding="utf-8")
_skill(home, "deploy-runbook", "Roll out a release. Description revision two.")
_skill(home, "incident-triage", "Triage a production incident.")
(project / "AGENTS.md").write_text("Project rules revision two.", encoding="utf-8")
@pytest.fixture
def profile(monkeypatch, tmp_path):
home = Path(os.environ["HERMES_HOME"])
project = tmp_path / "project"
with FakeLLMServer() as srv:
_write_profile(home, srv.base_url, project)
monkeypatch.setenv("TERMINAL_CWD", str(project))
yield srv, home, project
def _agent(srv, home, session_id):
from hermes_state import SessionDB
from run_agent import AIAgent
db = SessionDB(db_path=home / "state.db")
db.ensure_session(session_id, source="whatsapp") # the gateway opens the row before the turn
return AIAgent(provider="custom", base_url=srv.base_url, api_key="sk-fake-e2e", model="fake-model",
session_db=db, session_id=session_id, quiet_mode=True, platform="whatsapp")
def _system(request) -> str:
content = request["messages"][0]["content"]
assert request["messages"][0]["role"] == "system"
if isinstance(content, list):
return "".join(block.get("text", "") for block in content)
return content
def _user_texts(request) -> list[str]:
out = []
for message in request["messages"]:
if message["role"] != "user":
continue
content = message["content"]
if isinstance(content, list):
content = "".join(block.get("text", "") for block in content)
out.append(content)
return out
def _turn(srv, home, session_id, text, history, agent=None):
"""One gateway turn: a fresh AIAgent unless a warm ``agent`` is passed. Returns (history, system)."""
fresh = agent is None
agent = agent or _agent(srv, home, session_id)
try:
messages = agent.run_conversation(text, conversation_history=history, task_id="t")["messages"]
finally:
if fresh:
agent.close()
return messages, _system(srv.main_requests()[-1])
def _refresh_prompts(monkeypatch, home: Path, *extra: str) -> None:
"""Run the real CLI entrypoint against ``home`` (what an operator's update script calls)."""
import hermes_cli.main as main_mod
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(sys, "argv", ["hermes", "sessions", "refresh-prompts", *extra])
try:
main_mod.main()
except SystemExit as exc:
assert exc.code in (0, None), exc.code
def _assert_updated(system: str) -> None:
assert SOUL_V2 in system and SOUL_V1 not in system
assert "Description revision two." in system and "Description revision one." not in system
assert "incident-triage" in system
assert "Project rules revision two." in system
def _assert_stale(system: str) -> None:
assert SOUL_V1 in system and SOUL_V2 not in system
assert "Description revision one." in system
assert "incident-triage" not in system
assert "Project rules revision one." in system
def test_without_the_verb_a_continuing_session_keeps_its_stored_prompt(profile):
srv, home, project = profile
history, first = _turn(srv, home, "chat-a", "hello", [])
_assert_stale(first)
_update_profile(home, project)
history, second = _turn(srv, home, "chat-a", "still there?", history)
assert second == first # main's contract: the stored bytes are replayed until /new
def test_the_verb_makes_the_next_turn_adopt_soul_skills_and_project_files_once(profile, monkeypatch, capsys):
from run_agent import AIAgent
srv, home, project = profile
history, _ = _turn(srv, home, "chat-a", "hello", [])
history, _ = _turn(srv, home, "chat-a", "how are things?", history)
_update_profile(home, project)
_refresh_prompts(monkeypatch, home)
assert "1 session" in capsys.readouterr().out
history, refreshed = _turn(srv, home, "chat-a", "anything new?", history)
_assert_updated(refreshed)
request = srv.main_requests()[-1]
assert _user_texts(request)[:3] == ["hello", "how are things?", "anything new?"] # history intact
# One break per request: the next fresh agent restores the refreshed bytes without a rebuild.
builds = []
original = AIAgent._build_system_prompt
monkeypatch.setattr(AIAgent, "_build_system_prompt",
lambda self, *a, **k: builds.append(1) or original(self, *a, **k))
history, after = _turn(srv, home, "chat-a", "and now?", history)
assert after == refreshed and builds == []
assert len(_user_texts(srv.main_requests()[-1])) == 4
from hermes_state import SessionDB
with SessionDB(db_path=home / "state.db") as db:
assert db.get_session("chat-a")["system_prompt"] == refreshed # persisted for the next restore
def test_session_flag_refreshes_only_that_session(profile, monkeypatch):
srv, home, project = profile
history_a, _ = _turn(srv, home, "chat-a", "hello", [])
history_b, _ = _turn(srv, home, "chat-b", "hello", [])
_update_profile(home, project)
_refresh_prompts(monkeypatch, home, "--session", "chat-a")
_, system_a = _turn(srv, home, "chat-a", "next", history_a)
_, system_b = _turn(srv, home, "chat-b", "next", history_b)
_assert_updated(system_a)
_assert_stale(system_b)
def test_a_session_started_after_the_request_is_not_rebuilt_again(profile, monkeypatch):
from run_agent import AIAgent
srv, home, _project = profile
_refresh_prompts(monkeypatch, home)
history, first = _turn(srv, home, "chat-new", "hello", [])
builds = []
original = AIAgent._build_system_prompt
monkeypatch.setattr(AIAgent, "_build_system_prompt",
lambda self, *a, **k: builds.append(1) or original(self, *a, **k))
_, second = _turn(srv, home, "chat-new", "next", history)
assert second == first and builds == []
def test_warm_cached_agent_drops_its_stale_prompt(profile, monkeypatch):
srv, home, project = profile
agent = _agent(srv, home, "chat-warm")
try:
history, first = _turn(srv, home, "chat-warm", "hello", [], agent=agent)
history, second = _turn(srv, home, "chat-warm", "again", history, agent=agent)
assert second == first
_update_profile(home, project)
_refresh_prompts(monkeypatch, home)
history, refreshed = _turn(srv, home, "chat-warm", "anything new?", history, agent=agent)
_assert_updated(refreshed)
assert _user_texts(srv.main_requests()[-1])[:3] == ["hello", "again", "anything new?"]
history, after = _turn(srv, home, "chat-warm", "and now?", history, agent=agent)
assert after == refreshed
finally:
agent.close()
# The warm refresh was persisted: an evicted/restarted agent restores the same bytes.
_, restored = _turn(srv, home, "chat-warm", "after restart", history)
assert restored == refreshed
def test_refresh_request_is_scoped_to_its_profile(profile, monkeypatch, tmp_path):
srv, home_a, project = profile
home_b = tmp_path / "profile-b"
_write_profile(home_b, srv.base_url, project)
history_a, _ = _turn(srv, home_a, "chat-a", "hello", [])
monkeypatch.setenv("HERMES_HOME", str(home_b))
history_b, _ = _turn(srv, home_b, "chat-b", "hello", [])
_update_profile(home_a, project)
_update_profile(home_b, project)
_refresh_prompts(monkeypatch, home_a)
monkeypatch.setenv("HERMES_HOME", str(home_a))
_, system_a = _turn(srv, home_a, "chat-a", "next", history_a)
_assert_updated(system_a)
monkeypatch.setenv("HERMES_HOME", str(home_b))
_, system_b = _turn(srv, home_b, "chat-b", "next", history_b)
assert SOUL_V1 in system_b and "incident-triage" not in system_b
def test_no_request_means_no_rebuild_and_no_prompt_source_reads(profile, monkeypatch):
"""Never ran the verb: the stored prompt is replayed byte-identically with no source reads."""
import agent.prompt_builder as pb
from run_agent import AIAgent
srv, home, project = profile
history, first = _turn(srv, home, "chat-a", "hello", [])
_update_profile(home, project)
calls = []
# build_skills_system_prompt is left out: main's per-turn skills delta note reads it already.
for name in ("load_soul_md", "build_context_files_prompt"):
original_fn = getattr(pb, name)
monkeypatch.setattr(pb, name, lambda *a, _n=name, _f=original_fn, **k: calls.append(_n) or _f(*a, **k))
original = AIAgent._build_system_prompt
monkeypatch.setattr(AIAgent, "_build_system_prompt",
lambda self, *a, **k: calls.append("build") or original(self, *a, **k))
_, second = _turn(srv, home, "chat-a", "next", history)
assert second == first
assert calls == []
def test_refresh_rebuilds_the_skills_index_from_disk_even_when_its_cache_cannot_see_the_edit(profile, monkeypatch):
"""Coarse filesystem timestamps can leave an in-place, same-size SKILL.md edit with an unchanged
stat signature (see ``utils.RACY_MTIME_WINDOW_NS``); the skills index cache, keyed on those
signatures, would then keep serving the old render. The refresh must rebuild from disk anyway."""
import agent.prompt_builder as pb
monkeypatch.setattr(pb, "file_signature", lambda st: (1, st.st_size, 1, 1)) # timestamps that never move
srv, home, _project = profile
history, first = _turn(srv, home, "chat-a", "hello", [])
assert "Description revision one." in first
_skill(home, "deploy-runbook", "Roll out a release. Description revision six.") # same size
_refresh_prompts(monkeypatch, home)
_, refreshed = _turn(srv, home, "chat-a", "next", history)
assert "Description revision six." in refreshed and "Description revision one." not in refreshed
def _tool_description(request, name: str) -> str:
return next(t["function"]["description"] for t in request["tools"] if t["function"]["name"] == name)
def test_refresh_keeps_the_sessions_tools_pin(profile, monkeypatch):
"""The refresh is about instructions: tools[] stays the array the session already sends, even
when the agent built for this turn derives a different schema for one of its tools."""
import model_tools
srv, home, project = profile
history, _ = _turn(srv, home, "chat-a", "hello", [])
pinned = srv.main_requests()[-1]["tools"]
changed = "Changed after the session pinned its tools."
monkeypatch.setitem(model_tools._DYNAMIC_SCHEMA_REWRITERS, "clarify",
lambda td, _available: {**td, "function": {**td["function"], "description": changed}})
monkeypatch.setattr(model_tools, "_tool_defs_cache", type(model_tools._tool_defs_cache)())
_update_profile(home, project)
_refresh_prompts(monkeypatch, home)
_, refreshed = _turn(srv, home, "chat-a", "next", history)
_assert_updated(refreshed)
assert srv.main_requests()[-1]["tools"] == pinned
# Control: a new session built by the same process really does carry the changed schema.
_turn(srv, home, "chat-new", "hello", [])
assert _tool_description(srv.main_requests()[-1], "clarify") == changed
def test_refresh_prompts_unknown_session_fails(profile, monkeypatch, capsys):
import hermes_cli.main as main_mod
monkeypatch.setattr(sys, "argv", ["hermes", "sessions", "refresh-prompts", "--session", "nope"])
with pytest.raises(SystemExit) as exc:
main_mod.main()
assert exc.value.code == 1
assert "No session matches" in capsys.readouterr().out
+1
View File
@@ -1792,6 +1792,7 @@ Subcommands:
| `repair` | Repair a malformed `state.db` schema (e.g. `table messages_fts already exists`) so hidden sessions reappear; a backup is made first. |
| `repair-routing` | Re-attach gateway conversations stranded in session rows that lost their routing identity (a chat "jumping back in time" after a restart). Dry-run by default; `--apply` performs the adoptions (stop the gateway first); `--max-gap-seconds N` tunes the contiguity window. Only unambiguous cases are repaired. See [Sessions → Repair Stranded Gateway Sessions](../user-guide/sessions.md#repair-stranded-gateway-sessions). |
| `repair-prompts` | Report stored system prompts provably degraded by the pre-#122822 maintenance-compaction bug. Report-only by default; `--apply` clears verified rows so the next turn rebuilds them, `--json` is machine-readable (and non-interactive when combined with `--apply`), and an explicit `session_id` is a destructive override that can clear even a healthy prompt. Rows without a readable tools[] pin, or with a memory-only pin, are reported as unverifiable and left unchanged by the scan (a resumed memory-only session re-pins its full tool surface, after which a scan can clear it). Restart a running gateway after `--apply` so repaired rows take effect. See [Sessions → Repair Degraded Stored Prompts](../user-guide/sessions.md#repair-degraded-stored-prompts). |
| `refresh-prompts` | Make continuing sessions rebuild their stored system prompt from the current `SOUL.md`, skills and context files on their next turn, keeping conversation history and session ids. Run it after updating a profile's prompt sources. `--session ID` (id or unique prefix) limits the request to one session, `--json` prints the recorded epoch and the session count, `-p/--profile` targets another profile. Works with the gateway running (no restart needed) and survives restarts. Each refreshed session pays one prompt-cache miss. See [Sessions → Refresh Prompts After a Profile Update](../user-guide/sessions.md#refresh-prompts-after-a-profile-update). |
| `repair-profiles` | Settle session, routing, Telegram-topic and voice-mode state that landed under the wrong profile (rows in another profile's store, labels disagreeing with the session key, parent links crossing profiles, index rows for deleted profiles). Dry-run by default; `--apply` performs the repairs after snapshotting every store (stop the gateway first); `--legacy-main rekey\|move` decides what `agent:main` rows inside a named profile's store are; `--json` for automation. See [Sessions → Repair State Crossed Between Profiles](../user-guide/sessions.md#repair-state-crossed-between-profiles). |
| `recover` | Offline, non-destructive recovery of a damaged `state.db` into a separate clean database. |
| `retitle-skills` | Regenerate titles for sessions opened with a `/skill`, using what the user actually typed; lists changes unless `--apply` is passed. |
+41
View File
@@ -701,6 +701,47 @@ Database size: 12.4 MB
For deeper analytics — token usage, cost estimates, tool breakdown, and activity patterns — use [`hermes insights`](../reference/cli-commands.md#hermes-insights).
### Refresh Prompts After a Profile Update
A continuing session sends the same system prompt on every turn, the bytes it
built on its first turn. Keeping those bytes fixed is what lets the provider
reuse its prompt cache across a long conversation. The side effect: changes to
`SOUL.md`, installed or edited skills, or project context files (`AGENTS.md`
and friends) do not reach a session that is already running. A gateway
restart does not change that, because the gateway restores each session's
stored prompt. Until now the only way in was `/new`, which also drops the
conversation.
After updating a profile's prompt sources, ask its sessions to adopt them:
```bash
# Every session in the active profile (add -p <profile> for another one)
hermes sessions refresh-prompts
# One session (id or unique prefix)
hermes sessions refresh-prompts --session SESSION_ID
```
The command records a refresh request in the profile's `state.db` and prints
how many sessions hold a stored prompt. On each session's next turn, Hermes
rebuilds the system prompt from the current sources and then sends the
message. The conversation history, the session id and the session's tool list
stay as they were. The rebuilt prompt is saved, so every later turn reuses it
byte for byte.
- **Cost:** each refreshed session pays one prompt-cache miss, on the turn
that rebuilds. Later turns are cached again.
- **No restart needed:** the request is read at the start of every turn, by
agents the gateway keeps in memory and by agents it restores from the
database. It also survives restarts. Run it once the new files are in
place: an update script can call it before or after replacing the gateway.
- **What gets rebuilt:** the whole system prompt goes through the normal
builder, so memory and `USER.md` blocks also pick up their latest saved
state on that turn. Tool definitions do not change. A session started after
the request already has the new prompt and does not rebuild again.
- **Without the command** nothing changes: sessions keep replaying their
stored prompt as before.
### Repair Stranded Gateway Sessions
If a gateway conversation ever "jumps back in time" after a restart — resuming
@@ -41,7 +41,7 @@ Memory (MEMORY.md and USER.md) is injected into the system prompt as a **frozen
### "I told it my name mid-session and it acted like it never heard it"
If the agent saved your name to memory, the save worked — check with the `memory` tool's responses or `hermes journey list`. What you're seeing is the frozen-snapshot rule above: the system prompt doesn't refresh mid-session, so the *injected* memory block still shows the session-start state. The agent can still use what you told it within the current conversation (it's in the context), and the saved entry will be in the system prompt from the next session onward. The same applies to edits you make to `SOUL.md` or `AGENTS.md` while a session is running: context is assembled at session start, so restart the session to pick up changes.
If the agent saved your name to memory, the save worked — check with the `memory` tool's responses or `hermes journey list`. What you're seeing is the frozen-snapshot rule above: the system prompt doesn't refresh mid-session, so the *injected* memory block still shows the session-start state. The agent can still use what you told it within the current conversation (it's in the context), and the saved entry will be in the system prompt from the next session onward. The same applies to edits you make to `SOUL.md` or `AGENTS.md` while a session is running: context is assembled at session start, so restart the session to pick up changes. For long-running gateway conversations you do not want to reset, `hermes sessions refresh-prompts` makes them adopt the updated files on their next turn while keeping the history (see [Refresh Prompts After a Profile Update](./sessions.md#refresh-prompts-after-a-profile-update)).
:::tip Quick decision guide
- Want to change how the agent **talks**? Edit `~/.hermes/SOUL.md` — [Personality & SOUL.md](./features/personality.md).