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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user