Skip to content

feat(documents): restore rehydrates stripped documents as digests (offload PR-3) - #1139

Merged
philmerrell merged 3 commits into
developfrom
feature/document-offload-pr3
Sep 17, 2026
Merged

philmerrell merged 3 commits into
developfrom
feature/document-offload-pr3

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

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_bytes had 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 filename PromptBuilder gave the block (FileSanitizer.sanitize_filename turns BBR Policy.pdf into BBR Policy_pdf; the _2/_3 duplicate 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. One SessionIndex query per restore that has a document block, none otherwise.
  • Lazy digests, outline-only, from the bytes already in the message. Rows without a digest (uploads before PR-2, agent-written files) get extract_outline run 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.
  • Never worse than before. A block with no matching row (a direct base64 attachment, a deleted file) keeps the pre-PR-3 placeholder verbatim; every failure on the path falls back to it for that block; the lookup is attempted once. DOCUMENT_REHYDRATE_ENABLED=false restores the old path entirely.
  • Ledger. _strip_document_bytes now records document_rehydrated (documents, digest tokens) and document_stripped (the unmatched residue) separately, so PR-3's effect is a before/after on the counter PR-1 started writing.
  • Repository. list_session_files_sync / update_file_digest_sync bodies (the async methods wrap them): initialize() runs under the Strands agent constructor with a loop already running and cannot await.
  • Spec: PR-3 row marked built; §8 decisions 16–20.

What this does not change

  • The attach turn is untouched: the full document still goes inline, and the block's bytes are the same, so no prefix change on attachment turns. (Putting the upload id on the block itself would be cleaner but changes those bytes; deferred.)
  • Restore output stays byte-stable across restores given the same upload rows (tested), so the cache contract on the restore path is unchanged. One known one-time change: an outline-only lazy digest later overwritten by the upload-path build with an abstract renders differently once, visible as a jump in document_rehydrated.documentTokens.
  • No offload trigger, no pinning, nothing in 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; s3Location blocks 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 openpyxl on PYTHONPATH, verification only): 8879 passed, 3 skipped; the only 33 failures are the two spreadsheet-preview test files, which need the real openpyxl the stub cannot stand in for. CI's install settles those.

🤖 Generated with Claude Code

philmerrell and others added 3 commits September 16, 2026 14:43
…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>
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