feat(compaction): system prompt always rebuilds at the commit boundary — updates finally reach long-lived sessions (#98426)

* feat(compaction): always rebuild the system prompt at the commit boundary — keep-prompt now gated on byte equality of the LIVE builder output; plugin sections re-render with fail-open to last good bytes

* feat(clock): 'Conversation started' resolves through the session-lineage ROOT — a compacted/rotated session keeps its original birth date (Bot Mode forever-chats know when they were first born)

* test: retire old-contract pins — plugin sections re-render at invalidate (freeze stays restore-only), commit boundary always runs the live builder, byte-equal keep preserves object identity
This commit is contained in:
Teknium
2026-08-30 01:20:27 -07:00
committed by GitHub
parent 26350357d7
commit 514707ff3e
7 changed files with 286 additions and 54 deletions

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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\n<memory>same facts</memory>",
) 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\n<memory>same facts</memory>"
build_prompt.assert_not_called()
build_prompt.assert_called_once()
memory_store.load_from_disk.assert_called_once()

View File

@@ -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()

View File

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