16 KiB
Oversized tool-result spill + budget-delta flush
Date: 2026-06-02
Branch/worktree: oversized-output-spill
Status: spec (pre-implementation)
Problem
A single large tool result can dominate the context window before any
existing pruning mechanism reacts. Observed: a session at ~70% of a 1M-token
window where one fetch result was ~1.08 MB (~270K tokens, ~27% of the
window) on its own.
Why nothing "kicked in":
- No single-result cap. Every captured result — regardless of size — waits for normal batch close → async summarization → rolling-window aging before its raw body leaves context. A 1 MB output sits at full size for several turns.
- Budget trigger is a single aggregate gate.
shouldBudgetFlush(src/budget.ts) fires only whenusage.tokens / usage.contextWindow >= autoBudgetThreshold, evaluated once perturn_end(index.ts:714-730). The user'sautoBudgetThresholdwas0.8(DEFAULT_CONFIGshipsnull= off); at 70% it had not yet tripped, and a single turn can jump from below to over-limit between two boundaries.
This spec adds two layers:
- A — eager spill: synchronously offload any single oversized result to a file and index it immediately, so it never reaches a request at full size.
- B — budget-delta flush: force a flush when one turn adds a large fraction of the window, independent of the absolute aggregate threshold.
Both reuse existing machinery (Indexer.addBatch → isSummarized →
pruneMessages stub-replace; shouldBudgetFlush + turn_end flush).
Ground truth (package namespace)
This extension currently pins and imports @mariozechner/*
(pi-coding-agent, pi-ai, pi-tui, pi-agent-core) — that is what is
installed and what every source file imports, so API verification for this
spec resolves under node_modules/@mariozechner/pi-coding-agent/dist.
Caveat: the entire @mariozechner/* family is deprecated; each
package's npm metadata says "please use @earendil-works/<pkg> instead going
forward" (the global pi harness already runs @earendil-works/pi-coding-agent).
@earendil-works/* is the renamed continuation — same symbols, same paths —
so the table below is expected to hold verbatim after migration. Migrating
this repo's peer deps from @mariozechner to @earendil-works is a
repo-wide dependency rename, out of scope for this spec and tracked
separately; this feature builds against whatever the peer deps resolve to at
implementation time.
Symbols this spec depends on, verified present (in the installed
@mariozechner types):
| symbol | location | note |
|---|---|---|
getSessionDir(), getSessionId(), getSessionFile() |
core/session-manager.d.ts:189-191 (on ReadonlySessionManager, l.136) |
spill dir + id |
getContextUsage(): ContextUsage | undefined |
core/extensions/types.d.ts:231 |
budget triggers |
ContextUsage.tokens: number | null, contextWindow: number, percent: number | null |
core/extensions/types.d.ts:192-198 |
tokens is null right after compaction — must guard |
truncateHead(content, options?), DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES |
core/tools/truncate.d.ts (re-exported from index) |
options object, not positional |
Non-goals
- Changing the
fetch/bashtools (harness-owned, separate repo). A is tool-agnostic and covers them all. - Orphan-blob garbage collection / startup sweep. Out of scope for v1; blob cleanup rides on session deletion only (see Cleanup).
- Replacing the LLM summarizer. Eager-spilled results are NOT summarized by an LLM; the stub is mechanical.
A — Eager single-result spill
Trigger
At tool-result capture (live turn_end path in src/batch-capture.ts, and
the reconstruction path captureUnindexedBatchesFromSession), evaluate each
CapturedToolCall independently. A call is oversized when
resultText.length >= spillThreshold (chars — matches resultText.length,
not bytes).
Ordering relative to existing pre-flush passes (precedence is strict):
- Protected-tools filter first — a
protectedToolsresult is never spilled. - Dedup check second — compute the content hash on the full in-memory
body. If it matches an existing original, alias as today (
registerDuplicate) and do not write a file. A duplicate of an already-spilled original aliases to that original's record (and thus itsspillPath); the byte cost is paid once. - Spill last — only a non-duplicate oversized result is written to a sidecar file and indexed.
The spill + addBatch happens before the existing flushPending call in the
turn_end handler. No extra context event is forced; the stub takes effect
on the next natural context build (same as today's summarized records).
Storage (hybrid)
- Bodies
< spillThresholdstay inline in thecontext-prune-indexentry exactly as today (IndexEntryData.toolCalls[].resultText). Portable: the JSONL remains self-contained for all normal content. - Bodies
>= spillThresholdare written to a sidecar file; the index entry stores a reference + head preview instead of the full body.
File location:
<sessionDir>/<sessionId>-blobs/<toolCallId>.txt
sessionDir = ctx.sessionManager.getSessionDir(),sessionId = ctx.sessionManager.getSessionId()(confirmed onReadonlySessionManager,session-manager.d.ts). Persistent root alongside the session JSONL — survives reboot/resume, not subject toos.tmpdir()reapers.- One file per tool call, full verbatim
resultText, UTF-8. toolCallIdis filename-safe in practice (provider ids liketoolu_...); sanitize defensively ([^A-Za-z0-9_-]→_) to avoid path traversal / separators.
Record / persistence changes
ToolCallRecord (src/types.ts) gains optional fields:
spillPath?: string; // absolute path to the sidecar blob
spillBytes?: number; // full byte length of the spilled body
resultPreview?: string; // head preview kept inline when spilled
contentHash?: string; // dedup hash of the FULL body, persisted
resultText invariant. Persisting the full body in the index entry would
defeat the spill (MB back in the JSONL). So for a spilled record the persisted
entry carries resultPreview (not the full body), spillPath, spillBytes,
and contentHash. The full resultText exists only transiently in memory at
capture time — long enough to compute contentHash and write the file — and
is not serialized.
Dedup hash persistence. Today addBatch computes the content hash from
resultText and reconstructFromSession recomputes it on reload. A spilled
record has no full resultText after reload, so the hash is computed once at
spill time and persisted as contentHash; reconstruct reads it back
instead of recomputing. Non-spilled records keep the existing
recompute-from-resultText path unchanged.
No summarizer path. Eager-spilled records bypass
serializeBatchForSummarizer entirely (they are never sent to the LLM
summarizer), so there is no double-truncation between preview and summarizer
input.
IndexEntryData.toolCalls[] already serializes ToolCallRecord, so adding the
optional fields persists them with no new custom entry type.
Indexer.reconstructFromSession replays them unchanged; the preview + hash
travel inline, the full body stays on disk.
What the model sees (stub)
pruneMessages already stub-replaces any isSummarized tool result. For a
spilled record the stub text is mechanical (no LLM summary) and contains:
- tool name + a compact rendering of args,
- size (
spillBytesand line count), - the head preview (
resultText), - the absolute
spillPathand an explicit hint:read this file (offset/limit supported) to access the full output, - the existing short-ref line so
context_tree_querystill works.
This gives file-handle ergonomics: the model slices the body on demand with
the native read tool; no LLM round-trip happened on capture.
Stub branching (src/pruner.ts)
pruneMessages Phase 1 already replaces a summarized tool result with a single
hardcoded [Summarized … ref tN] stub. It must branch on the resolved record:
getRecord(toolCallId)hasspillPathset → emit the mechanical spill stub: tool name, compact args,spillBytes+ line count, theresultPreview, the absolutespillPath, aread this file (offset/limit supported)hint, and the existing short-ref line.- otherwise → emit the existing summary-ref stub unchanged.
Both branches return a stub message (never delete the result) so pi-ai's orthan-tool-result repair is not triggered.
Indexing (synchronous, no summarizer)
Eager spill must make the result isSummarized immediately so pruneMessages
stubs it on the very next context build, without waiting for flushPending
or an LLM call. Reuse Indexer.addBatch with a single-call batch carrying the
spilled record(s); it issues short refs, persists the index entry via
appendEntry, and updates the in-memory map. No context-prune-summary
message is emitted for eager-spilled results (the stub is self-describing).
Recovery
context_tree_query (src/query-tool.ts): when the resolved record has
spillPath, read the file and apply the existing
truncateHead(content, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES }) call already used in this file to the file contents
instead of to resultText — no new constants or exports. Falls back to
resultPreview if the file is missing. The primary recovery path the model
should use is the native read tool on spillPath (full slicing);
context_tree_query remains the head-truncated fallback.
Failure handling
- Write-then-mutate (atomic). Write the sidecar file first; mutate the
record fields (
spillPath/spillBytes/resultPreview/contentHash) only afterwriteFileresolves. A throw leaves theCapturedToolCalluntouched and the result falls through to the normal flush pipeline. Never drop a result. Log at debug. - Filename collision.
toolCallIdis unique per call and the sanitized alphabet ([^A-Za-z0-9_-]→_) collides only on pathological ids; before writing, if the target path already exists for a different record, fall back to inline rather than overwrite. - Missing blob at query time → return
resultPreview+ a note that the full body is unavailable.
B — Budget-delta flush
Add a second, delta-based flush trigger in the turn_end handler, evaluated
alongside the existing absolute shouldBudgetFlush.
- New config
budgetTurnDelta: number | null(fraction of window, defaultnull= off; suggested operational value0.15). - Track the previous turn's usage fraction (
tokens / contextWindow) as a module-level variable inindex.ts, set tonullonsession_start. After a restart the first turn cannot fire the delta trigger (no previous fraction); this gap is accepted because the absoluteautoBudgetThresholdstill guards the post-restart aggregate. On a turn whereusage.tokensis null (e.g. immediately after compaction), leavepreviousFractionunchanged rather than overwriting it, so the next real reading is compared against the last known fraction. - If
(currentFraction - previousFraction) >= budgetTurnDelta, callflushPending(ctx, { delivery: "session" })— same flush the aggregate trigger uses. - Either trigger (absolute OR delta) firing causes a flush; they are ORed.
Rationale: catches a turn that adds a large aggregate (many medium outputs, or
a spike that A's per-result threshold didn't individually catch) without
waiting to cross the absolute autoBudgetThreshold.
Helper lives in src/budget.ts next to shouldBudgetFlush:
export function shouldDeltaFlush(
usage: ContextUsage | undefined,
previousFraction: number | null,
delta: number | null,
): boolean
Pure; mirrors shouldBudgetFlush's guards exactly — returns false when
!usage || usage.tokens == null || !(usage.contextWindow > 0) || delta == null || previousFraction == null. Uses the same 0–1 tokens / contextWindow
fraction (not ContextUsage.percent, which is 0–100 and null when tokens
is). Because tokens is null right after a compaction, previousFraction is
left unchanged on a null-token turn (no spurious delta when usage
"reappears").
Config (DEFAULT_CONFIG, src/types.ts)
| field | type | default | meaning |
|---|---|---|---|
spillThreshold |
number |
65536 |
min chars (resultText.length) for a single result to spill |
spillPreviewBytes |
number |
2048 |
head-preview size in bytes kept inline as resultPreview |
budgetTurnDelta |
number | null |
null |
per-turn usage-fraction increase that forces a flush |
spillThreshold is intentionally high (64 KB): only genuinely huge outputs
spill; medium outputs (tens of KB) keep flowing through normal
summarization/dedup so the model sees them without a read round-trip.
Cleanup
- The blob dir is a sibling of the session file; deleting the session dir removes its blobs. No separate GC in v1.
- No orphan sweep on startup (explicit non-goal). Accepted: blobs for deleted sessions whose dir was partially removed can linger; revisit if it matters.
Touched files
| file | change |
|---|---|
src/types.ts |
ToolCallRecord.spillPath?/spillBytes?; DEFAULT_CONFIG + ContextPruneConfig gain spillThreshold, spillPreviewBytes, budgetTurnDelta |
src/spill.ts (new) |
pure-ish helpers: blobDirFor, blobPathFor, headPreview, writeSpill, readSpill; isolates fs + path sanitize |
src/batch-capture.ts |
protected filter → dedup → spill non-duplicate oversized CapturedToolCalls (write file, set spillPath/spillBytes/resultPreview/contentHash), addBatch synchronously |
src/indexer.ts |
persist + replay contentHash for spilled records (skip recompute when present); pass new optional fields through reconstructFromSession |
src/pruner.ts |
branch on spillPath: mechanical spill stub vs. existing summary-ref stub |
src/query-tool.ts |
spillPath → read file then truncateHead; fallback to preview |
src/budget.ts |
shouldDeltaFlush |
index.ts |
turn_end: track previous usage fraction; OR shouldDeltaFlush into the flush decision; reset on session_start |
README.md |
document the 3 new settings + read-the-blob recovery |
PRUNING.md |
document eager-spill layer + budget-delta trigger |
Testing
Pure unit:
- threshold gating (
< / == / >spillThreshold). headPreview(line-boundary cut, byte cap, multibyte safety).- blob path sanitize (no traversal/separators).
shouldDeltaFlushtruth table (null delta, missing usage, below/at/above).
Integration (Pi extension smoke, per AGENTS.md):
- oversized result → sidecar file exists with full body; index entry has
spillPath+ preview, not full body. pruneMessagesstubs the spilled result on the nextcontextbuild with nocontext-prune-summaryand no summarizer LLM call.context_tree_queryon the spilled id returns file-backed (head-truncated) content;readonspillPathreturns the full body.- spill + dedup: a large result spills once; an identical later result aliases
to the spilled original (no second file write) and resolves to the same
spillPath. - reconstruct-from-session: after restart, record still resolves, blob still read.
- budget-delta: a turn whose usage jump
>= budgetTurnDeltatriggersflushPending; below it does not; a null-tokensturn neither fires nor overwritespreviousFraction.
Typecheck before commit per AGENTS.md (bun x tsc --noEmit ...).
Open questions
None. Threshold (64 KB), B in scope, no orphan sweep — all decided.