feat(compaction): record the truncation anchor's cache gap on the ledger (offload PR-5) - #1141
Merged
Merged
Conversation
…load PR-1) The recovery half of docs/specs/document-context-offload.md, with the analytics pulled forward from PR-7 so the cost work is measured from day one. - document_read (agents/builtin_tools/document_read_tool.py, service in apis/shared/files/document_read.py): PDF page ranges re-assembled into a native document block (max_pages 8, hard cap 20), regex over the text layer with page/paragraph numbers, bounded text for DOCX (stdlib extractor) and text-family files, a session listing and a PDF page index. Gated on the session having a readable upload, never on enabled_tools; the id stays out of INJECTED_TOOL_IDS; DOCUMENT_READ_ENABLED=false removes it. - Tool presence rides the agent cache key (document_tools) instead of vetoing the cache, so an attachment session that keeps a warm agent still does; resume recomputes the same gate. - document_read results are exempt from the tool-result offloader. - Per-call document context on C# rows (hasDocuments, documentCount, documentTokens, documentDigests, documentsAttached, documentSlices, documentSliceTokens, documentMime, documentReads), a document_stripped compaction-ledger event from _strip_document_bytes, session rollups (fullDocumentCalls, digestOnlyCalls, documentReadCalls, documentReadPages), content-policy projections, anatomy + profile fields, SPA anatomy rendering, and DocumentRead EMF in AgentCoreStack/Compaction. - Spec: PR sequence re-cut (analytics in PR-1, outcome signal as PR-7), §6.1 field design, §8 decision log. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…Metadata (offload PR-2) Stacked on PR-1. The digest a later turn will carry instead of the document's bytes (docs/specs/document-context-offload.md §4A), built once, off the model path, when a document upload completes. - apis/shared/files/document_digest.py: a deterministic outline (headings with page/paragraph/line anchors, table and figure mentions, counts, a text sample spread across the document) via pypdfium2 and the PR-1 DOCX extractor, plus a 3-5 sentence abstract from Nova Micro (DOCUMENT_DIGEST_MODEL_ID) that fails open. render_digest emits the <document-digest> block under a hard 1,500-token budget (sections dropped first, then the abstract; the handle always survives) and the estimate is stored as digest.tokens. - FileMetadata.digest + FileUploadRepository.update_file_digest; complete_upload schedules the build as a strong-referenced background task for document uploads only. DOCUMENT_DIGEST_ENABLED=false skips it. - Content policy: digest.abstract / digest.sections denylisted; status, format, count and tokens projected; the attachment profile reports digested count and digest tokens. DocumentDigestGenerated/Tokens/Ms EMF in AgentCoreStack/Compaction. - Nothing in the chat path reads the digest yet (PR-3). Spec: PR-2 row marked built; §8 decisions 12-15. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…fload PR-3) Stacked on PR-2. The correctness half of docs/specs/document-context-offload.md §4E: on restore, an inline document block becomes its <document-digest …> block (abstract, outline, upload_id handle for document_read) instead of the contentless placeholder that lost a returning user's document. - session/document_rehydration.py: match each block to the session's upload rows on the sanitized filename PromptBuilder gave it (dot -> underscore, _2/_3 duplicate suffix), byte size as tiebreak, newest first, each row claimed once; render the row's digest. Rows without a digest get an outline-only digest built from the bytes already in the restored message (no S3, no model call on the synchronous restore path) and persisted. Unmatched blocks keep the placeholder verbatim; every failure falls back to it. DOCUMENT_REHYDRATE_ENABLED=false restores the old path. - _strip_document_bytes delegates and records document_rehydrated and document_stripped separately on the ledger. - FileUploadRepository: list_session_files_sync / update_file_digest_sync bodies (initialize() runs under the agent constructor and cannot await). - Restore output is byte-stable across restores given the same rows (tested). - Spec: PR-3 row marked built; §8 decisions 16-20. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… re-write is free (offload PR-4) Stacked on PR-3. The cost half of docs/specs/document-context-offload.md (§4C / §4D): once a document is no longer the active subject, its inline bytes leave the cacheable prefix and its digest (PR-2) plus the document_read handle (PR-1) take their place. - session/document_offload.py: pinning (attach turn + next; the incoming or previous prompt names the document's stem; a recent document_read result), the 5,000-token size floor, the crc32 rollout bucket (DOCUMENT_OFFLOAD_ROLLOUT_PERCENT) and DOCUMENT_OFFLOAD_ENABLED kill switch, in-place replacement, and document_read slice ageing. - TurnBasedSessionManager.apply_document_offload runs head-of-turn right after apply_pending_compaction (the compaction stack's §3.5 predicate is this spec's cache-aware rule): only on cache_expired, prefix_changed or over_ceiling; otherwise nothing moves. The replacement is PR-3's restore transformation, so the live block equals a cold restore's. Records document_offload on the ledger with cacheGapSeconds and emits DocumentOffloaded/DocumentOffloadedTokens/DocumentSlicesAged EMF. - Slices older than DOCUMENT_SLICE_MAX_TURNS are stubbed on the live path and on restore, so a warm agent and a cold restore agree. - CompactionEvent (py + TS) gains digestTokens / slices / sliceTokens. - Spec: PR-4 row marked built; §8 decisions 21-26. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ger (offload PR-5) The spec's PR-5 asked for a cache-live guard on the truncation deferral (July 2026: 34 of 47 truncation events fired inside the 5-minute TTL). That defect was closed by the compaction stack before this spec's PRs began: the anchor moves only with a cut (applied paid-when-free) or in _maybe_advance_truncation_anchor behind _cache_window_expired, and the stability suite pins that a warm cache never advances it. What was missing was the proof from rows. - truncation_anchor ledger event (anchorFrom, anchorTo, cacheGapSeconds measured BEFORE the save re-stamps updated_at) and TruncationAnchorAdvanced / TruncationAnchorCacheGapSeconds EMF. - cacheGapSeconds on the restore-time `applied` event, measured at the top of _apply_compaction, so a restore-time slice or truncation inside the TTL is countable as a rebuild while the cache was live. - No behavior change. Spec: PR-5 row rewritten; §8 decision 27. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
philmerrell
changed the base branch from
feature/document-offload-pr4
to
develop
September 17, 2026 00:37
philmerrell
added a commit
that referenced
this pull request
Sep 17, 2026
Resolves the document-context offload stack (#1137-#1141, #1143) against the feedback work. Every conflict was both sides adding adjacent members to the same model, flag module, allowlist or interface, so the resolution keeps both, except FEEDBACK_ROW_PROJECTION's turn-class comment: PR-1 widened that projection to the full document-context field set, which already carries hasDocuments and documentDigests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #1140 (PR-4) → #1139 → #1138 → #1137. Merge in order; this PR's diff is the one commit on top. CI runs only for PRs targeting
develop/main, so no checks show here until the stack is re-targeted — the suite result below is the local run. Small and independent of PR-4's behavior; it could equally ride ondevelopdirectly if the stack is re-cut.Why
The spec's PR-5 row said: fix the cache-live guard on the existing truncation deferral, target "72% of truncation events while the cache is live → ~0". That number comes from the July 2026 audit (34 of 47 truncation events fired inside the 5-minute TTL), against code where truncation ran at every
initialize()and theextra_toolsbypass rebuilt the agent every turn.That defect is already closed, by the compaction stack that merged before this spec's PRs began: the truncation anchor is a pure function of persisted state that moves only with a cut (applied paid-when-free, thresholds spec §3.5) or in
_maybe_advance_truncation_anchor, which checks_cache_window_expiredfirst — the same predicate PR-4 reuses — andtest_warm_cache_never_advances_anchorpins it. Re-implementing the guard would have been a second copy of a rule that already holds.What was missing is the proof from rows. The advance was a log line, so the 72% claim could only be re-checked by correlating timestamps by hand — the same gap the compaction spec's §7.2 lists. This PR makes it a standing measurement.
What
truncation_anchorledger event from_maybe_advance_truncation_anchor:anchorFrom,anchorTo,cacheGapSeconds. The gap is measured before the advance saves state, because the save re-stampsupdated_atand would otherwise read as zero. Kind registered inCOMPACTION_EVENT_KINDS.TruncationAnchorAdvanced/TruncationAnchorCacheGapSecondsinAgentCoreStack/Compaction.cacheGapSecondson the restore-timeappliedevent, measured at the top of_apply_compactionbefore anything stamps state, next to the existingtruncatedToolResults. A restore-time slice or truncation inside the TTL is a rebuild while the cache was live — a rebuild's cost, which theextra_toolsbypass spec owns, and now countable per row.What this does not change
How to read it
truncation_anchorevents withcacheGapSecondsunder the TTL: zero. Any one is a regression of the guard. Restoreappliedevents withtruncatedToolResults > 0and a short gap are the rebuild-while-live cohort; their count against all restores is the number the bypass spec's fix should move.Tests
Four new tests in
test_compaction_stability.py: a cold restore records onetruncation_anchorevent with the pre-save gap (≥ the age, not ~0); a warm restore records none; the restoreappliedevent carries the gap; the kind is registered. The existing anchor-guard tests are untouched and still the ones that prove the guard.Local backend suite (stub
openpyxlonPYTHONPATH, verification only): 8905 passed, 3 skipped; the only 33 failures are the two spreadsheet-preview test files, which need the realopenpyxlthe stub cannot stand in for. CI's install settles those.🤖 Generated with Claude Code