Repository navigation
Incremental provider normalization across sends (journal-backed history send cost grows with row count) #3849
Description
Problem
After #854, conversation history lives in a disk journal and every send streams rows from it. A warm send under the context limit still costs more than it did with in-memory history. The per-send cost grows with the number of rows, because provider normalization rebuilds its snapshot over every surviving row each time.
Measured on the #854 branch (commit aebb531), single warm send, local HTTP provider, mixed text/tool/media history:
| Rows | #854 branch | main (in-memory) |
|---|---|---|
| 1,000 | 349 ms | ~104 ms |
| 8,192 | 2,955 ms | ~1,275 ms |
At 8,192 rows the time splits roughly as: normalization snapshot open 0.77 s, projectPromptEnvelope 0.8 s, estimatePrompt 0.4 s, the rest in enforcement and body production. Journal folds are not the cost; the durable fold checkpoint restores in 1 to 3 ms.
Two earlier hot spots were already fixed on the branch: the o200k estimator (5.9 s to 0.25 s at 167k tokens) and a quadratic tool-pairing rescan in the Responses source input (27.9 s to 3.7 s at 8,192 rows).
Proposal
Checkpoint the provider normalization result across sends, the same way DurableFoldCheckpoint checkpoints the fold. When the surviving prefix is unchanged, normalize only the appended rows.
Normalization lives in packages/core/src/services/history/provider-curated-stream.ts (prepareProviderContentSnapshot) and runs four stages over a ProviderNormalizationDisk. Two of them make a naive prefix checkpoint wrong:
completeResponsessynthesizes orphan tool responses using arespondedset filled over the whole stream, including later rows and the pending tail.indexResponseslets a later duplicate response for the same call id replace an earlier response.
So the checkpoint can only cover a "closed" cut c, where every tool call in [0, c) has its response inside [0, c). It must be invalidated when appended rows carry a duplicate response for a call in the prefix.
Suggested key: pinned journal identity, projection identity, resume boundary, c, and a SHA-256 over the first c 64-byte row-directory entries. Directory entries determine durable row content (source, offset, bytes, chronology overlay, invalidation flags), so rewind, compression, replacement, density, purge and chronology changes all change the digest. The cut must stop before the first pending source row.
Open question: whether streamCuratedProviderHistory is a per-row stateless filter, so that a curated index maps to a fold index.
Acceptance
- Request bodies stay byte-identical. A differential test over generated sessions with rewinds, compression, density, inserts, purges, media and tool pairs compares incremental and full normalization at every send.
- A counting seam shows rows normalized per send is bounded by the appended rows plus a constant when the prefix is unchanged, and returns to a full rebuild after an invalidating mutation.
- A corrupted or mismatched checkpoint falls back to a full rebuild.
- No context-sized state is kept in memory between turns. The checkpoint is disk-backed and released on store dispose.
- Warm send at 8,192 rows under about 1.5 s; at 1,000 rows under about 200 ms.
Follow-up from #854.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTodo
Activity
github-actions commentedon Oct 10, 2026
LLxprt Issue Planner — infrastructure failure
The autonomous planner failed with exit code 1. Please inspect the workflow logs (Run planner agent section) and re-run once resolved.
acoliver commentedon Oct 14, 2026
Status after the PR #3852 perf work: per-send cost is down a lot, but a warm send on the journal-backed path is still slower than main's in-memory path, so this issue stays open.
Same bench as before (real ChatSession and runtime wiring, local HTTP provider, mixed text, tool and media rows, warm send under the limit, median of 5). Branch and main ran back to back per cell. The machine was heavily loaded (load average 40 to 69), so CPU time is the fairer comparison:
Before these changes the wall-clock ratio was 2.3x to 5.6x (for example 4,560 ms vs 811 ms for chat at 4,000 rows).
What changed (all exact, request bytes and token counts unchanged):
What remains is the work this issue describes: every send still normalizes every surviving row. At 1,000 rows that fixed cost is most of the gap (about 150 to 250 ms). Checkpointing normalization across sends, as proposed above, is the next step.