feat(documents): offload unpinned documents to their digests when the re-write is free (offload PR-4) - #1140
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>
This was referenced Sep 16, 2026
Merged
philmerrell
changed the base branch from
feature/document-offload-pr3
to
develop
September 17, 2026 00:37
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 #1139 (PR-3) → #1138 (PR-2) → #1137 (PR-1). 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. This is the cost work and is independently revertible (kill switch, or drop this commit); PRs 1–3 stand on their own as the correctness fix.Why
A document enters the cacheable prefix on the turn that attaches it and is re-written in full on every cold turn for the life of the session:
cacheWriteis 48–50% of attachment-session spend, and the 5-minute TTL cliff is plainly visible in the cold re-write rate (3% under a minute, 66% past five). The digest (PR-2) is a small fraction of a large PDF and the model can pull any page back withdocument_read(PR-1), so once the document has had its turn the bytes can leave the prefix. The evaluation spec's recoverable envelope is the document share of every cold re-write, which PR-1'sdocumentTokensnow measures per row.Why head-of-turn, not
update_after_turnThe spec's §4C put the trigger post-turn. The compaction stack has since moved to "decide post-turn, apply at the head of the next turn only when the prefix re-write is free or unavoidable" (
apply_pending_compaction, thresholds spec §3.5) — and that predicate is exactly this spec's cache-aware rule (spec §8 item 21). So the offload runs in the same slot, right after the parked cut is applied, on the same facts:cache_expired(past the TTL since the last turn),prefix_changed(model/agent key differs), orover_ceiling(the last turn's input exceeded the compaction ceiling). Otherwise nothing moves. One decision point for every prefix mutation.What
session/document_offload.py— pure functions over the message list: pinning (§4D: the attach turn and the next,DOCUMENT_OFFLOAD_PIN_TURNS=2; the incoming or previous prompt names the document's stem; adocument_readresult for it sits in the recent turns), the size floor (DOCUMENT_OFFLOAD_MIN_TOKENS=5000, bytes/4), the rollout bucket (crc32(session_id) % 100 < DOCUMENT_OFFLOAD_ROLLOUT_PERCENT, default 100;DOCUMENT_OFFLOAD_ENABLED=falsekills), the in-place replacement, and slice ageing.TurnBasedSessionManager.apply_document_offloadowns the cache-gap decision (readingupdated_at,last_prefix_key,last_input_tokens, or an in-process last-turn stamp when compaction is off), mutates in place (never rebindsagent.messages, the @-mention forks conversation history: the mention turn and the plain turns run on two different cached agents #741 alias), recordsdocument_offloadon the ledger withdocuments,documentTokens(evicted),digestTokens,slices,sliceTokens,cacheGapSeconds, and emitsDocumentOffloaded/DocumentOffloadedTokens/DocumentSlicesAged/DocumentOffloadCacheGapSecondswith the reason as a property. Never raises.document_readpage slices are aged on both paths: older thanDOCUMENT_SLICE_MAX_TURNS=2turns they become a deterministic stub ([Retrieved pages placeholder: name=…]) on the live path under the same gate and on restore ininitialize, so a warm agent and a cold restore agree.apply_pending_compaction, passing the incoming prompt so a document the user just named stays pinned.CompactionEvent(py + TS) gains the offload fields; the anatomy's event title shows them.What this does not change
over_ceilingandprefix_changedoffloads carry a shortcacheGapSecondsby design and carry their reason.pin_messagemetadata (spec §8 item 23): our slice is notSummarizingConversationManager, and message metadata is persisted to AgentCore Memory.Tests
tests/agents/main_agent/session/test_document_offload.py(22): pin release after two turns, prompt-name pinning (incoming and previous prompt),document_read-result pinning, short stems ignored; candidate selection (unpinned + over the floor; digests ands3Locationnever candidates); rollout bucket stability, percent and kill switch; in-place replacement that equals the restore output byte-for-byte, unmatched stay inline, lookup failure leaves everything inline; slice ageing (old stubbed, recent kept, idempotent, too few turns); the session manager's three reasons and the wait, pinned documents never move even when free, compaction-off path, kill switch and bucket, slices under the same gate, never-raise; restore ageing throughinitialize.Local backend suite (stub
openpyxlonPYTHONPATH, verification only): 8901 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