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.
This commit is contained in:
can1357
2026-06-15 12:45:21 +02:00
parent e8ef706abf
commit f40e528073
6 changed files with 38 additions and 3 deletions
+1
View File
@@ -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:
+4
View File
@@ -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
+12 -1
View File
@@ -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;
}
@@ -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();
});
});
+1 -1
View File
@@ -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,
@@ -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,