feat(compaction): model-relative thresholds, floor-seeking cut, hysteresis (PR-1) - #1125
Conversation
…resis (PR-1) Compaction fired at a fixed 100k tokens regardless of the model's window, chose its cut by turn count, re-cut on every over-threshold turn, and compared a slice-relative index against the persisted absolute checkpoint. Underneath it, Strands' default 40-message SlidingWindowConversationManager (AgentFactory passed none) slid the front of agent.messages every turn past 40 messages: a prefix re-write per turn and the D3 coordinate mismatch. Spec: docs/specs/compaction-model-relative-thresholds.md (rides with this PR). - CompactionPolicy.resolve(): ceiling = min(0.5 x window, 100k), floor = 0.25 x ceiling, hard ceiling = min(0.7 x window, 1.5 x ceiling), from the catalog's maxInputTokens (already looked up per turn for the badge). The window scales the ceiling DOWN, never up: the 2026-09-15 replay of 20 heavy Sonnet 5 sessions priced 200k/50k 43% above 100k/25k on the input side, because cold re-writes after a >5 min pause scale with the context at the pause. Unknown window -> the fixed threshold; kill switch AGENTCORE_MEMORY_COMPACTION_MODEL_RELATIVE_ENABLED=false -> legacy exactly. - choose_checkpoint(): oldest tool-pair-safe cut whose retained estimate is at or under the floor (min protected_turns kept); per-message estimates calibrated to the context-breakdown `messages` partition. - Hysteresis: CompactionState.armed. A cut disarms; a turn under the ceiling re-arms; only the hard ceiling forces a cut while disarmed (logged compaction_forced = the previous cut did not take). The spiral's 56 consecutive cuts become 1 cut + 55 no-ops. - One coordinate system: _live_offset (absolute index of agent.messages[0], set by the restore slice); persisted checkpoint = offset + relative cut. - AgentFactory.build_conversation_manager(): explicit SlidingWindowConversationManager(window_size=2000, should_truncate_results=True) (AGENTCORE_CONVERSATION_WINDOW_MESSAGES; 40 restores the SDK default). Kept rather than Null because its reduce_context is the only ContextWindowOverflow recovery in the stack, independent of window size. Consequence: conversations between 40 messages and the ceiling now go to the model whole (0.1x reads instead of 1.25x re-writes). - `compaction` SSE payload + CompactionResult carry the policy fields (additive; SPA validator ignores extras, TS interface gains optionals); the persisted compaction map records `armed` and a `policy` snapshot. No change to WHEN history bytes change: the slice still applies at restore. Bounding the summary is spiral-spec PR-2 (next); in-place apply on warm agents with paid-when-free scheduling is PR-3. Tests: policy table, kill switch, estimator, floor-seeking cut, disarm / forced / re-arm, spiral shape cuts exactly once, live-offset coordinates, conversation window default + override; existing byte-stability suite unchanged. Backend suite: 8622 passed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Threshold replay on 20 audited prod sessions (2026-09-15)Replay method. Per session, tools+system = min non-zero Sample: the top-5 September users' three most expensive conversations plus the fleet's seven largest September conversations. All Sonnet 5, all heavy. "Actual" already benefits from the 40-message Strands window bounding contexts, which PR-1 removes, so "no compaction" is the baseline for what PR-1 replaces.
200k/50k is worse than 100k/25k on 13 of 20 sessions and never better, and lands at roughly today's spend. Mechanism: cold re-writes after a > 5 min pause are ~36% of write dollars in this sample and their size is the context at the pause; a 150k-context return costs ~$0.375, a 30–50k one $0.08–0.12, and under 200k/50k most of these sessions never reach the ceiling so they run at 100–190k throughout. Cohort note for PR-2 vs PR-3 ordering: all 16 over-100k sessions carry |
An armed cut whose input happened to exceed the hard ceiling was being
tagged forced=True. The flag is the spiral signal ("the previous cut did not
take"), so it must only fire for a cut that ran while disarmed because the
hard ceiling was reached. No behavior change to when cuts happen; the
persisted policy snapshot and the SSE field now say what the spec says.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mpaction ledger
The cost-diagnostics per-call ledger (record_compaction_event →
ContextLedgerHook → `compactionEvents` on the next C# cost row) is landing
on develop separately. Record the two PR-1 decisions the anatomy needs to
show — a cut that ran while disarmed ("forced") and a cut whose protected
tail alone exceeds the floor ("floor_unreachable") — through a small
helper that resolves the recorder by attribute, so it is a no-op on a build
without the ledger and activates when it merges. Int fields only, per the
ledger contract.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Resolves the one integration conflict with the compaction stack (#1125, #1128, #1129, #1131, #1132) in ``update_after_turn``. Both sides append to the same post-cut block: the stack added ``_emit_compaction_metrics`` and refactored to a local ``state`` alias (``state = self.compaction_state``, so the two save calls were already equivalent); this branch added the ledger's ``checkpoint`` event. Keep both, and route the event through the stack's ``_record_ledger_event`` seam instead of calling ``record_compaction_event`` directly, so the recorder stays resolved-by-attribute like every other cut decision. ``test_no_ledger_is_a_noop`` asserted the recorder was *absent* — true only while this branch was unmerged. It now simulates a ledger-less build via ``monkeypatch.delattr`` so it still guards the getattr seam. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Why
Compaction fires at a fixed 100k regardless of the model's window, picks its cut by turn count, re-cuts on every over-threshold turn (the #833 spiral: 56 consecutive cuts in one session), and compares a slice-relative index against the persisted absolute checkpoint (spiral-spec D3). Underneath all of it, Strands' default 40-message
SlidingWindowConversationManagerwas running —AgentFactorynever set one — and past 40 messages it slid the front ofagent.messagesevery turn: a full prefix re-write per turn, and the reason the checkpoint/anchor coordinates never agreed (ANCHOR_MISMATCHon 14 of 20 sessions in the 2026-09-15 prod cost audit).Spec rides with this PR:
docs/specs/compaction-model-relative-thresholds.md. It answers the original question — should the threshold vary with the context window? — with measured data: the window should scale the ceiling down, never up. A 200k ceiling on Sonnet 5 replayed at 43% more input-side cost than 100k on the 20 audited sessions, because 36% of cache-write dollars are cold re-writes after a >5 min pause and their size is the context at the pause.What
compaction_policy.py):ceiling = min(0.5 × window, 100k),floor = 0.25 × ceiling,hard ceiling = min(0.7 × window, 1.5 × ceiling), from the catalog'smaxInputTokens. Unknown window → the fixed threshold. Kill switchAGENTCORE_MEMORY_COMPACTION_MODEL_RELATIVE_ENABLED=false→ legacy behavior exactly (fixed threshold, turn-count cut, no hysteresis). Every ratio/cap is env-backed.protected_turnsare always kept; estimates calibrated to the context-breakdownmessagespartition.CompactionState.armed(persisted; legacy rows default armed). A cut disarms, a turn under the ceiling re-arms, only the hard ceiling forces a cut while disarmed (compaction_forcedlog = the previous cut did not take)._live_offset= absolute index ofagent.messages[0]; persistedcheckpoint = offset + relative cut.SlidingWindowConversationManager(window_size=2000, should_truncate_results=True)viaAgentFactory.build_conversation_manager()(AGENTCORE_CONVERSATION_WINDOW_MESSAGES;40restores the SDK default). Kept rather thanNullConversationManagerbecause itsreduce_contextis the stack's onlyContextWindowOverflowExceptionrecovery, and that path is independent of window size.compactionSSE payload and the persistedcompactionmap carry the policy fields (additive; the SPA validator ignores unknown keys, the TS interface gains optionals).What this does NOT change
When history bytes change. The slice still applies only at restore (
_apply_compaction). Bounding the summary (8k cap, persisted verbatim) is spiral-spec PR-2 and is next: all 16 over-100k sessions in the audit wereAGENT_CACHE_BYPASS, so the restore slice already runs for them and the unbounded 23–40k-token summary is what defeats it. In-place apply on warm agents with paid-when-free scheduling is PR-3.Behavior change to know about
Conversations between 40 messages and the ceiling now go to the model whole: more 0.1× cache reads per turn, far fewer 1.25× re-writes, and the model sees the conversation rather than its last 40 messages. Above the ceiling the policy bounds it.
Tests
test_compaction_policy.py(table incl. unknown-window and kill-switch rows, estimator, floor-seeking cut, disarm / forced / re-arm, spiral shape cuts exactly once, live-offset coordinates, window flows into policy),test_conversation_window.py.armed/policyround-trip; SSE emit-once stub accepts the new kwargs and asserts the extended payload.Follow-ups filed
%H:00inget_current_date_pacific()) re-writes every session's prefix once an hour (2.6% of September cache-write spend) — a measurement confounder for everything here; spawned as a separate task.OVER_COMPACTION_THRESHOLDdiagnosis should read the persistedcompaction.policymap instead of the fixed default (small, after this lands).🤖 Generated with Claude Code