fix: compaction notice degrades to a standing form between escalations (#513) - #33
Conversation
|
Cache impact: none. Verified directly against
if md.get("ephemeral") and not md.get("persisted"):
excluded = walkedPure metadata flags — zero dependence on message content. Both the full and standing notice carry One real, separate side effect worth calling out: this trailing message was already excluded from caching before this PR — that exclusion mechanism predates this change entirely (there's a documented historical bug in that same function about an earlier unstable-tail regression: "cache_read frozen... write:read ratio 5.96x" before it was fixed). So the notice was never cached in either version. What does change is the raw uncached token count sent on repeat turns: previously the full ~400–600 token report was resent uncached on every turn after the first compaction; now, standing turns send the much shorter standing text instead. That's a real cost reduction, but it's a reduction in what gets sent, not a change in cache hit rate. What to expect empirically: cache_read/cache_creation trend and hit pattern unchanged from before this PR. The visible difference is a smaller |
Data back on the premise: the 76%-outlier was a different defect, and removing it strengthens your caseWe measured this rather than argue it. Two findings, and the second one matters to you. 1. The loudest session in the evidence base is not an instance of your claimSession Its notices change every turn ( 2. With that artifact removed, your premise's signal gets stronger, not weakerBoth arms of the original analysis were dominated by one pathological session (sonnet 75.6% of dups, opus 82.3%):
Excluding both — the only principled cut — clears the pre-registered 2.0× line, and agrees with the independent leave-top-3-sessions-out variant (2.61×). Also worth knowing: the original 2.00× reading was never decidable. At the precision published (4dp), the ratio interval is [1.99796, 2.00041] — it straddles the threshold. What this does and does not sayIt supports your PREMISE. Independently confirmed on our own captures, on both providers: 3.71 notice-carrying requests per client-side compaction, 76.0% of 2,581 consecutive notice pairs byte-identical, longest identical run 14 requests with It does NOT yet validate your FIX. Notice and information loss are perfectly collinear in observational data — every notice-carrying turn is also a post-compaction turn where prior tool results were genuinely deleted, so a re-read may be correct behaviour rather than waste. Only an A/B can separate them. That A/B is now running: 3 arms — main / this PR's branch / a minimal informational-only variant (recency marker + a corrected "ephemeral" sentence, no prescriptive "don't re-run tools") — across sonnet-5, gpt-5.6-terra and opus-5, n=3/arm/provider. We'll post the numbers here either way, including if they don't support the change. Two constraints that shaped it, in case they're useful to you: our local corpus has only n=8 sonnet-5 and n=1 opus-5 notice-carrying requests, so the model contrast must be manufactured, not sampled; and 139 of 860 compaction events are |
Measured A/B on this PR — 18 runs, 2 providers, in DTUsYou disclosed you couldn't run this in a DTU, so here's the data, and it's owed to you Design. Three arms, same scenario (S5-CRAC, 12 turns), same rig, fresh containers,
18 runs, 216 turns, 4,709 tool calls, $62.24, 0 invalid runs, 0 server-side-compaction The confound this was funded to breakIn production captures the notice and the information loss are perfectly collinear: Where your PR wins, clearlyCompaction narration per real compaction — the model announcing and rationalising a
Consistent across both providers. Your identity-comparison-not- Where it doesn't — post-notice duplicate tool callsExact repeats of
Restricted to read-only tools ( Our pre-registered bar was a ≥40% cut. Bootstrapping the cut over the sonnet runs puts The interpretation we'd defend: the model keeps re-reading because the information Arm C — the informational-only variant did not rescue itWe built C specifically to test whether information alone (recency marker + a true The holistic gateOur rule requires no quality regression and ≤10% cost increase on every provider.
Stating the n honestly: the terra gap is driven mostly by one B run scoring 84 What we're not claiming
If you want to push this furtherThe cheapest thing that would change our answer is a scenario where the re-reads are Happy to re-run against a scenario you think discriminates better — the harness is Method, per-run raw JSON, intervals, permutation tests, and the arm diffs: |
Problem
Once a single context compaction occurred,
context-simpleappended anidentical "Context has been compacted..." notice to the tail of every
subsequent request for the rest of the session. The notice had no
timestamp or sequence marker, so a model reading it could not tell "this
just happened" from "this happened 40 turns ago." Empirically this drove
claude-sonnet-5 into a repeated, wasteful "I need to re-verify state" loop
in production sessions, while claude-opus-5 under equivalent compaction
load was largely unaffected.
Fix
SimpleContextManagernow distinguishes:metadata.notice_kind="full") — emitted the firsttime a given compaction is announced, unchanged from today's report, and
metadata.notice_kind="standing",source="context-compaction-standing"in the text) — a short,self-contained "nothing new happened, no action needed" reminder emitted
on every subsequent request until the next real escalation.
The two are distinguished by comparing
_last_compaction_statsbyidentity, not by
strategy_level.strategy_levelis a sticky,monotonic high-water mark that reaches its ceiling early in a long session
and then stays constant across many further real escalations (verified:
across 25 turns, escalations at calls 0/5/9/14/18/23 all reported
strategy_level=8whilemessages_removedkept climbing 22→65) — keyingon level would have silently suppressed the notice for genuinely new
compaction work for the rest of the session, trading a noisy bug for a
silent one.
_last_compaction_statsis assigned a fresh dict at exactlyone site and never mutated afterward, so identity comparison is exact and
free.
metadata["source"]is deliberately left as"context-compaction"forboth notice kinds — an existing test (
test_notice_returns_once_tool_results_arrive)and downstream consumers filter on that value, and changing it would have
broken that contract for no benefit. The model-facing fresh/standing
distinction lives in the
source=attribute inside the notice's own XMLtext, plus the new
metadata["notice_kind"]field for telemetry.Cache-safety is unaffected: the notice is never persisted into
self.messagesand is always the last element of the ephemeral view, sothe prefix shared between consecutive requests never contains a notice at
all regardless of which variant is shown. The existing
test_prefix_stability_regressionalready strips the trailing noticebefore comparing and documents that it is "expected to be
present/absent/reworded depending on whether a new escalation happened."
Testing
tests/test_sticky_compaction_and_tail_notice.py:first-notice-is-full, repeat-with-no-escalation-is-standing,
new-escalation-emits-full-again (including the same-level-re-escalation
case that would break a level-keyed implementation), and both variants
respect
compaction_notice_enabled/compaction_notice_min_level.ruff checkclean,pyrightclean ontouched files.
module was mirrored in from this branch and mounted by a real Amplifier
orchestrator, which drove a real session to a genuine level-8/8
compaction escalation and emitted the correctly-formatted full
notice from this exact code path — confirming the module mounts and
integrates correctly with real session/provider machinery, not just in
isolated unit tests.
independently observe the standing-notice branch firing live — the
target session ended on the same turn it escalated, and a second
validation attempt produced invalid evidence (it substituted an
unrelated session for the DTU's own) that was discarded rather than
counted. The standing branch sits behind the same gate, in the same
function, with no additional plumbing beyond what the escalation turn
already exercised live, and it's the one deterministically covered by
test_repeat_notice_with_no_new_escalation_is_standingandtest_new_escalation_emits_full_notice_againagainst the real shippedcode — but it has not additionally been watched happen in a live
multi-turn session the way the escalation turn was. Flagging that
precisely rather than implying full end-to-end coverage.
See
docs/lanes/513-standing-compaction-notice/DONE-NOTE.mdfor the fulldesign rationale and edge-case reasoning.