mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
fb1b89b09e
commit
18af81bb5b
3 changed files with 135 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue