Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 51 additions & 9 deletions src/floor-stale.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<string, number>;
}

function scanEntries(entries: AnchorEntry[]): AnchorScan {
let lastUsageIdx = -1;
let lastCompressIdx = -1;
const compressIdxByCallId = new Map<string, number>();
for (let i = 0; i < entries.length; i++) {
const m = entries[i]!.message;
if (!m) continue;
Expand All @@ -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 };
}
17 changes: 11 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 85 additions & 1 deletion tests/floor-stale.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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> = {}): 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);
});
65 changes: 50 additions & 15 deletions tests/sent-view-arbitration.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, unknown>) {
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<string, unknown> = {}) => ({
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 });
});