52705e496fedf54fe6de8f4f514b352e95e2e28c
9 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
53559aaf86 |
fix(agent): protect batch-compaction markers from micro supersede/defrag
Phase 2 review findings on the salvage branch: C1 (critical): batch and micro summary markers share COMPRESSED_SUMMARY_METADATA_KEY, and compress() never reset micro state. After micro absorbed exchanges 1..k, a batch compaction summarizing 1..m (m>k) could fire; the next micro pass's supersede then dropped the batch marker (whose content the stale rolling summary does NOT contain) and archive_and_compact immediately made the loss durable. Defrag had the same hazard: it rewrote "the newest marker" even if that was a batch marker. Empirically confirmed with a probe (batch marker content destroyed in one pass). Fix, three parts: - Micro-created markers now carry MICRO_COMPACT_MARKER_KEY; supersede and defrag only ever touch micro-tagged markers. Rehydration in _resolve_compact_cursor tags the marker it absorbs (containment proof), which safely covers adopting a batch marker as the new rolling base after a reset. - compress() success path resets micro rolling summary/cursor state so a stale summary can never claim cumulativeness over a batch marker. - Regression tests for both directions plus the reset. W4: _splice_micro_compact_result no longer strips _db_persisted stamps from surviving messages. Micro archives in place under the SAME session id (unlike batch's child-session rotation, #57491), so surviving stamps are accurate; stripping them meant an archive_and_compact failure left every previously-persisted message unstamped and the next append-only flush re-inserted them all as duplicate active rows. W5: finalize_turn micro gate now checks agent._persist_disabled — persistence-isolated fork agents (background review) must not burn an aux call per review turn, and must never archive_and_compact the canonical session rows if their compressor ever gains a DB binding. W1: _serialize_one_exchange now delegates to _serialize_for_summary (was a ~70-line near-verbatim copy; one serializer, one place to fix). S4: _find_one_exchange boundary guard rejects only assistant/tool boundaries (the actual alternation hazard) instead of requiring user — a stray mid-list system/injected message can no longer wedge the cursor forever. 5 new regression tests; 38 micro/prune tests, 400 compression-suite tests, 61 finalize/persist tests pass; ruff clean. |
||
|
|
b8bfd68af1 |
fix(agent): make micro-compaction alternation-safe and defrag user-preserving
Two integration bugs found during review of #74522, both confirmed with empirical probes against the production message-repair path: 1. Alternation: the summary marker was role="user" and an exchange was a single assistant+tools group, so splicing between two user turns produced user -> marker(user) -> user. The pre-request repair_message_sequence pass (conversation_loop.py, runs before EVERY API call) then merged the marker into the neighbouring real user message: metadata gone, cursor unrecoverable on resume, and the summary text duplicated into the transcript on every later pass (the transcript GREW every turn). Fix: an exchange is now a full agent turn (assistant + tools + follow-up assistant iterations, bounded by user messages), the marker is assistant-role, and superseding an old marker deliberately merges the two adjacent real user turns (plain-text \n\n-join, identical to repair pass 2) so the returned transcript is alternation-valid by construction. Probe result: repairs 0 (was 2), marker survives, no summary leakage. 2. Defrag destroyed user messages: _defrag_rolling_summary serialized the whole remaining middle (user turns included) and spliced it away — 8 of 10 user prompts destroyed in one pass, contradicting the feature's "your messages are never compacted" invariant. Fix: defrag now re-summarizes only the rolling summary TEXT and rewrites the marker content in place; transcript shape, cursor, and user turns untouched. Probe result: 10 of 10 user prompts survive. Also: marker provenance is now COMPRESSED_SUMMARY_HAS_USER_TURN_KEY=False — micro markers absorb only assistant/tool content (#64650 invariant), and real user turns remain in the transcript for provenance detection. Adds 5 regression tests (repair-pass integration, alternation on multi-iteration tool turns, defrag user survival, defrag input scope, marker provenance); updates the two existing tests and the design doc to the corrected semantics. 28 tests pass. |
||
|
|
9ca4ee72ca |
feat(agent): make the micro-compaction cadence configurable
The on/off switch was the only knob. A pass fired after every completed turn, absorbed exactly one exchange, and there was no way to ask for less. Since a pass is also what breaks the prompt-cache prefix, "how often does it run" and "how often do I pay a cache break" are the same question, and it had no answer. Add `compression.micro_compact_every_n_turns` (default 1, clamped to >= 1). At 1 the behaviour is what it was; at 5 you get a fifth of the breaks and a fifth of the reclaim rate. The counter advances per invocation rather than per committed pass, so a turn that finds nothing to absorb still moves the cadence along and cannot wedge it, and a bogus 0 or negative degrades to "every turn" instead of silently disabling compaction. Also expose `micro_compact_defrag_threshold_tokens`, which has been a hardcoded attribute on the compressor with no path from config since it was added. This does not give micro-compaction the prune's reclaim-size gate -- a pass still commits whatever the single absorbed exchange saved. It makes the break frequency tunable, which reaches the same end by absorbing less rather than by waiting for a bigger win. The docs now say that plainly, including that a reclaim threshold is the obvious follow-up and does not exist yet. Tests cover the skip-until-due window, the cursor and prefix staying untouched on skipped turns, the clamp, and that the feature is off unless enabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
60781a0cc8 |
fix(agent): do not destroy compacted history when a session resumes
The rolling summary lives only in memory. A resumed session starts with an empty one while the marker carrying every previously absorbed exchange is still in the transcript. The first pass after a resume therefore built a marker from a single exchange and superseded the marker holding the entire history -- silently discarding everything micro-compaction had accumulated. This was introduced by the supersede fix. Before it, markers piled up wastefully, but nothing was ever lost. Two changes, so a single failure cannot lose data: Rehydrate. When the cursor is recovered by scanning the transcript -- the resume path -- also recover the rolling summary from that marker, so the next pass merges into the existing history instead of replacing it. Extraction uses rfind for the heading because SUMMARY_PREFIX references the heading text itself, so the first occurrence is inside the preamble. Gate superseding. Earlier markers are dropped only when this pass's summary is demonstrably cumulative, i.e. the rolling summary was non-empty going in. If rehydration ever fails, the pass keeps both markers: wasteful, but the history survives. Tests cover the resume path, the failed-rehydration fallback, and the round trip of a summary through a marker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d626528560 |
fix(agent): derive the micro-compaction cursor from the spliced list
The cursor was set to the pre-splice `exchange_end`. A splice collapses the absorbed span -- an assistant plus its tool results, often four or more messages -- into a single marker, and may also drop a superseded marker further back, so every index after it shifts. The stale cursor therefore overshot, landing inside a *later* exchange's tool group. The next pass's `_find_one_exchange` walked forward from there to the following assistant, so the exchange it had landed inside was never absorbed at all. On tool-bearing conversations micro-compaction was silently doing roughly half the work it should. Traced on a 3-tool-per-exchange transcript: the cursor sat at index 6 when the marker was at 2, and the message count stalled at 32 instead of continuing to 28. Derive the cursor from the marker's actual position in the spliced result instead, which is self-correcting regardless of how much the splice moved. Apply it on the defrag path too, which had the same staleness. Found by a randomized long-horizon harness (480 conversation shapes x 25 passes, varying tool-group sizes and summarizer failure modes) asserting structural and progress invariants after every pass. Existing tests missed it because their fixtures have no tool results, so the absorbed span is one message and nothing shifts. The regression test uses tool groups. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ac48add3a7 |
feat(agent): report context occupancy, not just tokens saved
Tokens saved is the wrong headline for this feature. Micro-compaction is not an efficiency optimisation — the same summarization work happens either way. What it buys is (a) that work amortized across turns instead of one stall, and (b) a window kept low enough that a session runs much further before needing a hard compaction at all. Neither shows up in "net tokens saved". A session can save nothing on paper and still be a clear win on both counts. So the telemetry now carries occupancy: tokens_after as a share of the compaction threshold, plus the threshold and resolved window it was computed from. That is the number that says whether a session has headroom left. The report leads with it, and cross-references the batch `compression_attempt` lines already in the log so it can show how often the long pause actually fired — ideally never. Occupancy is read from the cached threshold only. The public `threshold_tokens` property resolves lazily and can issue a synchronous /models probe (#32221); telemetry must never be the thing that blocks a turn, so an unresolved window reports null. In practice a pass has already resolved it via the tail calculation, so the field is populated. A test pins the no-forcing behaviour directly against the emitter. The report is pure ASCII: `scripts/check_subprocess_stdin.py` currently dies on a cp1252 console before printing its results, and a diagnostic tool that crashes on the platform it is diagnosing is worse than no tool. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cac9526d2a |
feat(agent): token telemetry for micro-compaction
The existing log line reports message counts, which is the least informative number available here: absorbing one tool-heavy exchange can drop hundreds of tokens while moving the count by one. There was no way to answer "is this actually helping?" from a real session. Emit one content-free JSON line per pass, in the same shape as the batch compaction telemetry: before/after tokens, the delta, the size of the absorbed exchange, the rolling summary size, duration, and running per-session totals so a whole run can be read off the last line. No transcript content rides along. Add scripts/micro_compaction_report.py to aggregate those lines into passes, outcome mix, net tokens saved, mean exchange size and durations, with an optional per-session breakdown. Measuring it immediately surfaced something worth documenting: the first pass in a session normally *costs* tokens. The summary marker carries a fixed ~400 tokens of scaffolding, paid on pass one against a single absorbed exchange. From pass two the marker is replaced rather than added, so the overhead is already paid and each exchange is close to pure saving. Break-even is typically the second or third pass. Tests cover the telemetry contract, the cumulative totals, and that first-pass/later-pass shape so nobody reads a single turn and concludes it made things worse. The estimator costs ~5 ms at 600 messages and ~20 ms at 1200, taken twice per pass, post-turn — and only once an exchange is actually in hand, so turns that no-op early pay nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
214b5d8612 |
docs(agent): state that user turns are never micro-compacted
`_find_one_exchange`'s docstring described an exchange as "(optional) user message + assistant message + its tool results", but the walk skips past user messages and starts at the assistant, so user turns are never absorbed into the rolling summary. The code is right and the docstring was wrong. Assistant output is largely an account of what was done and survives summarising with little loss. The user's messages are the intent everything else is derived from and cannot be reconstructed from the work that followed — paraphrasing "use the existing helper, don't add a new one" into a summary is how an agent ends up doing the opposite six turns later. They are also cheap: a prompt is normally a tiny fraction of what one tool result costs. Correct the docstring, document the property (and its cost — a floor on how small the middle can get, since user turns accumulate), and add a test so it stays deliberate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
186cad02f9 |
feat(agent): per-turn micro-compaction to amortize context compression
Batch compaction pauses a session for one large summarization once the
window fills. Micro-compaction spreads that cost out: after each completed
turn, `finalize_turn` folds the single oldest un-absorbed exchange
(assistant message plus its tool results) into a rolling summary, so the
work happens in small increments during post-turn idle time instead of one
long stall.
Mechanics:
- a cursor tracks the first message not yet absorbed, recovered from the
transcript's last summary marker when in-memory state is unavailable;
- protected head and tail windows are never touched, so the system prompt
and recent turns stay verbatim;
- the absorbed span is replaced by a marker carrying the usual
`_compressed_summary` metadata, so resume, handoff and `/compress`
treat it exactly like a batch summary;
- `archive_and_compact` keeps the session DB in step, otherwise the
append-only flush would leave the original rows active and a resume
would double-load both summary and originals;
- when the rolling summary itself passes a token threshold it is
defragged: re-summarized in one shot and the cursor jumps to the tail;
- an exchange the summarizer can't handle is retried a bounded number of
times, then skipped, so one poison exchange can't stall every turn.
Keep only the newest summary marker. The rolling summary is cumulative, so
each marker already contains everything the previous ones held; leaving them
stacked near-duplicate copies of the same text, each with its own heading and
end-marker scaffolding, and the transcript grew on every turn instead of
shrinking. Measured over six turns on a 12-exchange conversation with tool
output: 4104 -> 4797 tokens before, 4104 -> 2572 after.
Off switch: `compression.micro_compact: false` (default on).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|