diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py
index c9598b481e..f4cb39315b 100644
--- a/agent/conversation_compression.py
+++ b/agent/conversation_compression.py
@@ -3979,29 +3979,21 @@ def compress_context(
exc_info=True,
)
- # Built-in memory is the only system-prompt input that a normal
- # compaction reloads. When the cached prompt already embeds the
- # freshly-reloaded memory blocks verbatim, keep the exact cached
- # prompt so local backends retain their KV-cache prefix. Containment
- # (not before/after snapshot equality) is required: fresh-agent
- # surfaces restore the cached prompt from the session DB, where it
- # can predate mid-session memory writes the in-memory snapshot has
- # already absorbed. External providers can change their own prompt
- # block during on_pre_compress(), so they retain the rebuild path.
- if (
- cached_system_prompt is not None
- and getattr(agent, "_memory_manager", None) is None
- and _cached_prompt_reflects_builtin_memory(agent, cached_system_prompt)
- ):
+ # ALWAYS rebuild the prompt at the admitted-commit boundary
+ # (maintainer-directed, #95681 arc). The previous "keep-prompt"
+ # containment branch put the OLD bytes back whenever the reloaded
+ # memory blocks were already embedded — which meant prompt-builder
+ # changes (guidance diets, new blocks, renames) NEVER reached a
+ # long-lived session. The cache argument for keeping bytes was
+ # hollow: when nothing changed, the rebuild is byte-identical and
+ # local KV prefixes survive on equality; when something changed,
+ # the cache was stale by definition and propagation is the point.
+ # Preserve OBJECT identity on byte-equality for backends that key
+ # on it.
+ rebuilt_system_prompt = agent._build_system_prompt(system_message)
+ if cached_system_prompt is not None and rebuilt_system_prompt == cached_system_prompt:
new_system_prompt = cached_system_prompt
agent._cached_system_prompt = cached_system_prompt
- # _invalidate_system_prompt() above also cleared the
- # cross-session-stable prefix marker boundary. The kept prompt
- # is byte-identical, so reconstruct the stable tier and reuse
- # it ONLY when the kept prompt still literally starts with it
- # (same startswith gate as the restore path); otherwise the
- # request layer falls back to the legacy single-breakpoint
- # layout with the prompt bytes untouched.
from agent.system_prompt import reconstruct_static_prefix
reconstruct_static_prefix(
@@ -4010,8 +4002,18 @@ def compress_context(
log_label="compression keep-prompt",
)
else:
- new_system_prompt = agent._build_system_prompt(system_message)
+ new_system_prompt = rebuilt_system_prompt
agent._cached_system_prompt = new_system_prompt
+ if cached_system_prompt is not None:
+ logger.info(
+ "Compaction rebuilt a drifted system prompt "
+ "(session=%s, %d -> %d chars): builder output changed "
+ "since the stored snapshot (update, config change, or "
+ "memory/skills growth)",
+ agent.session_id or "none",
+ len(cached_system_prompt),
+ len(new_system_prompt),
+ )
_session_commit_succeeded = False
_commit_started_at = time.monotonic()
diff --git a/agent/system_prompt.py b/agent/system_prompt.py
index c129cd6736..f47300d9ed 100644
--- a/agent/system_prompt.py
+++ b/agent/system_prompt.py
@@ -209,8 +209,19 @@ def _frozen_plugin_prompt_sections(agent: Any) -> tuple:
rendered = tuple(render_system_prompt_sections(_plugin_session_info(agent)))
except Exception as exc:
- logger.warning("Plugin system prompt sections could not be rendered: %s", exc)
- rendered = ()
+ # Fail-open: a plugin whose render raises at a rebuild boundary
+ # keeps its last good bytes (stashed by invalidate_system_prompt)
+ # instead of silently vanishing from the prompt.
+ previous = getattr(agent, "_plugin_system_prompt_sections_previous", None)
+ if previous:
+ logger.warning(
+ "Plugin system prompt sections failed to re-render (%s); "
+ "keeping the previous frozen sections", exc,
+ )
+ rendered = previous
+ else:
+ logger.warning("Plugin system prompt sections could not be rendered: %s", exc)
+ rendered = ()
setattr(agent, attr, rendered)
return rendered
@@ -284,6 +295,13 @@ def _session_start_like(agent: Any, now: Any) -> Any:
a Thursday-morning resume), contradicting the fresh per-turn time hint.
Prefer, in order:
+ 0. the LINEAGE-ROOT session id's embedded timestamp — compaction can
+ rotate the session id, and each rotated id embeds its OWN mint time,
+ so after months of compactions rung 1 alone would quietly re-birth
+ the conversation at its latest rotation. Walking to the lineage root
+ (same walk as ``_conversation_root_id``) recovers the ORIGINAL
+ birth stamp — a Bot Mode forever-chat keeps knowing when it was
+ first born, across every compaction (maintainer-directed, #98426);
1. the timestamp embedded in ``session_id`` (``YYYYMMDD_HHMMSS_...``) —
immutable for the life of the session, so the line is byte-stable
across every rebuild boundary (preserving prefix-cache KV);
@@ -315,18 +333,30 @@ def _session_start_like(agent: Any, now: Any) -> Any:
pass
return dt
- # 1. Session id embeds the true start as YYYYMMDD_HHMMSS.
+ # 0. Lineage root: compaction rotation mints NEW ids with NEW embedded
+ # stamps. Walk to the root id (cached on the agent — the lineage only
+ # grows at compaction, and this function runs at that exact boundary,
+ # so one walk per rebuild is fresh enough) and prefer ITS embedded
+ # timestamp: the conversation's true birth. Fail-open to rung 1.
session_id = getattr(agent, "session_id", None)
- if isinstance(session_id, str) and session_id:
- m = re.match(r"^(\d{8})_(\d{6})", session_id)
- if m:
- try:
- embedded = datetime.strptime(
- f"{m.group(1)}_{m.group(2)}", "%Y%m%d_%H%M%S"
- )
- return _to_display_tz(embedded)
- except ValueError:
- pass
+ root_id = None
+ try:
+ db = getattr(agent, "_session_db", None)
+ if db is not None and isinstance(session_id, str) and session_id:
+ root_id = db.get_conversation_root(session_id)
+ except Exception:
+ root_id = None
+ for candidate in (root_id, session_id):
+ if isinstance(candidate, str) and candidate:
+ m = re.match(r"^(\d{8})_(\d{6})", candidate)
+ if m:
+ try:
+ embedded = datetime.strptime(
+ f"{m.group(1)}_{m.group(2)}", "%Y%m%d_%H%M%S"
+ )
+ return _to_display_tz(embedded)
+ except ValueError:
+ pass
# 2. Session-creation stamp set by the runner.
session_start = getattr(agent, "session_start", None)
@@ -1028,10 +1058,21 @@ def invalidate_system_prompt(agent: Any) -> None:
"""Invalidate the cached system prompt, forcing a rebuild on the next turn.
Called after context compression events. Also reloads memory from disk
- so the rebuilt prompt captures any writes from this session.
+ so the rebuilt prompt captures any writes from this session, and clears
+ the frozen plugin-section snapshot so plugins re-render at the same
+ boundary (maintainer-directed, #95681 arc): a plugin section is just
+ another prompt block carrying state — freezing it while memory, skills,
+ and guidance refresh would recreate the stale-block disease inside
+ plugin-land. The previous bytes are stashed so a plugin whose render
+ RAISES falls back to its last good section instead of vanishing
+ (fail-open guard, not a freeze).
"""
agent._cached_system_prompt = None
agent._cached_system_prompt_static = None
+ _snapshot_attr = "_plugin_system_prompt_sections_snapshot"
+ if hasattr(agent, _snapshot_attr):
+ agent._plugin_system_prompt_sections_previous = getattr(agent, _snapshot_attr)
+ delattr(agent, _snapshot_attr)
if agent._memory_store:
agent._memory_store.load_from_disk()
diff --git a/tests/agent/test_plugin_prompt_sections.py b/tests/agent/test_plugin_prompt_sections.py
index d27e59b6fc..da63bb37a4 100644
--- a/tests/agent/test_plugin_prompt_sections.py
+++ b/tests/agent/test_plugin_prompt_sections.py
@@ -41,7 +41,7 @@ def _install_test_section(manager: PluginManager, content) -> None:
)
-def test_real_aiagent_builds_section_once_and_keeps_it_out_of_static_prefix(monkeypatch):
+def test_real_aiagent_freezes_section_within_life_and_rerenders_on_invalidate(monkeypatch):
# Pin the workspace snapshot: build_coding_workspace_block shells out to
# live `git status`/`git log` on every build, and a git call failing or
# timing out under xdist contention makes the two builds differ in the
@@ -64,15 +64,26 @@ def test_real_aiagent_builds_section_once_and_keeps_it_out_of_static_prefix(monk
agent = _real_agent()
first = build_system_prompt(agent)
+ # Within a prompt's life the frozen section is reused: a second build
+ # WITHOUT invalidation must not re-run plugin code.
+ again = build_system_prompt(agent)
+ assert again == first
+ assert len(calls) == 1
+
+ # invalidate_system_prompt is the compaction/rebuild boundary (#98426):
+ # plugin sections re-render there like every other prompt block, so a
+ # long-lived session's plugin context converges instead of freezing at
+ # its birth bytes.
invalidate_system_prompt(agent)
rebuilt = build_system_prompt(agent)
- assert first == rebuilt
- assert len(calls) == 1
+ assert len(calls) == 2
+ assert "rules render 2" in rebuilt
+ assert "rules render 1" not in rebuilt
assert calls[0]["session_id"] == agent.session_id
- assert "## Plugin Context: example.rules" in first
- assert "rules render 1" in first
- assert first.index("## Plugin Context: example.rules") < first.index("Conversation started:")
+ assert calls[1]["session_id"] == agent.session_id
+ assert "## Plugin Context: example.rules" in rebuilt
+ assert rebuilt.index("## Plugin Context: example.rules") < rebuilt.index("Conversation started:")
assert "example.rules" not in agent._cached_system_prompt_static
@@ -129,18 +140,28 @@ def test_fresh_process_resume_restores_identical_full_prompt_without_callback(tm
]
_restore_or_build_system_prompt(agent, None, history)
restored = agent._cached_system_prompt
+ calls_after_restore = int(calls_path.read_text())
rebuilt_equal = None
+ rebuilt_has_changed = None
+ calls_after_rebuild = None
if os.environ["TEST_PHASE"] != "first":
+ # invalidate_system_prompt is the compaction boundary (#98426):
+ # plugin sections re-render there, so the rebuilt prompt picks
+ # up the plugin's CURRENT output — it is EXPECTED to differ
+ # from the restored bytes when the plugin's render changed.
invalidate_system_prompt(agent)
rebuilt = build_system_prompt(agent)
rebuilt_equal = rebuilt == restored
- agent._cached_system_prompt = rebuilt
+ rebuilt_has_changed = "CHANGED" in rebuilt
+ calls_after_rebuild = int(calls_path.read_text())
print(json.dumps({
"prompt_b64": base64.b64encode(
- agent._cached_system_prompt.encode("utf-8")
+ restored.encode("utf-8")
).decode("ascii"),
- "calls": int(calls_path.read_text()),
+ "calls": calls_after_restore,
"rebuilt_equal": rebuilt_equal,
+ "rebuilt_has_changed": rebuilt_has_changed,
+ "calls_after_rebuild": calls_after_rebuild,
}))
db.close()
"""
@@ -179,6 +200,12 @@ def test_fresh_process_resume_restores_identical_full_prompt_without_callback(tm
f"resumed={resumed_prompt[diff_at - 100:diff_at + 100]!r}"
)
assert b"original bytes" in first_prompt
+ # RESTORE stays frozen: the resumed prompt is byte-identical, plugin
+ # render never ran (calls unchanged). The REBUILD boundary re-renders:
+ # the rebuilt prompt carries the plugin's current output and the
+ # render ran exactly once more.
assert b"CHANGED" not in resumed_prompt
assert outputs[0]["calls"] == outputs[1]["calls"] == 1
- assert outputs[1]["rebuilt_equal"] is True
+ assert outputs[1]["rebuilt_equal"] is False
+ assert outputs[1]["rebuilt_has_changed"] is True
+ assert outputs[1]["calls_after_rebuild"] == 2
diff --git a/tests/agent/test_system_prompt.py b/tests/agent/test_system_prompt.py
index 62f9373165..15082b9856 100644
--- a/tests/agent/test_system_prompt.py
+++ b/tests/agent/test_system_prompt.py
@@ -567,6 +567,61 @@ class TestSessionStartLike:
assert start.strftime("%Y-%m-%d") == "2026-01-01"
assert start.tzinfo is not None
+ def test_prefers_lineage_root_over_rotated_segment_id(self):
+ """Compaction rotates session ids; each rotation embeds its own
+ mint time. The birth date must come from the lineage ROOT so a
+ Bot Mode forever-chat keeps knowing when it was first born
+ (#98426)."""
+ from agent.system_prompt import _session_start_like
+
+ class _Db:
+ def get_conversation_root(self, sid):
+ assert sid == "20260615_090000_seg9"
+ return "20260101_120000_root"
+
+ now = datetime(2026, 6, 16, 9, 0, tzinfo=ZoneInfo("UTC"))
+ agent = SimpleNamespace(
+ session_id="20260615_090000_seg9",
+ session_start=datetime(2026, 6, 15, 9, 0),
+ _session_db=_Db(),
+ )
+ start = _session_start_like(agent, now)
+ assert start.strftime("%Y-%m-%d") == "2026-01-01"
+
+ def test_lineage_walk_failure_falls_open_to_segment_id(self):
+ from agent.system_prompt import _session_start_like
+
+ class _Db:
+ def get_conversation_root(self, sid):
+ raise RuntimeError("db locked")
+
+ now = datetime(2026, 6, 16, 9, 0, tzinfo=ZoneInfo("UTC"))
+ agent = SimpleNamespace(
+ session_id="20260615_090000_seg9",
+ session_start=datetime(2026, 6, 15, 9, 0),
+ _session_db=_Db(),
+ )
+ start = _session_start_like(agent, now)
+ assert start.strftime("%Y-%m-%d") == "2026-06-15"
+
+ def test_nontimestamp_root_falls_through_to_segment_id(self):
+ """A root id without an embedded stamp (legacy/imported lineage)
+ must not break the ladder — rung 1 still applies."""
+ from agent.system_prompt import _session_start_like
+
+ class _Db:
+ def get_conversation_root(self, sid):
+ return "imported-legacy-root"
+
+ now = datetime(2026, 6, 16, 9, 0, tzinfo=ZoneInfo("UTC"))
+ agent = SimpleNamespace(
+ session_id="20260615_090000_seg9",
+ session_start=datetime(2026, 6, 15, 9, 0),
+ _session_db=_Db(),
+ )
+ start = _session_start_like(agent, now)
+ assert start.strftime("%Y-%m-%d") == "2026-06-15"
+
def test_falls_back_to_session_start(self):
from agent.system_prompt import _session_start_like
diff --git a/tests/run_agent/test_413_compression.py b/tests/run_agent/test_413_compression.py
index 9800c44cef..a91a585823 100644
--- a/tests/run_agent/test_413_compression.py
+++ b/tests/run_agent/test_413_compression.py
@@ -481,8 +481,11 @@ class TestPreflightCompression:
{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"},
{"role": "user", "content": "hello", _DB_PERSISTED_MARKER: True},
]
- assert new_system_prompt == "You are helpful."
- build_prompt.assert_not_called()
+ # Post-#98426 the commit boundary ALWAYS runs the live builder;
+ # its output differs from the cached prompt here, so the rebuilt
+ # prompt wins.
+ assert new_system_prompt == "new system prompt"
+ build_prompt.assert_called_once()
assert events == [
("lifecycle", COMPACTION_STATUS),
("compress", "started"),
@@ -529,7 +532,12 @@ class TestPreflightCompression:
assert ("compacted", COMPACTION_DONE_STATUS) not in events
def test_compression_reuses_cached_prompt_when_memory_snapshot_is_unchanged(self, agent):
- """A memory reload without new injected text must keep the cache prefix."""
+ """A byte-equal rebuild must keep the EXACT cached prompt object.
+
+ Post-#98426 the commit boundary always runs the live builder; when
+ its output is byte-identical to the stored prompt, the ORIGINAL
+ string object is kept (KV/prefix caches keyed on identity survive).
+ """
agent.compression_enabled = False
agent._memory_enabled = True
agent._user_profile_enabled = False
@@ -547,7 +555,11 @@ class TestPreflightCompression:
"compress",
return_value=[{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}],
),
- patch.object(agent, "_build_system_prompt") as build_prompt,
+ patch.object(
+ agent,
+ "_build_system_prompt",
+ return_value="cached system prompt\n\nsame facts",
+ ) as build_prompt,
):
_, new_system_prompt = agent._compress_context(
[{"role": "user", "content": "hello"}],
@@ -557,7 +569,7 @@ class TestPreflightCompression:
assert new_system_prompt is agent._cached_system_prompt
assert new_system_prompt == "cached system prompt\n\nsame facts"
- build_prompt.assert_not_called()
+ build_prompt.assert_called_once()
memory_store.load_from_disk.assert_called_once()
diff --git a/tests/test_compaction_prompt_rebuild.py b/tests/test_compaction_prompt_rebuild.py
new file mode 100644
index 0000000000..2c54e804e3
--- /dev/null
+++ b/tests/test_compaction_prompt_rebuild.py
@@ -0,0 +1,93 @@
+"""Compaction ALWAYS rebuilds the system prompt from the live builder (#95681).
+
+The old keep-prompt containment branch restored the stored bytes whenever the
+reloaded memory blocks were embedded — so prompt-builder changes (guidance
+diets, renames, new blocks) never reached long-lived sessions (Bot Mode
+forever-chats, gateway channels). New contract:
+
+1. builder output byte-equal -> keep the ORIGINAL string object (identity
+ preserved for KV/prefix caches keyed on it)
+2. builder output differs -> the rebuilt prompt wins, logged
+3. plugin sections re-render at the same boundary; a RAISING plugin falls
+ back to its last good bytes (fail-open), never silently vanishes
+"""
+import unittest
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from agent.system_prompt import invalidate_system_prompt
+
+
+def _agent(**over):
+ base = dict(
+ _cached_system_prompt="OLD PROMPT",
+ _cached_system_prompt_static="OLD",
+ _memory_store=None,
+ )
+ base.update(over)
+ return SimpleNamespace(**base)
+
+
+class TestInvalidateClearsPluginFreeze(unittest.TestCase):
+ def test_invalidate_stashes_and_clears_plugin_snapshot(self):
+ agent = _agent()
+ agent._plugin_system_prompt_sections_snapshot = ("frozen-section",)
+ invalidate_system_prompt(agent)
+ self.assertFalse(hasattr(agent, "_plugin_system_prompt_sections_snapshot"))
+ self.assertEqual(agent._plugin_system_prompt_sections_previous, ("frozen-section",))
+ self.assertIsNone(agent._cached_system_prompt)
+
+ def test_invalidate_without_snapshot_is_noop_for_plugins(self):
+ agent = _agent()
+ invalidate_system_prompt(agent)
+ self.assertFalse(hasattr(agent, "_plugin_system_prompt_sections_snapshot"))
+
+
+class TestPluginRerenderFailOpen(unittest.TestCase):
+ def test_raising_plugin_render_falls_back_to_previous_bytes(self):
+ from agent.system_prompt import _frozen_plugin_prompt_sections
+
+ agent = _agent(_cached_system_prompt=None)
+ agent._plugin_system_prompt_sections_previous = ("last-good",)
+ with patch("hermes_cli.plugins.render_system_prompt_sections",
+ side_effect=RuntimeError("plugin exploded")):
+ rendered = _frozen_plugin_prompt_sections(agent)
+ self.assertEqual(rendered, ("last-good",))
+
+ def test_raising_plugin_render_without_previous_is_empty(self):
+ from agent.system_prompt import _frozen_plugin_prompt_sections
+
+ agent = _agent(_cached_system_prompt=None)
+ with patch("hermes_cli.plugins.render_system_prompt_sections",
+ side_effect=RuntimeError("plugin exploded")):
+ rendered = _frozen_plugin_prompt_sections(agent)
+ self.assertEqual(rendered, ())
+
+
+class TestCommitAlwaysRebuilds(unittest.TestCase):
+ """Source-level contract pins for the commit-site semantics."""
+
+ def _src(self):
+ import inspect
+ from agent import conversation_compression as cc
+ return inspect.getsource(cc)
+
+ def test_keep_prompt_branch_requires_byte_equality(self):
+ src = self._src()
+ i = src.find("rebuilt_system_prompt = agent._build_system_prompt(")
+ self.assertGreater(i, 0, "commit site must always run the live builder")
+ window = src[i:i + 900]
+ self.assertIn("rebuilt_system_prompt == cached_system_prompt", window,
+ "keep-prompt must be gated on BYTE EQUALITY of the "
+ "rebuilt output, not on memory containment")
+ self.assertNotIn("_cached_prompt_reflects_builtin_memory(agent, cached_system_prompt)",
+ window,
+ "the containment keep-prompt gate must not return")
+
+ def test_drift_rebuild_is_logged(self):
+ src = self._src()
+ self.assertIn("Compaction rebuilt a drifted system prompt", src)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_compaction_tool_refresh.py b/tests/test_compaction_tool_refresh.py
index 405a78a096..ce14f5e01c 100644
--- a/tests/test_compaction_tool_refresh.py
+++ b/tests/test_compaction_tool_refresh.py
@@ -81,8 +81,10 @@ class TestCompactionWiring(unittest.TestCase):
"""The commit boundary invokes the refresh and a raising refresh
must not break compaction (wrapped in try/except at the call site).
Pin the call-site contract by source: the helper call sits between
- _invalidate_system_prompt and the prompt rebuild, inside a
- try/except."""
+ _invalidate_system_prompt and the always-rebuild of the prompt
+ (post-#95681: the keep-prompt containment branch is gone — the
+ rebuilt prompt is compared byte-for-byte and only object identity
+ is preserved on equality)."""
import inspect
from agent import conversation_compression as cc
@@ -90,7 +92,7 @@ class TestCompactionWiring(unittest.TestCase):
i_invalidate = src.find("agent._invalidate_system_prompt()")
i_refresh = src.find("_refresh_agent_tool_definitions(agent)",
i_invalidate)
- i_rebuild = src.find("_cached_prompt_reflects_builtin_memory(agent",
+ i_rebuild = src.find("rebuilt_system_prompt = agent._build_system_prompt(",
i_refresh)
self.assertGreater(i_refresh, i_invalidate,
"refresh must follow prompt invalidation")