fix(caching): reconstruct static system prefix on session restore and post-compression reuse

Follow-up to the cherry-picked #68258 base: the cross-session-stable
prefix (_cached_system_prompt_static) was only recorded on fresh
builds, so two paths silently degraded to the legacy single-breakpoint
layout (flagged in review of #68258/#69341/#69704):

- Session restore: gateway surfaces build a fresh AIAgent per turn and
  restore the persisted prompt verbatim from the session DB; the static
  prefix stayed None from turn 2 onward, flip-flopping the wire layout.
- Post-compression cached-prompt reuse: _invalidate_system_prompt()
  clears the static prefix, and the keep-cached-prompt branch never
  restored it.

Both sites now reconstruct the stable tier and adopt it ONLY when the
authoritative prompt string literally startswith() it — stable-tier
drift (skills edited, identity changed) falls back to the legacy layout
with the stored bytes untouched. Fail-open on any builder error. The
restore-path rebuild is gated on _use_prompt_caching so non-Anthropic
routes skip it entirely.

Refs #68191

Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
This commit is contained in:
teknium1 2026-07-24 12:32:18 -07:00 committed by Teknium
parent fb1b89b09e
commit 18af81bb5b
3 changed files with 135 additions and 0 deletions

View file

@ -1969,6 +1969,21 @@ def compress_context(
):
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.
try:
from agent.system_prompt import build_system_prompt_parts as _build_parts
_static = _build_parts(agent, system_message=system_message)["stable"]
if _static and cached_system_prompt.startswith(_static):
agent._cached_system_prompt_static = _static
except Exception:
pass
else:
new_system_prompt = agent._build_system_prompt(system_message)
agent._cached_system_prompt = new_system_prompt

View file

@ -436,6 +436,37 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
# Continuing session — reuse the exact system prompt from the
# previous turn so the Anthropic cache prefix matches.
agent._cached_system_prompt = stored_prompt
# Reconstruct the cross-session-stable prefix for the early cache
# breakpoint. The static prefix is not persisted (only the full
# prompt is), so gateway surfaces that build a fresh AIAgent per
# turn would otherwise lose the two-block system layout after the
# first turn — flip-flopping the wire shape mid-conversation and
# silently degrading to the legacy single-breakpoint layout.
#
# Safety: the rebuilt stable tier is used ONLY when the restored
# prompt literally starts with it (checked here AND re-checked by
# ``_apply_system_cache_markers``'s ``startswith`` gate). If any
# stable-tier input changed since the prompt was persisted (skills
# edited, identity changed), the prefix mismatches, ``_static``
# stays None, and the request falls back to the legacy layout with
# the restored prompt bytes untouched — never a rewritten prompt.
#
# Gated on ``_use_prompt_caching`` so non-Anthropic routes skip the
# rebuild entirely (the static prefix is only consumed by
# ``apply_anthropic_cache_control``).
if getattr(agent, "_use_prompt_caching", False):
try:
from agent.system_prompt import build_system_prompt_parts as _build_parts
_static = _build_parts(agent, system_message=system_message)["stable"]
if _static and stored_prompt.startswith(_static):
agent._cached_system_prompt_static = _static
except Exception:
# Fail-open: restore continues with the legacy cache layout.
logger.debug(
"static system-prefix reconstruction failed on restore",
exc_info=True,
)
return
if stored_prompt:
stored_state = "stale_runtime"

View file

@ -32,6 +32,10 @@ def _make_agent(session_db=None, prebuilt_prompt: str = "BUILT_PROMPT"):
agent.provider = "openrouter"
agent.platform = "cli"
agent._session_db = session_db
# MagicMock attributes are truthy by default; the static-prefix
# reconstruction is gated on _use_prompt_caching, so default it off
# for the legacy restore tests (the reconstruction tests enable it).
agent._use_prompt_caching = False
agent._build_system_prompt = MagicMock(return_value=prebuilt_prompt)
return agent
@ -261,5 +265,90 @@ class TestPromptStabilityInvariant:
assert agent._cached_system_prompt.encode("utf-8") == stored.encode("utf-8")
# ---------------------------------------------------------------------------
# Cross-session static prefix reconstruction (issue #68191 follow-up)
# ---------------------------------------------------------------------------
class TestStaticPrefixReconstructionOnRestore:
"""The two-block cache layout must survive session restore.
Gateway surfaces construct a fresh AIAgent per turn and restore the
persisted prompt from the session DB; the cross-session-stable prefix
(``_cached_system_prompt_static``) is only set on fresh builds, so
without reconstruction the wire layout silently degrades to the legacy
single-breakpoint layout after turn 1 (flagged on PR #68258 review).
"""
def test_restore_reconstructs_static_prefix_when_it_matches(self):
stable = "STATIC IDENTITY AND GUIDANCE"
stored = stable + "\n\nper-session context\n\nvolatile tail"
db = MagicMock()
db.get_session.return_value = {"system_prompt": stored}
agent = _make_agent(session_db=db)
agent._use_prompt_caching = True
agent._cached_system_prompt_static = None
from unittest.mock import patch as _patch
with _patch(
"agent.system_prompt.build_system_prompt_parts",
return_value={"stable": stable, "context": "", "volatile": ""},
):
_restore_or_build_system_prompt(
agent, None, [{"role": "user", "content": "hi"}]
)
# Restored prompt bytes untouched; static prefix reconstructed.
assert agent._cached_system_prompt == stored
assert agent._cached_system_prompt_static == stable
def test_restore_leaves_static_unset_on_prefix_mismatch(self):
"""Stable-tier drift (skills edited since persist) → no static prefix,
legacy layout, restored bytes still authoritative."""
stored = "OLD STATIC HEAD\n\nper-session context"
db = MagicMock()
db.get_session.return_value = {"system_prompt": stored}
agent = _make_agent(session_db=db)
agent._use_prompt_caching = True
agent._cached_system_prompt_static = None
from unittest.mock import patch as _patch
with _patch(
"agent.system_prompt.build_system_prompt_parts",
return_value={"stable": "NEW STATIC HEAD", "context": "", "volatile": ""},
):
_restore_or_build_system_prompt(
agent, None, [{"role": "user", "content": "hi"}]
)
assert agent._cached_system_prompt == stored
assert agent._cached_system_prompt_static is None
def test_restore_survives_parts_builder_exception(self):
"""Prefix reconstruction is fail-open: a parts-builder crash must not
break the byte-identical restore."""
stored = "Stored prompt — must survive"
db = MagicMock()
db.get_session.return_value = {"system_prompt": stored}
agent = _make_agent(session_db=db)
agent._use_prompt_caching = True
agent._cached_system_prompt_static = None
from unittest.mock import patch as _patch
with _patch(
"agent.system_prompt.build_system_prompt_parts",
side_effect=RuntimeError("boom"),
):
_restore_or_build_system_prompt(
agent, None, [{"role": "user", "content": "hi"}]
)
assert agent._cached_system_prompt == stored
assert agent._cached_system_prompt_static is None
if __name__ == "__main__":
pytest.main([__file__, "-v"])