Skip to content

feat(compaction): record the truncation anchor's cache gap on the ledger (offload PR-5) - #1141

Merged
philmerrell merged 5 commits into
developfrom
feature/document-offload-pr5
Sep 17, 2026
Merged

philmerrell merged 5 commits into
developfrom
feature/document-offload-pr5

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Stacked on #1140 (PR-4) → #1139#1138#1137. 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. Small and independent of PR-4's behavior; it could equally ride on develop directly if the stack is re-cut.

Why

The spec's PR-5 row said: fix the cache-live guard on the existing truncation deferral, target "72% of truncation events while the cache is live → ~0". That number comes from the July 2026 audit (34 of 47 truncation events fired inside the 5-minute TTL), against code where truncation ran at every initialize() and the extra_tools bypass rebuilt the agent every turn.

That defect is already closed, by the compaction stack that merged before this spec's PRs began: the truncation anchor is a pure function of persisted state that moves only with a cut (applied paid-when-free, thresholds spec §3.5) or in _maybe_advance_truncation_anchor, which checks _cache_window_expired first — the same predicate PR-4 reuses — and test_warm_cache_never_advances_anchor pins it. Re-implementing the guard would have been a second copy of a rule that already holds.

What was missing is the proof from rows. The advance was a log line, so the 72% claim could only be re-checked by correlating timestamps by hand — the same gap the compaction spec's §7.2 lists. This PR makes it a standing measurement.

What

  • truncation_anchor ledger event from _maybe_advance_truncation_anchor: anchorFrom, anchorTo, cacheGapSeconds. The gap is measured before the advance saves state, because the save re-stamps updated_at and would otherwise read as zero. Kind registered in COMPACTION_EVENT_KINDS.
  • EMF TruncationAnchorAdvanced / TruncationAnchorCacheGapSeconds in AgentCoreStack/Compaction.
  • cacheGapSeconds on the restore-time applied event, measured at the top of _apply_compaction before anything stamps state, next to the existing truncatedToolResults. A restore-time slice or truncation inside the TTL is a rebuild while the cache was live — a rebuild's cost, which the extra_tools bypass spec owns, and now countable per row.
  • Spec: PR-5 row rewritten to say what was found; §8 decision 27.

What this does not change

  • No behavior: the anchor moves exactly when it did before; nothing about which messages are truncated, sliced or restored. Additive numeric fields on events that already existed, plus one new event kind that fires only on a cold advance.
  • Nothing document-specific; this is the compaction ledger.

How to read it

truncation_anchor events with cacheGapSeconds under the TTL: zero. Any one is a regression of the guard. Restore applied events with truncatedToolResults > 0 and a short gap are the rebuild-while-live cohort; their count against all restores is the number the bypass spec's fix should move.

Tests

Four new tests in test_compaction_stability.py: a cold restore records one truncation_anchor event with the pre-save gap (≥ the age, not ~0); a warm restore records none; the restore applied event carries the gap; the kind is registered. The existing anchor-guard tests are untouched and still the ones that prove the guard.

Local backend suite (stub openpyxl on PYTHONPATH, verification only): 8905 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 5 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>
…ger (offload PR-5)

The spec's PR-5 asked for a cache-live guard on the truncation deferral
(July 2026: 34 of 47 truncation events fired inside the 5-minute TTL). That
defect was closed by the compaction stack before this spec's PRs began: the
anchor moves only with a cut (applied paid-when-free) or in
_maybe_advance_truncation_anchor behind _cache_window_expired, and the
stability suite pins that a warm cache never advances it. What was missing
was the proof from rows.

- truncation_anchor ledger event (anchorFrom, anchorTo, cacheGapSeconds
  measured BEFORE the save re-stamps updated_at) and
  TruncationAnchorAdvanced / TruncationAnchorCacheGapSeconds EMF.
- cacheGapSeconds on the restore-time `applied` event, measured at the top
  of _apply_compaction, so a restore-time slice or truncation inside the TTL
  is countable as a rebuild while the cache was live.
- No behavior change. Spec: PR-5 row rewritten; §8 decision 27.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@philmerrell
philmerrell changed the base branch from feature/document-offload-pr4 to develop September 17, 2026 00:37
@philmerrell
philmerrell merged commit 8409a2e into develop Sep 17, 2026
philmerrell added a commit that referenced this pull request Sep 17, 2026
Resolves the document-context offload stack (#1137-#1141, #1143) against
the feedback work. Every conflict was both sides adding adjacent members
to the same model, flag module, allowlist or interface, so the resolution
keeps both, except FEEDBACK_ROW_PROJECTION's turn-class comment: PR-1
widened that projection to the full document-context field set, which
already carries hasDocuments and documentDigests.

Co-Authored-By: Claude Opus 5 <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