mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
5cba2718c0
commit
e240d617e8
11 files changed
+590
-3
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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. |
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user