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")