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
9 changes: 9 additions & 0 deletions src/compress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ export function isCompressNoopText(text: string): boolean {
return compressPanelBlocks(text) === 0;
}

/** "Nothing to compress" = the kernel found no compressible content in the
* requested ranges (all already compressed / below the min threshold). This
* is a TERMINAL state, not a retryable parameter error: the model should stop
* compressing and continue its task, not retry. Detected by string-matching
* the kernel's error text (ApplyCompressionResult.errors is string[]). */
export function isNothingToCompressText(text: string): boolean {
return /Nothing to do|nothing to compress|too small/i.test(text);
}

function tier3OnlyRewrite(newBlocks: CompressionBlock[], allBlocks: CompressionBlock[]): string[] | null {
if (newBlocks.length === 0) return null;
const byId = new Map(allBlocks.map((b) => [b.blockId, b]));
Expand Down
54 changes: 40 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { CoreMessage, NudgeDecision, CompressionBlock, Prompts } from "acp-
import { renderNudgeText, resolvePrompts, defaultPrompts } from "acp-kernel";
import { type AdapterConfig, resolveDelegate } from "./config.js";
import { createRuntime, type AcpRuntime, MAX_COMPRESS_ATTEMPTS } from "./runtime.js";
import { makeCompressTool, isCompressSuccessText, isCompressNoopText } from "./compress-tool.js";
import { makeCompressTool, isCompressSuccessText, isCompressNoopText, isNothingToCompressText } from "./compress-tool.js";
import { makeDecompressTool } from "./decompress-tool.js";
import { makeSearchTool } from "./search-tool.js";
import { makeStatusTool } from "./status-tool.js";
Expand Down Expand Up @@ -340,16 +340,28 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void {
}

if (outcome !== null) {
const failed = outcome.retryFor !== null ? compressOutcomes.find((o) => o.toolCallId === outcome.retryFor) : undefined;
if (failed) {
rebuilt.push(compressRetryMessage(failed.text, outcome.count, MAX_COMPRESS_ATTEMPTS));
logWarn("nudge", { sid, event: "compress-retry-inject", attempt: outcome.count, max: MAX_COMPRESS_ATTEMPTS, toolCallId: failed.toolCallId });
debug.event("compress-retry-injected", { sid, turnKey, attempt: outcome.count, toolCallId: failed.toolCallId, text: failed.text.slice(0, 200) });
} else if (outcome.cappedNow) {
logWarn("nudge", { sid, event: "compress-retry-capped", failures: outcome.count });
debug.event("compress-retry-capped", { sid, turnKey, failures: outcome.count });
if (ctx.hasUI) {
ctx.ui.notify(`[ACP] compress failed ${outcome.count}× this turn — retry prompts disabled until the next user message.`);
if (outcome.continueFor !== null) {
// "Nothing to compress" is a terminal state, not a retryable error:
// inject a one-shot "continue your task" message and STOP re-injecting.
// (Re-injecting on every LLM call is what drove the acp_status loop.)
if (!runtime.continueShownFor(turnKey)) {
rebuilt.push(compressContinueMessage());
runtime.markContinueShown(turnKey);
logWarn("nudge", { sid, event: "compress-continue-inject", toolCallId: outcome.continueFor });
debug.event("compress-continue-injected", { sid, turnKey, toolCallId: outcome.continueFor });
}
} else {
const failed = outcome.retryFor !== null ? compressOutcomes.find((o) => o.toolCallId === outcome.retryFor) : undefined;
if (failed) {
rebuilt.push(compressRetryMessage(failed.text, outcome.count, MAX_COMPRESS_ATTEMPTS));
logWarn("nudge", { sid, event: "compress-retry-inject", attempt: outcome.count, max: MAX_COMPRESS_ATTEMPTS, toolCallId: failed.toolCallId });
debug.event("compress-retry-injected", { sid, turnKey, attempt: outcome.count, toolCallId: failed.toolCallId, text: failed.text.slice(0, 200) });
} else if (outcome.cappedNow) {
logWarn("nudge", { sid, event: "compress-retry-capped", failures: outcome.count });
debug.event("compress-retry-capped", { sid, turnKey, failures: outcome.count });
if (ctx.hasUI) {
ctx.ui.notify(`[ACP] compress failed ${outcome.count}× this turn — retry prompts disabled until the next user message.`);
}
}
}
}
Expand Down Expand Up @@ -530,15 +542,15 @@ function turnStartIndex(entries: Array<{ type: string; message?: { role?: string
// session would keep an old failure as the "newest outcome" forever, and the
// per-turn counter reset would then re-prompt it with count 0 on every LLM
// call of every later turn (review finding on 7ddd2c6).
function collectCompressOutcomes(entries: Array<{ type: string; id: string; message?: AgentMessage }>, startIndex: number): Array<{ toolCallId: string; isError: boolean; success: boolean; noop: boolean; text: string }> {
const out: Array<{ toolCallId: string; isError: boolean; success: boolean; noop: boolean; text: string }> = [];
function collectCompressOutcomes(entries: Array<{ type: string; id: string; message?: AgentMessage }>, startIndex: number): Array<{ toolCallId: string; isError: boolean; success: boolean; noop: boolean; nothingToCompress: boolean; text: string }> {
const out: Array<{ toolCallId: string; isError: boolean; success: boolean; noop: boolean; nothingToCompress: boolean; text: string }> = [];
for (let i = Math.max(startIndex, -1) + 1; i < entries.length; i++) {
const entry = entries[i]!;
if (entry.type !== "message" || !entry.message) continue;
const m = entry.message as { role?: string; toolName?: string; toolCallId?: string; isError?: boolean; content?: unknown };
if (m.role !== "toolResult" || m.toolName !== "compress" || !m.toolCallId) continue;
const text = extractText(m.content);
out.push({ toolCallId: m.toolCallId, isError: m.isError === true, success: m.isError !== true && isCompressSuccessText(text), noop: m.isError !== true && isCompressNoopText(text), text });
out.push({ toolCallId: m.toolCallId, isError: m.isError === true, success: m.isError !== true && isCompressSuccessText(text), noop: m.isError !== true && isCompressNoopText(text), nothingToCompress: m.isError === true && isNothingToCompressText(text), text });
}
return out;
}
Expand All @@ -563,6 +575,20 @@ function compressRetryMessage(errorText: string, attempt: number, maxAttempts: n
return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() } as AgentMessage;
}

/** One-shot message for the "nothing to compress" terminal state: the model's
* requested ranges are already compressed / below the min threshold, so there
* is nothing to fix. Tell it to STOP compressing and continue its task. Injected
* exactly once per turn (tracked by continueShownFor) — NOT re-injected on
* every LLM call, which is what caused the acp_status re-check loop. */
function compressContinueMessage(): AgentMessage {
const text = [
"[ACP] Nothing left to compress — your requested ranges are already compressed (or below the minimum threshold).",
"",
"Do NOT call compress or acp_status again for this. Continue with your original task.",
].join("\n");
return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() } as AgentMessage;
}

function nudgeMessage(nudge: NudgeDecision, blocks: CompressionBlock[], prompts: Prompts): AgentMessage {
const rendered = renderNudgeText(nudge, prompts);
const lines = [rendered.text];
Expand Down
27 changes: 22 additions & 5 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,16 @@ export interface AcpRuntime {
setPrompts(prompts: Prompts): void;
markNudgeShown(turnKey: string): void;
nudgeShownFor(turnKey: string): boolean;
markContinueShown(turnKey: string): void;
continueShownFor(turnKey: string): boolean;
/** Process compress toolResults for the CURRENT user turn only (the caller
* scopes the list — see collectCompressOutcomes in src/index.ts); idempotent
* per toolCallId. Outcome classes: isError or noop (0-block panel) →
* failure (count++), success panel (>= 1 block) → reset, other non-error
* text → neutral (count unchanged). Returns the failure count, the
* toolCallId of the newest failure that still needs a retry prompt (null
* when none, capped, or count 0), and whether the cap was just reached. */
noteCompressOutcomes(turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean }>): { count: number; retryFor: string | null; cappedNow: boolean };
noteCompressOutcomes(turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean; nothingToCompress?: boolean }>): { count: number; retryFor: string | null; continueFor: string | null; cappedNow: boolean };
/** True when this turn already burned MAX_COMPRESS_ATTEMPTS failed/no-op
* compress calls — used to stop re-injecting the (dedup-exempt) emergency
* nudge that would otherwise keep looping no-op compressions (issue #6). */
Expand Down Expand Up @@ -289,8 +291,12 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
const compressOutcomeSeen = new Set<string>();
let compressFailTurnKey: string | null = null;
let compressFailCount = 0;
// turnKeys that already received the one-shot "nothing to compress → continue
// your task" message. Prevents re-injecting it on every LLM call (the
// acp_status re-check loop). Reset per turn via clearCompressRetryTracking.
const continueShownTurns = new Set<string>();

function noteCompressOutcomes(turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean }>): { count: number; retryFor: string | null; cappedNow: boolean } {
function noteCompressOutcomes(turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean; nothingToCompress?: boolean }>): { count: number; retryFor: string | null; continueFor: string | null; cappedNow: boolean } {
if (compressFailTurnKey !== turnKey) {
compressFailTurnKey = turnKey;
compressFailCount = 0;
Expand All @@ -299,6 +305,11 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
for (const o of outcomes) {
if (compressOutcomeSeen.has(o.toolCallId)) continue;
compressOutcomeSeen.add(o.toolCallId);
if (o.nothingToCompress === true) {
// terminal state: not a retryable failure — do NOT consume a retry
// attempt, so a later genuine parameter error still gets its full cap.
continue;
}
if (o.isError || o.noop === true) {
compressFailCount += 1;
} else if (o.success) {
Expand All @@ -307,12 +318,17 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
// neutral: counter untouched
}
const latest = outcomes.length > 0 ? outcomes[outcomes.length - 1] : undefined;
const latestFailed = latest && (latest.isError || latest.noop === true);
const isNothing = latestFailed !== null && latestFailed !== undefined && latestFailed && latest.nothingToCompress === true;
// count >= 1 guards against a deduped stale failure sliding in with a
// reset-to-0 counter (defense in depth; the caller's turn scoping already
// prevents it — an "attempt 0 of 3" prompt must be impossible).
const retryFor = latest && (latest.isError || latest.noop === true) && compressFailCount >= 1 && compressFailCount < MAX_COMPRESS_ATTEMPTS ? latest.toolCallId : null;
// retryFor: retryable parameter error (NOT nothing-to-compress).
const retryFor = latestFailed && !isNothing && compressFailCount >= 1 && compressFailCount < MAX_COMPRESS_ATTEMPTS ? latest.toolCallId : null;
// continueFor: nothing-to-compress (terminal — tell the model to continue).
const continueFor = isNothing ? latest.toolCallId : null;
const cappedNow = compressFailCount >= MAX_COMPRESS_ATTEMPTS && prevCount < MAX_COMPRESS_ATTEMPTS;
return { count: compressFailCount, retryFor, cappedNow };
return { count: compressFailCount, retryFor, continueFor, cappedNow };
}

function compressRetryCappedFor(turnKey: string): boolean {
Expand All @@ -323,6 +339,7 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
compressOutcomeSeen.clear();
compressFailTurnKey = null;
compressFailCount = 0;
continueShownTurns.clear();
}

async function acquireLock(sid: string): Promise<() => void> {
Expand Down Expand Up @@ -410,4 +427,4 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
lastActiveBlockIds.delete(sid);
}

return { core, store, density, setCountModel: (m) => { countModelId = m; }, noteActiveBlocks, clearSessionTracking, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k) => { nudgeShownTurns.add(k); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, throttleFor, throttleDrop };}
return { core, store, density, setCountModel: (m) => { countModelId = m; }, noteActiveBlocks, clearSessionTracking, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k) => { nudgeShownTurns.add(k); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), markContinueShown: (k) => { continueShownTurns.add(k); }, continueShownFor: (k) => continueShownTurns.has(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, throttleFor, throttleDrop };}
75 changes: 74 additions & 1 deletion tests/compress-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { rm } from "node:fs/promises";
import { createAcpExtension } from "../src/index.js";
import { createRuntime, MAX_COMPRESS_ATTEMPTS } from "../src/runtime.js";
import { isCompressSuccessText, isCompressNoopText } from "../src/compress-tool.js";
import { isCompressSuccessText, isCompressNoopText, isNothingToCompressText } from "../src/compress-tool.js";

// Failure-triggered compress retry (session 01a00a38 post-mortem): the model's
// ONLY compress call in a 3-hour session was rejected by pi's typebox
Expand Down Expand Up @@ -60,6 +60,10 @@ const SUCCESS_PANEL = "▣ ACP | 58.5K → 5.7K tokens (~52.8K reclaimed, 4 bloc
const PARTIAL_PANEL = "▣ ACP | 58.5K → 30K tokens (~28.5K reclaimed, 3 blocks)\nErrors: range m00009..m00012: Summary too long";
const NOOP_PANEL = "▣ ACP | 58.5K → 58.5K tokens (~0 reclaimed, 0 blocks)\nErrors: range m00001..m00002: Requested range(s) already compressed; nothing to compress";
const NEUTRAL_TEXT = "No ranges provided.";
// The kernel's "nothing to compress" terminal error (session 01a02715 loop):
// the requested ranges are already compressed / below the min threshold. This
// is NOT a retryable parameter error — the model should stop and continue.
const NOTHING_TO_COMPRESS_ERR = "Requested range(s) already compressed (e.g. m00809..m00888); remaining compressible content 1009 chars < min 5000. Nothing to do. Current active blocks span b1..b23 — retry with startId/endId set to active block IDs in that span.";

function fakeCtx(getEntries: () => any[], stateFile: string) {
return {
Expand Down Expand Up @@ -426,3 +430,72 @@ test("emergency nudge stops re-injecting once the turn's retry cap is burned (is
assert.ok(nudgeCount(rRe) >= 1, "after a successful compress the emergency nudge may resume");
await rm(`${stateFile}.acp.json`, { force: true });
});

// ─── session 01a02715: "nothing to compress" must NOT re-nudge (acp_status loop) ──
//
// The model's compress call was rejected with "Nothing to do" (ranges already
// compressed / below min). The old retry nudge re-injected on EVERY LLM call
// (the cap only advances on a NEW compress failure, and the model switched to
// acp_status) → 203 acp_status calls. Fix: "nothing to compress" is a terminal
// state → one-shot "continue your task" message, NOT re-injected, and it does
// not consume the retry budget.

test("classification: 'nothing to compress' errors are terminal, not retryable", () => {
assert.equal(isNothingToCompressText(NOTHING_TO_COMPRESS_ERR), true);
assert.equal(isNothingToCompressText("Total compressible content too small (100 chars). Combine more messages into your range(s) to meet the threshold."), true);
assert.equal(isNothingToCompressText("already compressed (messages consumed by existing block(s)); nothing to compress."), true);
assert.equal(isNothingToCompressText(VALIDATION_ERR), false, "parameter errors are NOT nothing-to-compress");
assert.equal(isNothingToCompressText(SUCCESS_PANEL), false);
assert.equal(isNothingToCompressText(NEUTRAL_TEXT), false);
});

test("noteCompressOutcomes: nothing-to-compress → continueFor (not retryFor), does not consume retry budget", () => {
const rt = createRuntime({});
const nothing = (id: string) => ({ toolCallId: id, isError: true, success: false, nothingToCompress: true });
const fail = (id: string) => ({ toolCallId: id, isError: true, success: false });

let r = rt.noteCompressOutcomes("u1", [nothing("t0")]);
assert.equal(r.count, 0, "nothing-to-compress does NOT consume a retry attempt");
assert.equal(r.retryFor, null, "no retry prompt for terminal state");
assert.equal(r.continueFor, "t0", "continue signal set");

// a later genuine parameter error still gets its full budget (attempt 1, not 2)
r = rt.noteCompressOutcomes("u1", [nothing("t0"), fail("t1")]);
assert.equal(r.count, 1, "genuine failure after nothing-to-compress is attempt 1");
assert.equal(r.retryFor, "t1");
assert.equal(r.continueFor, null, "latest is a parameter error, not nothing-to-compress");
});

test("nothing-to-compress error → one-shot 'continue task' message, NOT re-injected (acp_status loop breaker)", async () => {
const { api, handlers } = captureApi();
createAcpExtension({ modelContextLimit: 200_000 })(api as any);
const stateFile = "/tmp/pai-acp-retry-nothing.session.json";
await rm(`${stateFile}.acp.json`, { force: true });

let entries: any[] = [userMsg("e1", ZH)];
const ctx = fakeCtx(() => entries, stateFile);
await fire(handlers, ctx);

const continueMsgs = (r: any) =>
(r?.messages ?? []).filter((m: any) => m.role === "user" && /Nothing left to compress/.test(JSON.stringify(m.content)));

// model calls compress with already-compressed ranges → "Nothing to do" error
entries = [...entries, toolResultMsg("e2", "call_1", NOTHING_TO_COMPRESS_ERR, true)];
const r1 = await fire(handlers, ctx);
assert.equal(continueMsgs(r1).length, 1, "nothing-to-compress → one-shot continue message");
assert.equal(retryMsgs(r1).length, 0, "no retry nudge for terminal state");
const ct = r1.messages.find((m: any) => m.role === "user" && /Nothing left to compress/.test(JSON.stringify(m.content)))?.content[0].text as string;
assert.match(ct, /Do NOT call compress or acp_status again/);

// re-fire (model calls acp_status, context fires again): continue message NOT re-injected
const r2 = await fire(handlers, ctx);
assert.equal(continueMsgs(r2).length, 0, "continue message is one-shot, not re-injected on every fire");
assert.equal(retryMsgs(r2).length, 0, "no retry nudge either");

// a NEW genuine parameter error in the same turn still gets a retry nudge
entries = [...entries, toolResultMsg("e3", "call_2", VALIDATION_ERR, true)];
const r3 = await fire(handlers, ctx);
assert.equal(retryMsgs(r3).length, 1, "genuine parameter error still triggers retry nudge");
assert.match(retryText(r3), /attempt 1 of 3/);
await rm(`${stateFile}.acp.json`, { force: true });
});
Loading