From f40e5280736ef4ccf4bb2773987002f74cd6c044 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 15 Jun 2026 12:43:23 +0200 Subject: [PATCH] fix(agent/compaction): skipped truncating tiny tool outputs during pruning - Added a 50-token minimum floor constant for pruning decisions. - Allowed non-superseded, non-useless tool results below that floor to avoid truncation. --- docs/compaction.md | 1 + packages/agent/CHANGELOG.md | 4 ++++ packages/agent/src/compaction/pruning.ts | 13 ++++++++++++- packages/agent/test/supersede-prune.test.ts | 19 +++++++++++++++++++ packages/agent/test/tool-protection.test.ts | 2 +- .../test/plan-mode/plan-protection.test.ts | 2 +- 6 files changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/compaction.md b/docs/compaction.md index ca29449e8..b745aa0ca 100644 --- a/docs/compaction.md +++ b/docs/compaction.md @@ -151,6 +151,7 @@ Default prune policy: - Protect newest `40_000` tool-output tokens. - Require at least `20_000` total estimated savings. +- Never blank a result below `50` tokens (`MIN_PRUNE_TOKENS`): the `[Output truncated - N tokens]` placeholder costs ~8 tokens, so pruning a sub-floor result would grow the context and churn the prompt cache for nothing. (Superseded and useless results keep their own rules — the useless collector already drops no-savings candidates; superseded reads prune for correctness regardless of size.) - Never prune `skill` tool results, `read` results of `skill://` paths, or reads of the active plan reference file (added via `AgentSession`'s plan protection). Pruned tool results are replaced with: diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index d1ecb4620..0fda122c3 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Added the `interruptible` tool field: when set, the agent loop may abort the tool mid-execution to deliver a queued steering message (honored only in `immediate` interrupt mode). - Added support for `gemini` and `gemma` as valid owned tool syntax values in environment configuration +### Fixed + +- Fixed `pruneToolOutputs` blanking tiny tool results during overflow pruning: results below `50` tokens (`MIN_PRUNE_TOKENS`) are no longer replaced with the `[Output truncated - N tokens]` placeholder, which cost more tokens than the result itself and churned the prompt cache for zero savings. + ## [15.13.2] - 2026-06-15 ### Breaking Changes diff --git a/packages/agent/src/compaction/pruning.ts b/packages/agent/src/compaction/pruning.ts index 5cf22de47..58c3dccca 100644 --- a/packages/agent/src/compaction/pruning.ts +++ b/packages/agent/src/compaction/pruning.ts @@ -81,6 +81,16 @@ function createPrunedNotice(tokens: number): string { return `[Output truncated - ${tokens} tokens]`; } +/** + * Generic age-based pruning floor. Below this, blanking a result to + * `[Output truncated - N tokens]` recovers nothing — the placeholder itself + * costs ~8 tokens, so a sub-floor result grows the context (and churns the + * prompt cache) instead of shrinking it. Superseded/useless results keep their + * own rules: useless already drops no-savings candidates, superseded prunes for + * correctness regardless of size. + */ +const MIN_PRUNE_TOKENS = 50; + function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined { if (entry.type !== "message") return undefined; const message = entry.message as AgentMessage; @@ -271,7 +281,8 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = // any age). const superseded = supersededMessages?.has(message) ?? false; const useless = uselessMessages?.has(message) ?? false; - if (!superseded && !useless && (accumulatedTokens < config.protectTokens || isProtected)) { + const tooSmall = tokens < MIN_PRUNE_TOKENS; + if (!superseded && !useless && (accumulatedTokens < config.protectTokens || isProtected || tooSmall)) { accumulatedTokens += tokens; continue; } diff --git a/packages/agent/test/supersede-prune.test.ts b/packages/agent/test/supersede-prune.test.ts index 55bab5d55..f71f63526 100644 --- a/packages/agent/test/supersede-prune.test.ts +++ b/packages/agent/test/supersede-prune.test.ts @@ -503,3 +503,22 @@ describe("pruneToolOutputs — useless results", () => { expect(resultText(result1)).toBe(NO_MATCH_TEXT); }); }); + +describe("pruneToolOutputs — small-result floor", () => { + test("sub-floor results are left intact while a large neighbor is pruned", () => { + // "ok" is ~1 token: blanking it to `[Output truncated - 1 tokens]` would + // grow the context, so the floor must keep it. The large neighbor still prunes. + const [tinyCall, tinyResult] = readPair("src/tiny.ts", "ok", T0); + const [bigCall, bigResult] = readPair("src/big.ts", FILE_CONTENT, T0 + 1_000); + const entries: SessionEntry[] = [tinyCall, tinyResult, bigCall, bigResult]; + + // Protect window empty and zero savings threshold: only size keeps the tiny one. + const result = pruneToolOutputs(entries, { protectTokens: 0, minimumSavings: 0, protectedTools: [] }); + + expect(result.prunedCount).toBe(1); + expect(resultText(tinyResult)).toBe("ok"); + expect(resultMessage(tinyResult).prunedAt).toBeUndefined(); + expect(resultText(bigResult)).toMatch(/^\[Output truncated - \d+ tokens\]$/); + expect(resultMessage(bigResult).prunedAt).toBeDefined(); + }); +}); diff --git a/packages/agent/test/tool-protection.test.ts b/packages/agent/test/tool-protection.test.ts index 59d427fe2..0d5281dae 100644 --- a/packages/agent/test/tool-protection.test.ts +++ b/packages/agent/test/tool-protection.test.ts @@ -53,7 +53,7 @@ function readResult(toolCallId: string, text: string): SessionMessageEntry { describe("conditional tool-result protection", () => { it("prunes regular read results but keeps skill:// reads", () => { const skillResult = readResult("skill-read", "skill read output that must remain intact"); - const fileResult = readResult("file-read", "file read output that can be pruned"); + const fileResult = readResult("file-read", "file read output that can be pruned ".repeat(20)); const entries = [ assistantReadCall("skill-read", "skill://session-memory"), skillResult, diff --git a/packages/coding-agent/test/plan-mode/plan-protection.test.ts b/packages/coding-agent/test/plan-mode/plan-protection.test.ts index ac0d61a70..ab234b102 100644 --- a/packages/coding-agent/test/plan-mode/plan-protection.test.ts +++ b/packages/coding-agent/test/plan-mode/plan-protection.test.ts @@ -123,7 +123,7 @@ describe("plan-read protection in compaction", () => { it("prunes regular reads but keeps the plan read intact", () => { const planResult = readResult("plan-read", "plan body that must remain intact"); - const fileResult = readResult("file-read", "file body that can be pruned"); + const fileResult = readResult("file-read", "file body that can be pruned ".repeat(20)); const entries = [ readCall("plan-read", "local://wp-migration.md"), planResult,