diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 37b681b3a75..723f50d2ea0 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -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 diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index daa6cac807a..fae6ff2e695 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -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" diff --git a/tests/agent/test_system_prompt_restore.py b/tests/agent/test_system_prompt_restore.py index 956c1152a42..72564325d93 100644 --- a/tests/agent/test_system_prompt_restore.py +++ b/tests/agent/test_system_prompt_restore.py @@ -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"])