From 07392c462ca5e9bbe25317cf9fb29e998ee213d7 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:37:17 -0700 Subject: [PATCH 1/2] feat(cache): generalize breakpoint eligibility to unstable-suffix, not trailing-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_count_trailing_ephemeral_messages` walked backward from the end and stopped at the first message that was either role="tool" or NOT marked ephemeral -- so it could only ever exclude a TRAILING run of ephemeral messages from cache-breakpoint eligibility. The system-reminder redesign (in-flight, amplifier-module-loop-streaming) introduces a LEADING pre-user reminder block: `[..., block(ephemeral, ...), user(real), ...]`. Under `ephemeral_injection_mode="tail"` + `reminder_placement="pre_user"`, that block is regenerated per request (never persisted) but is no longer trailing -- the old walk's count stayed 0, so a cache breakpoint could land at or after the block, guaranteeing a cache miss on the very next request (the exact "write every turn, read never" failure class this provider's cache-breakpoint logic exists to prevent). Replaces `_count_trailing_ephemeral_messages` with `_unstable_suffix_length`, keyed on the metadata signal already present in the data: `ephemeral=True, persisted=True` = STABLE (frozen canonical history, written via `context.add_message`, safe to cache past); `ephemeral=True` alone (no `persisted`) = UNSTABLE (regenerated per request). Everything from the last unstable message through the end of the conversation is excluded from eligibility, whether or not that message is itself last. This is a STRICT GENERALIZATION, verified against every shape in the spec's own table plus a from-scratch stash-compare of the full suite: - Tail mode today (`[..., asst, block]`): old excludes 1, new excludes 1. Identical. - Tail mode + the redesign's pre-user placement (`[..., asst, block, user]`): old excludes 0 (bug), new excludes 2 (fixed). - Persist mode (`[..., block(persisted), user]`): old excludes 0, new excludes 0. Identical (the block is genuinely frozen history). - Persist mode, mid-loop trailing block (`[..., block(persisted)]`): old excludes 1 (over-conservative -- the old walk never checked `persisted`), new excludes 0 (improvement: a stable tail can now take a breakpoint). - All-ephemeral request: old excludes len, new excludes len. Identical (the `eligible_upper <= 0` guard still fires). - No metadata anywhere: unaffected either way (the loud-skip path is untouched). `_find_rolling_secondary_index` (the secondary/rolling cache breakpoint) is DELIBERATELY LEFT UNCHANGED, per the spec's own instruction -- but its docstring is corrected here to describe the ACTUALLY VERIFIED behavior under the new pre-user-block shape (empirically probed, not just derived on paper): the secondary breakpoint's real benefit for this shape is a WITHIN-A-TURN property (stable across a turn's own tool-loop iterations), not a cross-turn one as the spec's hand-derived trace assumed -- see the updated docstring and T-W5-04's test for the full derivation and the concrete counter-example that led to the correction. Tests (tests/test_prompt_cache_breakpoints.py, 28 -> 35): - Updated: the docstring on the existing trailing-tail test now states explicitly that it stays a genuinely UNSTABLE shape (no `persisted` key), and a new persisted-metadata variant of the existing multi-message regression guard is added alongside it. - New (6 tests, T-W5-01..06): unstable block before the trailing user excludes both from eligibility (FAILS on 833403b -- confirmed via `git stash`: old code stamped BOTH the block and the trailing user); persisted block before the user does not reduce eligibility (passes both before and after -- documented pin, not a fail-before proof, see PR description); a persisted TRAILING message may now take a breakpoint (FAILS on 833403b -- confirmed via `git stash`: old code always excluded it); the rolling secondary breakpoint's real within-turn-iteration behavior under the pre-user-block shape (passes both before and after -- `_find_rolling_secondary_index` is untouched); an unstable message buried behind a tool-role batch degrades safely, no crash; the pre-user-block shape still respects the 4-breakpoint hard limit. Suite: 754 passed (747 baseline + 7 net new/changed). `python_check`: 16 errors / 19 warnings across both touched files, confirmed via `git stash` compare against unmodified `833403b` to be identical, pre-existing, and unrelated to this diff (mock `.get()` typed as list-index access, blind-except lint warnings elsewhere in the file, etc.) -- zero new issues introduced. Spec: reminder-redesign-spec.md, section W5. Per the spec's dependency-ordered shipping order (section 13), W5 must land before W1 (amplifier-module-loop-streaming) -- it is a strict generalization correct for today's shapes too, so it ships ahead safely and prevents W1 from introducing a tail-mode cache regression when it lands. Merge note: this PR is intentionally left OPEN, not merged, pending DTU validation per the task's DTU-gated-merge policy (see PR description). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../__init__.py | 139 +++++-- tests/test_prompt_cache_breakpoints.py | 356 +++++++++++++++++- 2 files changed, 454 insertions(+), 41 deletions(-) diff --git a/amplifier_module_provider_anthropic/__init__.py b/amplifier_module_provider_anthropic/__init__.py index ca845de..2fd68a7 100644 --- a/amplifier_module_provider_anthropic/__init__.py +++ b/amplifier_module_provider_anthropic/__init__.py @@ -3267,8 +3267,8 @@ async def _complete_chat_request( # Determine ephemeral status BEFORE conversion -- Message.metadata is # only available on the original Message objects; _convert_messages # discards unrecognized keys when it rebuilds Anthropic-format dicts. - trailing_ephemeral_count, has_ephemeral_signal = ( - self._count_trailing_ephemeral_messages(conversation) + unstable_suffix_len, has_ephemeral_signal = self._unstable_suffix_length( + conversation ) # Track how many of the 4 Anthropic cache breakpoints are used, so we @@ -3327,7 +3327,7 @@ async def _complete_chat_request( all_messages, conversation_breakpoints_used = ( self._apply_conversation_cache_control( all_messages, - trailing_ephemeral_count, + unstable_suffix_len, has_ephemeral_signal, conversation_budget, ) @@ -5184,46 +5184,63 @@ def _last_safe_breakpoint_index( idx -= 1 return None - def _count_trailing_ephemeral_messages( - self, conversation: list[Message] - ) -> tuple[int, bool]: - """Count how many trailing conversation messages are ephemeral. - - Ephemeral status is read from ``Message.metadata["ephemeral"]`` -- - the same contract ``HookResult.ephemeral`` is meant to carry through - to the provider (see ``amplifier_core.models.HookResult``). Walking - from the end, the count stops at the first message that is either - not marked ephemeral, or has ``role == "tool"``: tool-role messages - can be batched together by ``_convert_messages`` (many input - messages -> one output message), which would break the 1:1 index - correspondence this count relies on. Ephemeral injections are never - tool-role in the current architecture (HookResult.context_injection_role - is one of "system"/"user"/"assistant"), so this is a hard stop, not a - heuristic guess. + def _unstable_suffix_length(self, conversation: list[Message]) -> tuple[int, bool]: + """How many trailing entries must be excluded from breakpoint eligibility. + + A message is UNSTABLE when it is marked ephemeral but not persisted -- + i.e. regenerated per request, so any cached prefix containing it can + never be reproduced. Everything from the last unstable message to the + end is excluded, whether or not the unstable message is itself last + (the pre-user reminder block introduced by the system-reminder + redesign is not: it sits BEFORE the real user message, not after it). + + Ephemeral AND persisted is STABLE: it was written into canonical + history via context.add_message and is byte-frozen from then on + (the persist-mode reminder block, and the orchestrator's own + budget-warning message, both qualify). + + This is a strict generalization of the prior + ``_count_trailing_ephemeral_messages`` (any ephemeral message, + regardless of position, disqualified the tail): that method's + behavior is reproduced exactly whenever ephemeral content only ever + appears at the true tail (today's shape, and the redesign's own + tail-injection-mode shape), and additionally handles a leading + ephemeral-but-unstable block correctly, which the old walk could not + express at all. + + The role == "tool" hard stop is retained verbatim from + _count_trailing_ephemeral_messages: _convert_messages batches + consecutive tool messages many-to-one, which breaks the 1:1 index + correspondence a count-from-the-end relies on. Stopping early is safe + here because view-only (unstable) injections only ever exist for the + CURRENT request -- they are never in canonical history, so none can be + buried behind an earlier tool batch. Returns: - (trailing_ephemeral_count, has_any_metadata_signal). The second - value tells the caller whether *any* message in the conversation - carries a metadata dict at all -- i.e. whether "not marked - ephemeral" is a trustworthy negative, or simply means nothing in - this deployment ever populates the field. + (excluded, has_any_metadata_signal). ``excluded`` is a count from + the end (never an absolute index -- ``all_messages`` combines + context-prefix messages with converted conversation messages, so + pre-conversion indices do not map 1:1 onto it). + ``has_any_metadata_signal`` tells the caller whether *any* + message in the conversation carries a metadata dict at all -- + i.e. whether "not unstable" is a trustworthy negative, or simply + means nothing in this deployment ever populates the field. """ has_any_metadata_signal = any(bool(m.metadata) for m in conversation) - count = 0 - for msg in reversed(conversation): + excluded = 0 + for walked, msg in enumerate(reversed(conversation), start=1): if msg.role == "tool": break - if bool((msg.metadata or {}).get("ephemeral")): - count += 1 - continue - break - return count, has_any_metadata_signal + md = msg.metadata or {} + if md.get("ephemeral") and not md.get("persisted"): + excluded = walked + return excluded, has_any_metadata_signal def _apply_conversation_cache_control( self, all_messages: list[dict[str, Any]], - trailing_ephemeral_count: int, + unstable_suffix_len: int, has_ephemeral_signal: bool, remaining_budget: int, ) -> tuple[list[dict[str, Any]], int]: @@ -5243,8 +5260,12 @@ def _apply_conversation_cache_control( The fix has two parts: - 1. Never place a breakpoint on a message known to be ephemeral -- - walk backward past ``trailing_ephemeral_count`` messages first. + 1. Never place a breakpoint on a message known to be unstable + (ephemeral and not persisted -- regenerated per request) -- + walk backward past ``unstable_suffix_len`` messages first. Unlike + the prior trailing-only walk, this correctly excludes an unstable + message wherever it sits in the eligible window, not only when + it is literally last (see ``_unstable_suffix_length``). 2. Use the *two* breakpoints Anthropic's docs describe for multi-turn conversations: one at the current stable boundary ("primary") and one right before the previous real user turn @@ -5257,13 +5278,13 @@ def _apply_conversation_cache_control( all_messages: Anthropic-formatted message array (context-prefix messages followed by conversation messages), mutated in place. - trailing_ephemeral_count: number of trailing entries of the - conversation region known to be ephemeral (see - ``_count_trailing_ephemeral_messages``). + unstable_suffix_len: number of trailing entries of the + conversation region excluded from breakpoint eligibility + (see ``_unstable_suffix_length``). has_ephemeral_signal: whether ephemeral status could be determined at all for this request (see above). If False, - "not marked ephemeral" cannot be trusted as a real signal, - and placing breakpoints on unverified content would risk + "not unstable" cannot be trusted as a real signal, and + placing breakpoints on unverified content would risk repeating the exact bug this method exists to fix. remaining_budget: cache breakpoints left before hitting Anthropic's 4-breakpoint hard limit (system + tools may @@ -5318,7 +5339,7 @@ def _apply_conversation_cache_control( ) return all_messages, 0 - eligible_upper = len(all_messages) - trailing_ephemeral_count + eligible_upper = len(all_messages) - unstable_suffix_len if eligible_upper <= 0: logger.warning( "[PROVIDER] Prompt caching: every message in this request is " @@ -5372,6 +5393,44 @@ def _find_rolling_secondary_index( breakpoints keeps landing on a previously-cached boundary as the conversation grows, per Anthropic's documented multi-turn caching pattern. + + Deliberately left alone under the system-reminder redesign's + pre-user reminder block (a stable, persisted, `role="user"` message + written immediately before the turn's real user message -- see + ``_unstable_suffix_length``). Verified empirically (see + tests/test_prompt_cache_breakpoints.py's T-W5-04 test), not just + derived on paper -- the actual mechanism is subtler than "the block + becomes the previous turn's primary": + + At iteration 1 of a fresh turn N+1 (request ends `[..., block N+1, + user N+1]`, no assistant reply yet), `primary_idx` IS `user N+1`'s + own index. This method's search starts AT `primary_idx` itself, and + since that message is `role="user"` and not a tool_result batch, it + matches on the very first loop iteration -- `last_user_turn_idx` + resolves to `user N+1`'s own index (not the block's), and the + secondary is `safe(last_user_turn_idx - 1)`, which lands ON `block + N+1` (it sits directly before `user N+1`). + + Once the model replies (iteration 2+: `[..., block N+1, user N+1, + assistant N+1(tool_use), tool_result, ...]`), `primary_idx` advances + into the tool_result batch. The search now walks PAST the + tool_result (excluded: it IS a tool_result batch) and past the + assistant message, and matches `user N+1` the same way -- so the + secondary lands on `block N+1` AGAIN, at the same position, across + every iteration of the SAME turn. That is the real rolling-overlap + property this reminder-block shape gets from this method, unchanged: + a consistent secondary target across a turn's own tool-loop + iterations. (A cross-TURN overlap -- turn N+1's secondary matching + turn N's OWN primary from the request that generated it -- does NOT + hold for this shape: that hypothetical prior request also ends in + `[..., block N, user N]` with no assistant reply yet, so its own + primary is `user N`'s index, not `block N`'s -- there is no shared + position between the two calls in that comparison. This is a + pre-existing property of "primary lands on a user-role message + when nothing follows it yet", not something the system-reminder + redesign changes.) Do not "fix" this to skip reminder blocks -- + skipping them here would break the WITHIN-turn overlap that + genuinely exists. """ last_user_turn_idx: int | None = None for idx in range(min(primary_idx, eligible_upper - 1), -1, -1): diff --git a/tests/test_prompt_cache_breakpoints.py b/tests/test_prompt_cache_breakpoints.py index 4d7f7e8..95c8d49 100644 --- a/tests/test_prompt_cache_breakpoints.py +++ b/tests/test_prompt_cache_breakpoints.py @@ -34,7 +34,6 @@ ToolCallBlock, ToolSpec, ) - from amplifier_module_provider_anthropic import AnthropicProvider from tests._helpers import DummyResponse @@ -122,10 +121,32 @@ def _ephemeral_tail(text: str) -> Message: role from `context_injection_role` (default/observed: "user"), content is the injected text, and -- once the necessary upstream plumbing fix is applied -- `metadata={"ephemeral": True}`. + + This is UNSTABLE under `_unstable_suffix_length` (ephemeral, no + `persisted` key) -- regenerated per request, e.g. loop-streaming's + `ephemeral_injection_mode="tail"` path, or the view-only splice for + `reminder_placement="pre_user"` in tail mode. """ return Message(role="user", content=text, metadata={"ephemeral": True}) +def _persisted_reminder(text: str) -> Message: + """Build a message shaped like the system-reminder redesign's + persist-mode reminder block (loop-streaming's `ephemeral_injection_mode + ="persist"` path): written into canonical history via + `context.add_message(...)`, so it is byte-frozen from then on. + + This is STABLE under `_unstable_suffix_length` + (`ephemeral=True, persisted=True`) -- safe to cache past, unlike + `_ephemeral_tail`'s unstable shape. + """ + return Message( + role="user", + content=text, + metadata={"ephemeral": True, "persisted": True}, + ) + + def _run(provider: AnthropicProvider, request: ChatRequest) -> dict: params = _capture_params(provider) @@ -198,6 +219,11 @@ def test_never_exceeds_four_breakpoints_even_with_many_turns(): def test_breakpoint_never_lands_on_ephemeral_tail_message(): + """Still valid under `_unstable_suffix_length` (system-reminder redesign, + W5): `_ephemeral_tail` carries no `persisted` key, so this remains an + UNSTABLE trailing message -- the mid-loop `ephemeral_injection_mode= + "tail"` shape (or the `reminder_placement="pre_user"` tail-mode splice, + which is view-only and therefore never persisted either).""" provider = _make_provider() messages: list[Message] = [Message(role="system", content="System prompt.")] @@ -598,6 +624,56 @@ def test_multi_message_with_ephemeral_metadata_places_breakpoints_as_before(capl ) +def test_multi_message_with_persisted_ephemeral_metadata_places_breakpoints_as_before( + caplog, +): + """Same regression guard as above, with a `persisted: True` variant + (system-reminder redesign, W5/T-W5-02): a persist-mode reminder block + sitting immediately before the trailing user message must not reduce + breakpoint eligibility at all -- it is genuinely frozen canonical + history, not regenerated content. Numerically this shape produces the + SAME `eligible_upper` as the pre-existing `_ephemeral_tail` (unstable) + variant above under BOTH the old and new algorithms here (the old + trailing-only walk never even reaches this position, since the very + last message is the real, non-ephemeral user text) -- pinning that this + stays true is the point: a future, less careful generalization could + easily start treating any `ephemeral=True` message as exclusion-worthy + regardless of `persisted`, which would silently regress this exact + case.""" + provider = _make_provider() + + messages: list[Message] = [Message(role="system", content="System prompt.")] + for i in range(3): + messages.extend(_turn(f"question {i}", f"answer {i}")) + messages.append(_persisted_reminder("...")) + messages.append(Message(role="user", content="the real ask")) + + request = ChatRequest(messages=messages) + + import logging + + with caplog.at_level(logging.WARNING, logger="amplifier_module_provider_anthropic"): + params = _run(provider, request) + + own_records = [ + r for r in caplog.records if r.name == "amplifier_module_provider_anthropic" + ] + assert not any(r.levelno >= logging.WARNING for r in own_records) + + found_breakpoint = False + for msg in params["messages"]: + content = msg.get("content") + if isinstance(content, list) and any( + isinstance(b, dict) and "cache_control" in b for b in content + ): + found_breakpoint = True + assert found_breakpoint, ( + "expected at least one conversation-region cache breakpoint even " + "with a persisted reminder block immediately before the trailing " + "user message" + ) + + # --------------------------------------------------------------------------- # Extended (1h) TTL opt-in for the stable system/tools region # --------------------------------------------------------------------------- @@ -1221,3 +1297,281 @@ def test_breakpoint_never_lands_on_a_redacted_thinking_block(): assert not offenders, ( f"cache_control landed on a redacted_thinking block: {offenders}" ) + + +# --------------------------------------------------------------------------- +# System-reminder redesign, W5: `_unstable_suffix_length` generalization. +# +# Prior to this patch, `_count_trailing_ephemeral_messages` walked backward +# from the end and stopped at the first message that was either role="tool" +# or NOT marked ephemeral -- i.e. it could only ever exclude a TRAILING run +# of ephemeral messages. The system-reminder redesign introduces a leading +# (pre-user) reminder block: `[..., block(ephemeral, ...), user(real), ...]`. +# `_unstable_suffix_length` distinguishes STABLE (ephemeral AND persisted -- +# frozen canonical history) from UNSTABLE (ephemeral, NOT persisted -- +# regenerated per request) and excludes everything from the last unstable +# message through the end, whether or not that message is itself last. +# --------------------------------------------------------------------------- + + +def test_unstable_block_before_trailing_user_excludes_both_from_eligibility(): + """T-W5-01 (the core W5 bug). An unstable (ephemeral, not persisted) + block sitting BEFORE the trailing real user message -- the shape + produced by loop-streaming's `ephemeral_injection_mode="tail"` + + `reminder_placement="pre_user"` -- must exclude BOTH the block and the + user message that follows it from breakpoint eligibility. A breakpoint + landing AT OR AFTER the block would still cache the block's + regenerated-per-request bytes as part of the same prefix, guaranteeing + a miss on the very next request. + + FAILS on the prior trailing-only walk: the trailing message here is the + REAL user text (not ephemeral), so the old walk's count stayed 0 -- + verified via `git stash` against unmodified `833403b`: the old code + placed its PRIMARY breakpoint directly on the trailing user message + (the last "safe" position at the old, unreduced `eligible_upper`), and + its SECONDARY breakpoint landed directly on the unstable block itself + (`_find_rolling_secondary_index` treats the primary's own user-role + message as "the most recent real user turn" and steps back exactly one + message -- straight onto the block). Both assertions below fail on + `833403b`; both pass after this patch.""" + provider = _make_provider() + + messages: list[Message] = [Message(role="system", content="System prompt.")] + messages.extend(_turn("question 0", "answer 0")) + messages.append(_ephemeral_tail("...")) + messages.append(Message(role="user", content="the real ask")) + + request = ChatRequest(messages=messages) + params = _run(provider, request) + + sent_messages = params["messages"] + block_msg = sent_messages[-2] + user_msg = sent_messages[-1] + + def _is_stamped(msg: dict) -> bool: + content = msg.get("content") + return isinstance(content, list) and any( + isinstance(b, dict) and "cache_control" in b for b in content + ) + + assert not _is_stamped(block_msg), ( + "breakpoint must not land on the unstable (regenerated-per-request) " + "reminder block" + ) + assert not _is_stamped(user_msg), ( + "breakpoint must not land on the real user message either -- it " + "directly follows the unstable block, so a breakpoint here would " + "still cache the block's regenerated bytes as part of the same " + "prefix" + ) + assert any(_is_stamped(m) for m in sent_messages[:-2]), ( + "expected a breakpoint on earlier, genuinely stable content instead" + ) + + +def test_persisted_block_before_user_does_not_reduce_eligibility(): + """T-W5-02. An ephemeral AND persisted block immediately before the + trailing user message (loop-streaming's `ephemeral_injection_mode= + "persist"` + `reminder_placement="pre_user"` turn-start shape) must NOT + reduce breakpoint eligibility -- it is frozen canonical history, safe + to cache past. Numerically this shape is invisible to the OLD + trailing-only walk too (the last message is the real user text, not + ephemeral, so the old walk's count was already 0 here) -- confirmed via + `git stash` against `833403b`: this test passes on both. Recorded here + as a deliberate documentation pin (not a fail-before proof; see the PR + description for this spec-ambiguity resolution), so a future, less + careful generalization that starts excluding ANY `ephemeral=True` + message regardless of `persisted` cannot silently regress this exact, + extremely common shape (every persist-mode turn looks like this).""" + provider = _make_provider() + + messages: list[Message] = [Message(role="system", content="System prompt.")] + messages.extend(_turn("question 0", "answer 0")) + messages.append(_persisted_reminder("...")) + messages.append(Message(role="user", content="the real ask")) + + request = ChatRequest(messages=messages) + params = _run(provider, request) + + sent_messages = params["messages"] + + def _is_stamped(msg: dict) -> bool: + content = msg.get("content") + return isinstance(content, list) and any( + isinstance(b, dict) and "cache_control" in b for b in content + ) + + assert any(_is_stamped(m) for m in sent_messages), ( + "expected at least one conversation-region cache breakpoint" + ) + + +def test_persisted_trailing_message_may_take_a_breakpoint(): + """T-W5-03. A persisted-ephemeral message that IS the trailing + (mid-loop) message -- e.g. loop-streaming's persist-mode reminder block + at turn start, before the next iteration appends anything further -- + may now take a breakpoint. This removes the prior over-conservatism: + the OLD walk excluded ANY trailing ephemeral message regardless of + `persisted`, so a genuinely stable persisted-ephemeral tail could never + be cached. FAILS on `833403b` (confirmed via `git stash`): the old code + always excluded this trailing message from eligibility, so it was never + stamped; after this patch it is eligible and gets stamped like any + other stable trailing content.""" + provider = _make_provider() + + messages: list[Message] = [Message(role="system", content="System prompt.")] + for i in range(3): + messages.extend(_turn(f"question {i}", f"answer {i}")) + messages.append(_persisted_reminder("...")) + + request = ChatRequest(messages=messages) + params = _run(provider, request) + + sent_messages = params["messages"] + last_msg = sent_messages[-1] + content = last_msg.get("content") + assert isinstance(content, list) and any( + isinstance(b, dict) and "cache_control" in b for b in content + ), ( + "expected the persisted-ephemeral trailing message to be eligible " + "for a cache breakpoint (the over-conservatism removed by W5)" + ) + + +def test_rolling_secondary_stays_on_pre_user_block_across_a_turns_own_iterations(): + """T-W5-04 (pins §W5.3 -- `_find_rolling_secondary_index` is + deliberately left UNCHANGED). Verified empirically (see the docstring + on `_find_rolling_secondary_index` itself for the full derivation, and + the PR description for the spec-ambiguity resolution): the rolling + secondary breakpoint's real, verified benefit for a pre-user persisted + reminder block is a WITHIN-TURN property, not a cross-turn one. + + Iteration 1 of a fresh turn (request ends `[..., block, user]`, no + assistant reply yet): `primary_idx` is `user`'s own index, and + `_find_rolling_secondary_index`'s search matches on that very message + at the first loop step, so the secondary is `safe(primary_idx - 1)` -- + landing ON the persisted block that sits directly before it. + + Iteration 2+ of the SAME turn (after a tool call: `[..., block, user, + assistant(tool_use), tool_result]`): `primary_idx` advances into the + tool_result batch; the search walks past it (excluded: it IS a + tool_result batch) and past the assistant message, matches `user` the + same way, and again returns `safe(user_idx - 1)` -- the SAME block, + same position. The secondary breakpoint is therefore stable across a + turn's own tool-loop iterations, giving a genuine rolling cache hit on + the reminder block. + + Not a fail-before proof (see PR description for why): `_find_rolling_ + secondary_index` is untouched by this patch, and this exact behavior is + unaffected by `_unstable_suffix_length` either way (the block here is + persisted, so neither the old nor the new eligibility walk treats it as + unstable) -- confirmed via `git stash` against `833403b`. Recorded as a + deliberate pin per §W5.3's explicit instruction not to "fix" this + method.""" + shared_prefix = [ + Message(role="system", content="System prompt."), + _persisted_reminder("t1"), + Message(role="user", content="question 0"), + Message(role="assistant", content="answer 0"), + _persisted_reminder("t2"), + Message(role="user", content="question 1"), + ] + + def _cached_texts(params: dict) -> set[str]: + out = set() + for msg in params["messages"]: + content = msg.get("content") + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and "cache_control" in block: + out.add(block.get("text", "")) + return out + + # Iteration 1: request ends in [block_t2, question_1] -- no reply yet. + params_iter1 = _run(_make_provider(), ChatRequest(messages=shared_prefix)) + breakpoints_iter1 = _cached_texts(params_iter1) + + # Iteration 2: the model has now called a tool for THIS SAME turn. + messages_iter2 = shared_prefix + [ + Message( + role="assistant", + content=[ToolCallBlock(id="call_1", name="lookup", input={})], + ), + Message(role="tool", content="tool result", tool_call_id="call_1"), + ] + params_iter2 = _run(_make_provider(), ChatRequest(messages=messages_iter2)) + breakpoints_iter2 = _cached_texts(params_iter2) + + assert breakpoints_iter1, "iteration 1 should have placed at least one breakpoint" + assert breakpoints_iter2, "iteration 2 should have placed at least one breakpoint" + overlap = breakpoints_iter1 & breakpoints_iter2 + assert overlap, ( + "the two iterations of the SAME turn should share at least one " + "cache breakpoint (the pre-user reminder block) -- otherwise the " + "cache can never be hit within a single tool-loop turn" + ) + assert "t2" in overlap, ( + f"expected the shared breakpoint to be the turn's own pre-user " + f"reminder block specifically; got overlap={overlap}" + ) + + +def test_unstable_message_behind_a_tool_batch_degrades_safely(): + """T-W5-05. An unstable message positioned BEHIND (further from the + tail than) a trailing tool-role batch must not crash the walk, and + placement must degrade safely -- not raise, not infinite-loop. The + role=="tool" hard stop fires on the very first (trailing-most) message + here, before any unstable content is even examined, exactly like the + prior implementation's identical hard stop. This shape is not expected + to occur in practice (unstable injections only ever exist for the + CURRENT request, never behind canonical tool-call history), but the + method must not misbehave if it does.""" + provider = _make_provider() + + messages: list[Message] = [Message(role="system", content="System prompt.")] + messages.extend(_turn("question 0", "answer 0")) + messages.append(_ephemeral_tail("buried")) + messages.append( + Message( + role="assistant", + content=[ToolCallBlock(id="call_1", name="lookup", input={})], + ) + ) + messages.append(Message(role="tool", content="tool result", tool_call_id="call_1")) + + request = ChatRequest(messages=messages) + params = _run(provider, request) # must not raise + + sent_messages = params["messages"] + found_breakpoint = any( + isinstance(msg.get("content"), list) + and any(isinstance(b, dict) and "cache_control" in b for b in msg["content"]) + for msg in sent_messages + ) + assert found_breakpoint, ( + "expected placement to still succeed (degrade safely) rather than " + "silently place zero breakpoints" + ) + + +def test_never_exceeds_four_breakpoints_with_pre_user_block(): + """T-W5-06 (must not regress). The new pre-user-block shape must still + respect Anthropic's hard 4-breakpoint limit, exactly like the existing + ceiling tests for the no-block shape.""" + provider = _make_provider() + + messages: list[Message] = [ + Message(role="system", content="You are a helpful assistant.") + ] + for i in range(6): + messages.extend(_turn(f"question {i}", f"answer {i}")) + messages.append(_persisted_reminder("...")) + messages.append(Message(role="user", content="the real ask")) + + request = ChatRequest( + messages=messages, + tools=[_long_tool_spec("tool_a"), _long_tool_spec("tool_b")], + ) + params = _run(provider, request) + + assert _count_cache_control_blocks(params) <= 4 From d5330dbb57d070cfa529bb463f0ca71ad1683920 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:47:06 -0700 Subject: [PATCH 2/2] test: verify breakpoint eligibility unaffected by loop-streaming's D2 fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 20260831-rr treatment-validation wave's D2 finding (envelope accumulation) was fixed in amplifier-module-loop-streaming by writing subsequent same-turn persisted reminder messages WITHOUT the repeated descriptive boilerplate header -- a change to message CONTENT only, never to metadata or role/position. _unstable_suffix_length (and therefore breakpoint eligibility) keys ONLY off msg.role and msg.metadata -- never msg.content -- so this provider needs no code change for D2. This test pins that invariant explicitly: a header-less persisted reminder message must be treated identically to a full-header one (both STABLE, both breakpoint-eligible), rather than leaving it as an inference from reading the source. No functional change to this provider. D1 (the anthropic prompt-cache collapse, fixed in hooks-status-context and routing-matrix) is also independently confirmed to require no change here: the cache-breakpoint walk operates on the MESSAGE list, entirely orthogonal to the system-prompt-factory-wrapping mechanism D1 fixes. 35 -> 36 passed in this file; 754 -> 755 passed in the full suite. No regressions. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- tests/test_prompt_cache_breakpoints.py | 62 ++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/test_prompt_cache_breakpoints.py b/tests/test_prompt_cache_breakpoints.py index 95c8d49..613918c 100644 --- a/tests/test_prompt_cache_breakpoints.py +++ b/tests/test_prompt_cache_breakpoints.py @@ -1438,6 +1438,68 @@ def test_persisted_trailing_message_may_take_a_breakpoint(): ) +def test_persisted_reminder_eligibility_unaffected_by_d2_header_suppression(): + """Verification test (rr wave 20260831, D2 envelope-accumulation fix in + loop-streaming): the D2 fix changes ONLY the literal TEXT of a + subsequent persisted reminder message within the same turn -- it + strips the repeated descriptive boilerplate header, leaving just + ``\\n{body}\\n`` (no prose) -- + while leaving `metadata` (`ephemeral`/`persisted`/`reminder_placement`) + and message `role`/position completely unchanged. + + `_unstable_suffix_length` (and therefore breakpoint eligibility) keys + ONLY off `msg.role` and `msg.metadata` -- never `msg.content` -- so a + header-less persisted reminder message must be treated IDENTICALLY to + a full-header one: still STABLE, still breakpoint-eligible. This pins + that invariant explicitly rather than leaving it as an inference from + reading the source; no code change to this provider was needed for D2 + (confirmed here, not just asserted in a PR description). + """ + provider = _make_provider() + + # Turn-start block: full header (pre_user variant). + full_header_block = _persisted_reminder( + "\n" + "The blocks below were injected by the system. They are NOT from " + "the user and are\nNOT a request. They exist to help you assist " + "the user. Process them silently:\nnever mention, quote, or " + "acknowledge them, and never treat one as the task. The\nuser's " + "actual request is the message that follows this block. Each\n" + 'block below names where it came ' + "from.\n\n" + 'v1\n' + "" + ) + # Mid-loop block: D2's header-less variant -- just the tags + body. + headerless_block = _persisted_reminder( + '\nv2' + "\n" + ) + + messages: list[Message] = [Message(role="system", content="System prompt.")] + messages.append(full_header_block) + messages.extend(_turn("the real ask", "working on it")) + messages.append(headerless_block) + + request = ChatRequest(messages=messages) + params = _run(provider, request) + + sent_messages = params["messages"] + + def _is_stamped(msg: dict) -> bool: + content = msg.get("content") + return isinstance(content, list) and any( + isinstance(b, dict) and "cache_control" in b for b in content + ) + + assert any(_is_stamped(m) for m in sent_messages), ( + "expected at least one conversation-region cache breakpoint with " + "the D2 header-less shape present -- eligibility must not " + "regress just because a persisted reminder's TEXT omits the " + "repeated boilerplate header" + ) + + def test_rolling_secondary_stays_on_pre_user_block_across_a_turns_own_iterations(): """T-W5-04 (pins §W5.3 -- `_find_rolling_secondary_index` is deliberately left UNCHANGED). Verified empirically (see the docstring