diff --git a/src/floor-stale.ts b/src/floor-stale.ts index 7509a5c..6246186 100644 --- a/src/floor-stale.ts +++ b/src/floor-stale.ts @@ -1,7 +1,7 @@ // pi's getContextUsage() anchors on the last assistant message with valid -// provider usage. A successful compress lands AFTER that anchor, so the -// floor must be skipped for the next LLM call (mirrors pi's own -// "usage source must be post-compaction" compaction check). +// provider usage; a successful compress after that anchor leaves it reflecting +// the pre-compression request until the next turn reports fresh usage. +import type { CompressionBlock } from "acp-kernel"; import { isCompressSuccessText } from "./compress-tool.js"; import { extractText } from "./messages.js"; @@ -32,13 +32,16 @@ function validAnchorUsage(u: UsageLike): boolean { return total > 0; } -/** True when the last valid assistant usage anchor comes strictly BEFORE the - * last successful compress toolResult — the host's provider-usage number - * still reflects the pre-compression request. Failed/no-op compresses don't - * count (nothing was reclaimed). */ -export function usageAnchorPredatesCompression(entries: AnchorEntry[]): boolean { +interface AnchorScan { + lastUsageIdx: number; + lastCompressIdx: number; + compressIdxByCallId: Map; +} + +function scanEntries(entries: AnchorEntry[]): AnchorScan { let lastUsageIdx = -1; let lastCompressIdx = -1; + const compressIdxByCallId = new Map(); for (let i = 0; i < entries.length; i++) { const m = entries[i]!.message; if (!m) continue; @@ -52,7 +55,46 @@ export function usageAnchorPredatesCompression(entries: AnchorEntry[]): boolean isCompressSuccessText(extractText(m.content)) ) { lastCompressIdx = i; + compressIdxByCallId.set(m.toolCallId, i); } } - return lastCompressIdx > lastUsageIdx; + return { lastUsageIdx, lastCompressIdx, compressIdxByCallId }; +} + +/** True when the last valid assistant usage anchor comes strictly BEFORE the + * last successful compress toolResult — the host's provider-usage number + * still reflects the pre-compression request. Failed/no-op compresses don't + * count (nothing was reclaimed). */ +export function usageAnchorPredatesCompression(entries: AnchorEntry[]): boolean { + const scan = scanEntries(entries); + return scan.lastCompressIdx > scan.lastUsageIdx; +} + +export interface AnchorStaleness { + // anchor still reflects the pre-compression request + predates: boolean; + // Σ max(0, compressedTokens − summary) over active blocks whose compress + // landed after the anchor; unattributable/pre-anchor blocks are excluded + netReclaimed: number; +} + +// issue #325: flooring at a stale anchor's raw value re-fires a false EMERGENCY, +// while skipping it under-counts by the fixed overhead the host already counts. +// Return how much was reclaimed since the anchor so callers floor at +// `anchor − netReclaimed` instead of either extreme. +export function compressionAnchorStaleness( + entries: AnchorEntry[], + blocks: readonly CompressionBlock[], + countTokens: (text: string) => number, +): AnchorStaleness { + const scan = scanEntries(entries); + let netReclaimed = 0; + for (const block of blocks) { + if (!block.active || block.compressCallId == null) continue; + const idx = scan.compressIdxByCallId.get(block.compressCallId); + if (idx == null || idx <= scan.lastUsageIdx) continue; + const saved = (block.compressedTokens ?? 0) - countTokens(block.summary ?? ""); + netReclaimed += saved > 0 ? saved : 0; + } + return { predates: scan.lastCompressIdx > scan.lastUsageIdx, netReclaimed }; } diff --git a/src/index.ts b/src/index.ts index 58e83b7..19faec2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ import { delegateStatusWidget } from "./fleet-widget.js"; import { wireToolGuardrails } from "./tool-guardrails.js"; import { debug, logError, logInfo, logWarn, logThrow, closeLogStream } from "./log.js"; import { collectCoveredMessageIds, estimateTokens, lastUserMessageId, collectImageTokens, modelSupportsImages } from "./tokens.js"; -import { usageAnchorPredatesCompression } from "./floor-stale.js"; +import { compressionAnchorStaleness } from "./floor-stale.js"; import { checkForUpdate } from "./update.js"; import { THROTTLE_RETRY_ERROR_MESSAGE, @@ -244,13 +244,18 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void { // a heuristic (images, mixed content, per-model tokenizer drift), so // the 0.75/0.95 bands run on the real scale via the floor. realUsage is // anchored on the last assistant's provider-reported usage + trailing - // estimate. Only ever raises (never lowers); skipped while the anchor - // predates a successful compress (floor-stale.ts). tokenCount only - // feeds processTurn. + // estimate. Only ever raises (never lowers). While the anchor predates a + // successful compress its raw value still reflects the pre-compression + // request, so we subtract the tokens reclaimed since it (issue #325) + // rather than skip the floor. tokenCount only feeds processTurn. let tokenCount = sentTokens; const realPromptTokens = realUsage?.tokens ?? 0; - if (!usageAnchorPredatesCompression(entries) && realPromptTokens > tokenCount) { - tokenCount = realPromptTokens; + if (realPromptTokens > 0) { + const { predates, netReclaimed } = compressionAnchorStaleness(entries, state.blocks, defaultCountTokens); + const effectiveFloor = predates ? Math.max(0, realPromptTokens - netReclaimed) : realPromptTokens; + if (effectiveFloor > tokenCount) { + tokenCount = effectiveFloor; + } } // Self-heal (armed): after an overflow, force this turn's usage to >=95% // so the kernel's emergency nudge + tool-result truncate fire immediately, diff --git a/tests/floor-stale.test.ts b/tests/floor-stale.test.ts index 28c4767..64c13cc 100644 --- a/tests/floor-stale.test.ts +++ b/tests/floor-stale.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { usageAnchorPredatesCompression } from "../src/floor-stale.js"; +import type { CompressionBlock } from "acp-kernel"; +import { usageAnchorPredatesCompression, compressionAnchorStaleness } from "../src/floor-stale.js"; // The issue #257 floor must be skipped while the host's provider-usage anchor // (the usage on the last valid assistant message) predates the last @@ -96,3 +97,86 @@ test("non-compress toolResults are ignored", () => { ]; assert.equal(usageAnchorPredatesCompression(entries), false); }); + +// issue #325: compressionAnchorStaleness attributes reclamation to blocks whose +// creating compress toolResult lands after the last valid usage anchor, so the +// caller can floor at (anchor − netReclaimed) instead of skipping the floor. +const ct = (t: string): number => t.length; +const blk = (over: Partial = {}): CompressionBlock => ({ + blockId: "b0", runId: "r", tier: 1, summary: "abcde", directMessageIds: [], effectiveMessageIds: [], + directBlockIds: [], compressedTokens: 1000, createdAt: Date.now(), survivedCount: 0, generation: "young", active: true, ...over, +}); + +test("staleness: fresh anchor → not predates, nothing reclaimed", () => { + const entries = [ + msg("e0", { role: "user", content: "go" }), + assistantUsage("e1"), + compressResult("e2", PANEL_OK), + assistantUsage("e3"), + ]; + const r = compressionAnchorStaleness(entries, [blk()], ct); + assert.equal(r.predates, false); + assert.equal(r.netReclaimed, 0); +}); + +test("staleness: stale anchor reclaims a post-anchor block", () => { + const entries = [ + msg("e0", { role: "user", content: "go" }), + assistantUsage("e1"), + compressResult("e2", PANEL_OK), // toolCallId c1 + ]; + const r = compressionAnchorStaleness(entries, [blk({ compressCallId: "c1" })], ct); + assert.equal(r.predates, true); + assert.equal(r.netReclaimed, 995); // 1000 − len("abcde") +}); + +test("staleness: pre-anchor block is not counted as reclaimed", () => { + const entries = [ + msg("e0", { role: "user", content: "go" }), + compressResult("e1", PANEL_OK), // c1 before any usage anchor + assistantUsage("e2"), + ]; + const r = compressionAnchorStaleness(entries, [blk({ compressCallId: "c1" })], ct); + assert.equal(r.predates, false); + assert.equal(r.netReclaimed, 0); +}); + +test("staleness: inactive and unattributable blocks are skipped", () => { + const entries = [ + msg("e0", { role: "user", content: "go" }), + assistantUsage("e1"), + compressResult("e2", PANEL_OK), // c1 + ]; + const r = compressionAnchorStaleness(entries, [ + blk({ compressCallId: "c1", active: false }), + blk({ blockId: "b1", compressCallId: undefined }), + ], ct); + assert.equal(r.predates, true); + assert.equal(r.netReclaimed, 0); +}); + +test("staleness: negative savings clamp to zero", () => { + const entries = [ + msg("e0", { role: "user", content: "go" }), + assistantUsage("e1"), + compressResult("e2", PANEL_OK), // c1 + ]; + const r = compressionAnchorStaleness(entries, [blk({ compressCallId: "c1", compressedTokens: 3 })], ct); + assert.equal(r.predates, true); + assert.equal(r.netReclaimed, 0); // 3 − 5 < 0 → clamped +}); + +test("staleness: sums multiple post-anchor blocks", () => { + const entries = [ + msg("e0", { role: "user", content: "go" }), + assistantUsage("e1"), + compressResult("e2", PANEL_OK), // c1 + msg("e3", { role: "toolResult", toolName: "compress", toolCallId: "c2", content: [{ type: "text", text: PANEL_OK }] }), + ]; + const r = compressionAnchorStaleness(entries, [ + blk({ compressCallId: "c1", compressedTokens: 1000 }), // 995 + blk({ blockId: "b1", compressCallId: "c2", compressedTokens: 2000 }), // 1995 + ], ct); + assert.equal(r.predates, true); + assert.equal(r.netReclaimed, 2990); +}); diff --git a/tests/sent-view-arbitration.test.ts b/tests/sent-view-arbitration.test.ts index 5667a01..9c1e8b8 100644 --- a/tests/sent-view-arbitration.test.ts +++ b/tests/sent-view-arbitration.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { rm } from "node:fs/promises"; +import { rm, writeFile } from "node:fs/promises"; import { createAcpExtension } from "../src/index.js"; // Nudge arbitration runs on the SENT-VIEW estimate floored at the host's real @@ -112,23 +112,58 @@ test("context transform stays idle when there is no provider usage to floor from await rm(`${STATE_FILE}.500.acp.json`, { force: true }); }); -// Stale-anchor guard (PR #258 review): the usage anchor (e19's 175K usage) -// predates the successful compress toolResult that follows it. The next LLM -// call must NOT floor the meter at the pre-compress anchor — the context was -// just shrunk, so no emergency fires even though the host still reports 97% -// of the window (same stream and fakeCtx(175_000) as the floor test above). -test("context transform skips the provider-usage floor while the anchor predates a successful compress", async () => { - await rm(`${STATE_FILE}.175001.acp.json`, { force: true }); - const { api, handlers } = captureApi(); - createAcpExtension({ modelContextLimit: 180_000 })(api as any); +// issue #325: the usage anchor (e19's 175K) predates the successful compress +// toolResult that follows it, so the host's 175K still reflects the PRE-compress +// request. Seeding a real reclaiming block makes the meter floor at +// (anchor − reclaimed) instead of skipping the floor or flooring at the raw +// anchor — the next turn must not snap back to 97% and re-fire a false EMERGENCY. +async function seedState(file: string, block: Record) { + const state = { blocks: [block], nextBlockId: 2, messageRefs: { byRaw: {}, byRef: {}, nextRef: 0 }, nudge: {}, stats: {} }; + await writeFile(file, JSON.stringify(state), "utf8"); +} - const ctx = fakeCtx(175_000); +const seedBlock = (over: Record = {}) => ({ + blockId: "b0", runId: 0, tier: 1, generation: "young", active: true, + summary: "compressed early history", directMessageIds: ["e1", "e2", "e3"], + effectiveMessageIds: ["e1", "e2", "e3"], directBlockIds: [], compressedTokens: 60_000, + survivedCount: 3, createdAt: Date.now(), compressCallId: "c1", ...over, +}); + +const staleStream = (): any[] => { const entries = [msg("e0", "user", "start " + MID)]; for (let i = 1; i <= 18; i++) entries.push(msg(`e${i}`, i % 2 ? "assistant" : "user", `f${i} ` + MID)); entries.push({ type: "message", id: "e19", parentId: null, timestamp: "", message: { role: "assistant", content: "f19 " + MID, timestamp: Date.now(), usage: { input: 175_000, cacheRead: 0, cacheWrite: 0 } } }); entries.push({ type: "message", id: "e20", parentId: null, timestamp: "", message: { role: "toolResult", toolName: "compress", toolCallId: "c1", content: [{ type: "text", text: "▣ ACP | 42.3K → 18.9K tokens (~23.4K reclaimed, 3 blocks)" }], timestamp: Date.now() } }); - branchEntries = entries; - const r = await fire(handlers, entries, ctx); - assert.equal(nudgeCount(r), 0, "no nudge: usage anchor predates a successful compress, floor skipped"); - await rm(`${STATE_FILE}.175001.acp.json`, { force: true }); + return entries; +}; + +test("context transform floors at anchor-minus-reclaimed while the anchor predates a successful compress", async () => { + const acpFile = `${STATE_FILE}.175000.acp.json`; + await rm(acpFile, { force: true }); + await seedState(acpFile, seedBlock({ compressedTokens: 60_000 })); + const { api, handlers } = captureApi(); + createAcpExtension({ modelContextLimit: 180_000 })(api as any); + + const ctx = fakeCtx(175_000); + branchEntries = staleStream(); + const r = await fire(handlers, branchEntries, ctx); + assert.equal(nudgeCount(r), 0, "no false emergency: floor adjusted down by ~60K reclaimed since the anchor"); + await rm(acpFile, { force: true }); +}); + +// issue #325 guard: if the compress barely reclaimed anything while the context +// is already near the limit, the adjusted floor must stay high enough to STILL +// trip the nudge — never over-suppress a genuinely full context. +test("context transform still trips the nudge when a stale-anchor compress reclaimed little", async () => { + const acpFile = `${STATE_FILE}.174000.acp.json`; + await rm(acpFile, { force: true }); + await seedState(acpFile, seedBlock({ compressedTokens: 1_000 })); + const { api, handlers } = captureApi(); + createAcpExtension({ modelContextLimit: 180_000 })(api as any); + + const ctx = fakeCtx(174_000); + branchEntries = staleStream(); + const r = await fire(handlers, branchEntries, ctx); + assert.ok(nudgeCount(r) >= 1, "nudge still fires: only ~1K reclaimed, context is genuinely near the limit"); + await rm(acpFile, { force: true }); });