feat(documents): restore rehydrates stripped documents as digests (offload PR-3) - #1139
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>
This was referenced Sep 16, 2026
Merged
philmerrell
changed the base branch from
feature/document-offload-pr2
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 #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. PRs 1–3 are the correctness fix and ship as a unit (spec §5).Why
On restore,
_strip_document_byteshad to drop every inline document's bytes (Bedrock rejects duplicate document names across a conversation) and replaced each with[Document placeholder: name=…, format=…, original_size=…]— zero content. Four of five attachment sessions rebuild their agent every turn, so a user's document was silently gone on turn 2: the 14% same-file re-upload rate, 87% of it byte-identical minutes later. PR-1 gave the model a way to read pages back; PR-2 built the digest at upload; this makes restore hand the model the digest and the handle instead of nothing.What
session/document_rehydration.py. Each inline document block in restored history is matched to the session's upload rows — on the sanitized filenamePromptBuildergave the block (FileSanitizer.sanitize_filenameturnsBBR Policy.pdfintoBBR Policy_pdf; the_2/_3duplicate suffix is allowed for), byte size as tiebreak, newest row first, each row claimed once — and replaced by the rendered<document-digest name upload_id format pages …>block with the abstract and outline inside. OneSessionIndexquery per restore that has a document block, none otherwise.extract_outlinerun on the block's own bytes — no S3 read, no model call on the synchronous restore path — and the result is persisted so the next restore renders identical bytes.DOCUMENT_REHYDRATE_ENABLED=falserestores the old path entirely._strip_document_bytesnow recordsdocument_rehydrated(documents, digest tokens) anddocument_stripped(the unmatched residue) separately, so PR-3's effect is a before/after on the counter PR-1 started writing.list_session_files_sync/update_file_digest_syncbodies (the async methods wrap them):initialize()runs under the Strands agent constructor with a loop already running and cannot await.What this does not change
document_rehydrated.documentTokens.update_after_turn— that is PR-4.Tests
tests/agents/main_agent/session/test_document_rehydration.py: matching preference order, duplicate suffix and claimed rows; digest replacement with the handle and no surviving bytes; lazy outline-only build persisted through the sync repository and served even when persistence fails; unmatched → placeholder; lookup failure → one attempt, all placeholders; no lookup without a document block;s3Locationblocks untouched; kill switch; the user/document-class filter; the two ledger events from the session manager; restore-output stability. The PR-1 strip-event tests now pin the no-rows fallback explicitly.Local backend suite (stub
openpyxlonPYTHONPATH, verification only): 8879 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