Files
my-pi/pi-condense/doc/specs/2026-06-02-oversized-output-spill.md
T

325 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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":
1. **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.
2. **Budget trigger is a single aggregate gate.** `shouldBudgetFlush`
(`src/budget.ts`) fires only when `usage.tokens / usage.contextWindow >=
autoBudgetThreshold`, evaluated once per `turn_end`
(`index.ts:714-730`). The user's `autoBudgetThreshold` was `0.8`
(`DEFAULT_CONFIG` ships `null` = 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` / `bash` tools (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):
1. **Protected-tools filter first** — a `protectedTools` result is never
spilled.
2. **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 its `spillPath`); the byte cost
is paid once.
3. **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 `< spillThreshold` stay **inline** in the `context-prune-index`
entry exactly as today (`IndexEntryData.toolCalls[].resultText`). Portable:
the JSONL remains self-contained for all normal content.
- Bodies `>= spillThreshold` are 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 on `ReadonlySessionManager`,
`session-manager.d.ts`). Persistent root alongside the session JSONL —
survives reboot/resume, not subject to `os.tmpdir()` reapers.
- One file per tool call, full verbatim `resultText`, UTF-8.
- `toolCallId` is filename-safe in practice (provider ids like
`toolu_...`); sanitize defensively (`[^A-Za-z0-9_-]` → `_`) to avoid path
traversal / separators.
### Record / persistence changes
`ToolCallRecord` (`src/types.ts`) gains optional fields:
```ts
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 (`spillBytes` and line count),
- the head preview (`resultText`),
- the absolute `spillPath` and an explicit hint:
`read this file (offset/limit supported) to access the full output`,
- the existing short-ref line so `context_tree_query` still 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)` has `spillPath` set → emit the **mechanical spill
stub**: tool name, compact args, `spillBytes` + line count, the
`resultPreview`, the absolute `spillPath`, a `read 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
after `writeFile` resolves. A throw leaves the `CapturedToolCall` untouched
and the result falls through to the normal flush pipeline. Never drop a
result. Log at debug.
- **Filename collision.** `toolCallId` is 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, default
`null` = off; suggested operational value `0.15`).
- Track the previous turn's usage fraction (`tokens / contextWindow`) as a
module-level variable in `index.ts`, set to `null` on `session_start`.
After a restart the first turn cannot fire the delta trigger (no previous
fraction); this gap is accepted because the absolute `autoBudgetThreshold`
still guards the post-restart aggregate. On a turn where `usage.tokens` is
null (e.g. immediately after compaction), leave `previousFraction`
unchanged rather than overwriting it, so the next real reading is compared
against the last known fraction.
- If `(currentFraction - previousFraction) >= budgetTurnDelta`, call
`flushPending(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`:
```ts
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 `CapturedToolCall`s (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).
- `shouldDeltaFlush` truth 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.
- `pruneMessages` stubs the spilled result on the next `context` build with
**no** `context-prune-summary` and **no** summarizer LLM call.
- `context_tree_query` on the spilled id returns file-backed (head-truncated)
content; `read` on `spillPath` returns 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 `>= budgetTurnDelta` triggers
`flushPending`; below it does not; a null-`tokens` turn neither fires nor
overwrites `previousFraction`.
Typecheck before commit per AGENTS.md (`bun x tsc --noEmit ...`).
## Open questions
None. Threshold (64 KB), B in scope, no orphan sweep — all decided.