Skip to content

feat(documents): offload unpinned documents to their digests when the re-write is free (offload PR-4) - #1140

Merged
philmerrell merged 4 commits into
developfrom
feature/document-offload-pr4
Sep 17, 2026
Merged

philmerrell merged 4 commits into
developfrom
feature/document-offload-pr4

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

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: cacheWrite is 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 with document_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's documentTokens now measures per row.

Why head-of-turn, not update_after_turn

The 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), or over_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; a document_read result 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=false kills), the in-place replacement, and slice ageing.
  • The replacement is PR-3's restore transformation applied early — same matcher, same persisted digest, same renderer — so an offloaded block is byte-identical to what the next cold restore would render for it. Tested. Unmatched blocks stay inline (the live path may keep bytes; restore may not).
  • TurnBasedSessionManager.apply_document_offload owns the cache-gap decision (reading updated_at, last_prefix_key, last_input_tokens, or an in-process last-turn stamp when compaction is off), mutates in place (never rebinds agent.messages, the @-mention forks conversation history: the mention turn and the plain turns run on two different cached agents #741 alias), records document_offload on the ledger with documents, documentTokens (evicted), digestTokens, slices, sliceTokens, cacheGapSeconds, and emits DocumentOffloaded / DocumentOffloadedTokens / DocumentSlicesAged / DocumentOffloadCacheGapSeconds with the reason as a property. Never raises.
  • document_read page slices are aged on both paths: older than DOCUMENT_SLICE_MAX_TURNS=2 turns they become a deterministic stub ([Retrieved pages placeholder: name=…]) on the live path under the same gate and on restore in initialize, so a warm agent and a cold restore agree.
  • The stream coordinator calls the new method right after 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.
  • Spec: PR-4 row marked built; §8 decisions 21–26.

What this does not change

  • The attach turn: the full document still goes inline, and it stays inline on the next turn whatever the cache state.
  • Nothing moves while the cache is live and the prefix is unchanged and the context is under the ceiling (tested) — the 72%-while-live violation the compaction PR-3 rule fixed cannot recur here. over_ceiling and prefix_changed offloads carry a short cacheGapSeconds by design and carry their reason.
  • Pinning does not use Strands' pin_message metadata (spec §8 item 23): our slice is not SummarizingConversationManager, and message metadata is persisted to AgentCore Memory.
  • PR-5 (the existing truncation deferral's cache-live guard), PR-6, PR-7 are untouched.

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 and s3Location never 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 through initialize.

Local backend suite (stub openpyxl on PYTHONPATH, verification only): 8901 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 4 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>
… 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>
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