fix(documents): PDF-aware documentTokens, and couple DOCUMENT_READ_ENABLED to the paths that promise the tool - #1147
Open
philmerrell wants to merge 2 commits into
Open
philmerrell wants to merge 2 commits into
philmerrell wants to merge 2 commits into
Conversation
`documentTokens` (offload spec §6.1) and the `DOCUMENT_OFFLOAD_MIN_TOKENS` floor (§4C) both read a document's weight from the compaction estimator's `bytes/4`. That is wrong for PDFs by an order of magnitude: Bedrock dual-encodes each page as an image *and* a text layer — the property §3 of the spec depends on — and the image channel has nothing to do with file size. Measured on dev (session 61de2256, 2026-09-16): a 27,578-byte, 60-page PDF produced a 109.1K-token first write against a 14.6K static prefix, so the document was ~94K tokens. `bytes/4` reported 6,894 — 13.7x low. At that error the spec's "document share of the prefix" reads 6% where the truth is ~87%, which inverts the ship/abandon call the evaluation spec §4.2 reads off these rows, and understates a measured 109.1K -> ~15K prefix drop. New `apis/shared/files/document_tokens.estimate_document_tokens`: pages x PDF_PAGE_TOKEN_ESTIMATE (default 1,500, env-tunable) for PDFs, floored by bytes/4 so a byte-heavy scan can only score higher than before; bytes/4 unchanged for formats Bedrock extracts to text. Page counting costs ~0.1ms even at 200 pages (measured), so no memoization is needed, and an unparseable PDF falls back to today's number. On the dev canary it lands at 90,000 against 94,485 measured — 5% low, deliberately, because this number gates an eviction. Wired into every consumer that reports or decides on that quantity, so the row and the offloader can never disagree: `document_context` (the `C#` row's documentTokens and documentSliceTokens), `document_offload` (Candidate.tokens, the min_tokens floor and the aged-slice ledger figure) and `document_rehydration` (document_stripped). The compaction estimator is deliberately NOT changed — it rescales to measured history and drives cut thresholds, so it needs its own decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e the tool The flag was read in exactly one place — `_document_tools_gate`, which decides whether `document_read` is injected. Neither the restore path nor the live offload consulted it, so pulling the one kill switch an operator would actually reach for (the tool is misbehaving) left two worse-than-before behaviours: 1. restore kept emitting `<document-digest upload_id=…>`, advertising a retrieval handle for a tool that would not be injected — the model is invited to make a call it cannot make; 2. the live offload kept evicting document bytes from the prefix with the only recovery path switched off. That is strictly worse than the pre-offload world, where live bytes never left at all. Spec §5 states the rule this violated: "a digest that points at a tool nobody has is no better than today's placeholder." Fix, split by whether dropping the bytes is optional: - **Restore has to drop them** either way (Bedrock rejects duplicate document names across a conversation), and a digest is still strictly more than the pre-PR-3 placeholder — so it still renders, but `render_digest` gains `include_handle` and the restore path passes `document_read_enabled()`. The outline and abstract survive; the false affordance does not. - **The live offload is optional**, so it stops entirely: `offload_enabled_for` now also requires `document_read_enabled()`. Both callers (the head-of-turn offload and restore-path slice ageing) already go through that single gate, so one check covers both and there is no second flag read to drift. With the flag on, every rendered byte is identical to before. 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.
Two independent defects found while validating the document-offload epic (PRs #1137–#1143) end to end — static review, 41 purpose-written probes, and a live clickthrough on dev. They share files, so they ship together rather than as a stack (an inner stack PR would get no CI).
1.
documentTokenscounted bytes, not PDF pagesdocumentTokens(offload spec §6.1) and theDOCUMENT_OFFLOAD_MIN_TOKENSfloor (§4C) both read a document's weight from the compaction estimator'sbytes/4. That is wrong for PDFs by an order of magnitude: Bedrock dual-encodes each page as an image and a text layer — the very property §3 relies on when it insists on native blocks over flattened text — and the image channel has nothing to do with file size. A 27 KB 60-page PDF and a 27 MB 60-page scan cost about the same;bytes/4sees only the bytes.Measured on dev (session
61de2256, 2026-09-16):bytes/4)This is not cosmetic. §6.1 is explicitly the instrument the evaluation spec's §4.2 ship/abandon rule reads off. At a 14× error those rows report documents as ~6% of the prefix when they were ~87% — which argues abandon ("the prefix is dominated by non-document content") for a change that in the same session dropped the prefix from 109.1K to ~15K.
Change. New
apis/shared/files/document_tokens.estimate_document_tokens: PDFs aremax(pages × PDF_PAGE_TOKEN_ESTIMATE, bytes/4)(default 1,500/page, env-tunable so the constant can be re-fit from measured rows without a deploy); other formats keepbytes/4, since Bedrock extracts those to text and there is no image channel to miss.max()rather than a replacement, so a byte-heavy scan can only score higher than before; an unparseable PDF falls back to today's number.1,500 is
compaction_policy.IMAGE_TOKEN_ESTIMATE's own rationale (image tokens ≈ (w·h)/750; a letter page at ~1000×1100 lands there). Against the measurement it is 1,500 vs 1,573 actual per page — ~5% low, deliberately, because this number gates an eviction and over-stating it would evict documents whose re-write has not earned it.No memoization: page counting measured ~0.1 ms even at 200 pages, while hashing 2 MB for a cache key costs 1.5 ms — the cache would be 15× more expensive than the thing it avoids.
Wired into every consumer of that quantity, because
Candidate.tokensfeeds the floor — the same weight used for a decision. Fixing the row but not the floor would leave a row saying "90K tokens" beside an offloader saying "3,750, below the floor, skip":document_context(documentTokens,documentSliceTokens),document_offload(Candidate.tokens, now a field rather than a derived property; the floor; the aged-slice ledger),document_rehydration(document_stripped).compaction_policy._block_tokensis deliberately NOT changed. Same blind spot, but it rescales to measured history and drives cut thresholds, so it needs its own decision.2.
DOCUMENT_READ_ENABLEDdid not reach the paths that promise the toolThe flag was read in exactly one place —
_document_tools_gate, which decides whetherdocument_readis injected. Neither the restore path nor the live offload consulted it, so pulling the one kill switch an operator would actually reach for left two worse-than-before behaviours:<document-digest upload_id=…>, advertising a retrieval handle for a tool that would not be injected — inviting a call the model cannot make;Spec §5 states the rule this violated: "a digest that points at a tool nobody has is no better than today's placeholder."
Fix, split by whether dropping the bytes is optional:
render_digestgainsinclude_handleand restore passesdocument_read_enabled(). Outline and abstract survive; the false affordance does not.offload_enabled_fornow also requiresdocument_read_enabled(). Both callers (head-of-turn offload, restore-path slice ageing) already route through that single gate, so one check covers both and there is no second flag read to drift.With the flag on, every rendered byte is identical to before.
Verification
tests/shared/test_document_tokens.py(24) andtests/agents/main_agent/session/test_document_read_flag_coupling.py(7), each stating its defect directly as a testagents/→apis.sharedis the allowed direction)document_read(page_range="44")→ slice aged on scheduleStill open from the same sweep (not in this PR)
docs/specs/document-offload-evaluation.md) is stillStatus: Draftwith no harness, whileDOCUMENT_OFFLOAD_ROLLOUT_PERCENTdefaults to 100 — which is that spec's own "Ship" state, so its B/C arms cannot existdocument_readpattern mode: a model-supplied(a+)+$measured 0.87 s at 24 chars, 14 s at 28, exponential; real PDF lines run 60–100INLINE_ATTACHMENTS_MAX_TOTAL_BYTES(7,500,000) base64-encodes to exactly 10,000,000 — the quota itself — leaving zero headroom for the JSON enveloperender_digestXML-escapes the abstract after slicing to the remaining room, so the 1,500-token cap is softprefixTokens.toolslooks off-by-one on cache-bypassed turns (reported 106,756 against a 12.5K static prefix)🤖 Generated with Claude Code