feat(pi-condense): vendor source and add summarizer retries

This commit is contained in:
云服务部-叶林立
2026-08-31 15:13:41 +08:00
parent 8002d7c213
commit e80959cc3f
94 changed files with 21327 additions and 185 deletions
@@ -0,0 +1,324 @@
# 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.