Skip to content

feat(compaction): park cuts post-turn, apply in place when the re-write is free (PR-3) - #1129

Merged
philmerrell merged 4 commits into
developfrom
feature/compaction-deferred-apply
Sep 16, 2026
Merged

philmerrell merged 4 commits into
developfrom
feature/compaction-deferred-apply

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Stacked on #1128 (PR-2), which is stacked on #1125 (PR-1). Merge in order; this PR's diff is the one commit on top.

Why

PR-1 decides what to cut and PR-2 bounds the summary, but a cut still landed at the next restore whether or not the prompt-cache prefix was warm, and never landed at all on a warm (cached) agent. Under Bedrock caching a cut costs one re-write of whatever survives, so the cheapest turn to pay it on is one that was going to re-write anyway. The 2026-09-15 replay of 20 heavy Sonnet 5 sessions applied exactly this rule (cut only when cold or at the hard ceiling) to get the $102.64 → $73.70 input-side result recorded in the spec. Thresholds spec §3.5.

What

  • Post-turn parks, never applies. update_after_turn stores the cut and its bounded summary as pendingCheckpoint / pendingSummary / pendingHardCeiling / pendingSince. checkpoint stays the applied value (what the restore slices at). A second over-ceiling turn while a cut is parked is a no-op; it never cuts deeper.
  • Head of turn decides. The stream coordinator calls apply_pending_compaction(agent, prefix_key="<model>|<agent>") before the first model call of every turn, on cached and freshly restored agents alike. It applies when, in order: cache_expired (more than cache_ttl_seconds since the previous turn — the entry is gone and the next call re-writes regardless), prefix_changed (the model|agent key differs from the persisted one — the cached prefix is already invalid), or hard_ceiling (the previous turn's input reached the hard ceiling the cut was computed under). Otherwise it waits.
  • Applied in place. messages[:] = [first_with_summary] + messages[k+1:] with k = pendingCheckpoint − _live_offset; then the state is promoted and persisted. The result is byte-identical to what _apply_compaction derives from stored history under the promoted state (pinned by a parity test), so a cold restore after a live apply reads the same prefix.
  • Aliasing carries the offset. _adopt_session_conversation copies _live_offset when it points a new agent at the live list.
  • Measured. Each application persists the reason, cacheGapSeconds and pendingSince on compaction.policy, logs rewrite_scheduled vs rewrite_forced, and emits CompactionApplied / CompactionAppliedForced / CompactionCacheGapSeconds; the cut record gains CompactionDeferred.
  • Kill switch AGENTCORE_MEMORY_COMPACTION_DEFERRED_APPLY_ENABLED=false applies immediately (PR-1/2 behavior). Legacy mode is always immediate.

Behavior to know about

Between the ceiling and the hard ceiling, a session on a warm cache keeps its current prefix until a pause longer than the cache TTL, a model or agent switch, or the hard ceiling. That is the trade the replay priced: a few turns of larger cache reads (0.1×) instead of an extra prefix re-write (1.25×) on a warm turn.

Not in this PR

context_window_limit on the Strands model config (needs the window at agent construction). Offload escalation when the protected tail alone exceeds the floor (PR-4). Selective 1h TTL experiment (PR-5).

Tests

Parking, no-deeper-cut, kill switch and legacy; warm/same-prefix waits; cache-expired, prefix-changed and hard-ceiling apply; first-ever key is not a change; live apply equals cold restore; pending beyond the live list is dropped; re-arm and cut again after apply; metrics carry the reason; state round-trip; offset sync on alias. The shared fixture pins existing suites to the immediate path explicitly. Full backend suite: 8659 passed, 3 skipped.

🤖 Generated with Claude Code

…rics (PR-2)

The compaction summary was an unbounded join of AgentCore LTM
ConversationSummary records (165k chars / ~40k tokens in the #833 incident):
a summary that is 40% of the threshold guarantees compaction can never get
back under it. Spiral-spec PR-2; thresholds spec §3.6 / §7.1.

- compaction_summary.bound_summary(): hold the persisted summary at
  COMPACTION_SUMMARY_TOKEN_BUDGET (8,000 tokens, chars/4 — the same estimate
  the admin SUMMARY_OVER_BUDGET diagnosis uses). Within budget → unchanged.
  Over budget → one Nova Micro converse call (side-channel, never touches
  agent.messages) with a prompt that keeps standing instructions, decisions,
  current state of the work, open items and exact identifiers, and drops
  narration and superseded drafts. Model failure, a ceiling-hit generation
  or an overshoot → newest-first truncation (keep the newest records that
  fit; if none fit, the tail of the newest). Runs once at checkpoint advance
  — the turn that already pays a prefix re-write — and the result is
  persisted verbatim, so the byte-stability contract is unchanged.
- Kill switch AGENTCORE_MEMORY_COMPACTION_SUMMARY_MODEL_ENABLED=false skips
  the model and truncates; AGENTCORE_MEMORY_COMPACTION_SUMMARY_MODEL_ID
  selects the model.
- Provenance on the persisted compaction.policy map: summarySource
  (ltm|fallback), summaryOutcome, summaryTokensBefore/After,
  summaryTokenBudget.
- One content-free EMF record per cut in AgentCoreStack/Compaction:
  CompactionCut, CompactionForced, CompactionInputTokens,
  CompactionRetainedTokens, CompactionSummaryTokens,
  CompactionSummaryOverBudget, with policySource/window/ceiling/floor/
  summaryOutcome as queryable properties. Silenced by
  PROMPT_CACHE_OBSERVABILITY_ENABLED=false with the rest of the layer.
- forced flag narrowed to "ran while disarmed" (same hunk as the PR-1 fix).

Tests: newest-first truncation, within-budget passthrough, model
compression, model failure / ceiling / overshoot fallbacks, kill switch,
env loading, oversized LTM join bounded and persisted through
update_after_turn, EMF record shape and kill switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@philmerrell
philmerrell force-pushed the feature/compaction-summary-budget branch from c7b96a4 to 667d43c Compare September 16, 2026 05:05
philmerrell and others added 2 commits September 15, 2026 23:05
…te is free (PR-3)

Compaction decided WHAT to cut (PR-1) and bounded the summary (PR-2), but a
cut still landed at the next restore regardless of whether the prompt-cache
prefix was warm, and never landed at all on a warm (cached) agent. Under
Bedrock caching a cut costs one re-write of what survives, so the cheapest
turn to pay it on is one that was going to re-write anyway.
Thresholds spec §3.5.

- update_after_turn PARKS the cut: pendingCheckpoint / pendingSummary /
  pendingHardCeiling / pendingSince on CompactionState. `checkpoint` stays
  the APPLIED value (what _apply_compaction slices at on restore). A second
  over-ceiling turn while a cut is parked is a no-op, never a deeper cut.
- apply_pending_compaction(agent, prefix_key) runs at the head of every
  turn (stream coordinator, right after the turn lease is stamped), on
  cached and freshly restored agents alike. It promotes the pending cut and
  slices agent.messages IN PLACE (slice assignment, never rebinding — the
  #741 alias) when, in order: cache_expired (more than cache_ttl_seconds
  since the previous turn), prefix_changed (model|agent key differs from
  the persisted lastPrefixKey), or hard_ceiling (previous input reached
  the hard ceiling the cut was computed under). Otherwise it waits.
- The in-place result is byte-identical to what _apply_compaction derives
  from stored history under the promoted state (pinned by
  test_live_apply_matches_a_cold_restore_of_the_same_state), so a cold
  restore after a live apply reads the same prefix.
- _adopt_session_conversation copies _live_offset when it points a new
  agent at the live list — the list's coordinate system travels with it.
- Each application persists the reason, cacheGapSeconds and pendingSince
  on compaction.policy, logs rewrite_scheduled vs rewrite_forced, and emits
  CompactionApplied / CompactionAppliedForced / CompactionCacheGapSeconds;
  the cut record gains CompactionDeferred.
- Kill switch AGENTCORE_MEMORY_COMPACTION_DEFERRED_APPLY_ENABLED=false
  applies immediately (PR-1/2 behavior); legacy mode is always immediate.
  The shared test fixture pins the immediate path explicitly; deferral has
  its own suite.

Not in this PR: context_window_limit on the Strands model config (needs
the window at agent construction).

Tests: parking, no-deeper-cut, kill switch, legacy; warm/same-prefix waits;
cache-expired / prefix-changed / hard-ceiling apply; first-ever key is not
a change; live apply == cold restore parity; pending beyond the live list
is dropped; re-arm and cut again after apply; metrics with reason; state
round-trip; offset sync on alias. Full backend suite: 8659 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The apply is the moment the bytes the model sees change, so it is the
event the cost anatomy should mark. Same attribute-resolved helper as the
PR-1 events; no-op until the ledger lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@philmerrell
philmerrell force-pushed the feature/compaction-deferred-apply branch from 6e7d3a3 to eab5119 Compare September 16, 2026 05:05
promoted=1 distinguishes the pending-cut promotion from the restore slice's
own applied event without a schema change; both are real byte changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@philmerrell
philmerrell changed the base branch from feature/compaction-summary-budget to develop September 16, 2026 14:46
@philmerrell
philmerrell merged commit dab0eee into develop Sep 16, 2026
philmerrell added a commit that referenced this pull request Sep 16, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant