From 369816748c423259373f700c9a320345f6263efb Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Sat, 22 Aug 2026 12:08:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(compress):=20'nothing=20to=20compress'=20is?= =?UTF-8?q?=20terminal=20=E2=80=94=20one-shot=20continue=20msg,=20not=20re?= =?UTF-8?q?-nudge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session 01a02715 post-mortem: a compress call rejected with 'Nothing to do' (ranges already compressed / below min) triggered the retry nudge, which re-injected on EVERY LLM call. The cap only advances on a NEW compress failure, and the model switched to acp_status (not compress) — so the counter stayed at 1 and the nudge re-pushed forever → 203 acp_status calls. Root cause: the retry nudge conflated two failure types: - parameter error (bad args) → should retry - nothing-to-compress (terminal) → should stop and continue the task Fix (extension only; the kernel has no retry nudge): - isNothingToCompressText(): string-match the kernel's terminal error - noteCompressOutcomes returns continueFor (terminal) vs retryFor (retryable); nothing-to-compress does NOT consume the retry budget - compressContinueMessage(): one-shot 'continue your task, stop calling compress/acp_status' message, tracked by continueShownTurns so it is injected exactly once per turn — not re-injected on every LLM call +3 tests (classification, counter, integration one-shot-not-reinjected). 412 tests pass, typecheck + build clean. --- src/compress-tool.ts | 9 +++++ src/index.ts | 54 +++++++++++++++++++------- src/runtime.ts | 27 ++++++++++--- tests/compress-retry.test.ts | 75 +++++++++++++++++++++++++++++++++++- 4 files changed, 145 insertions(+), 20 deletions(-) diff --git a/src/compress-tool.ts b/src/compress-tool.ts index 43900ac..0f057ec 100644 --- a/src/compress-tool.ts +++ b/src/compress-tool.ts @@ -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])); diff --git a/src/index.ts b/src/index.ts index fd3b872..d3b3eef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -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.`); + } } } } @@ -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; } @@ -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]; diff --git a/src/runtime.ts b/src/runtime.ts index f55dbe4..b2fdb39 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -68,6 +68,8 @@ 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) → @@ -75,7 +77,7 @@ export interface AcpRuntime { * 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). */ @@ -289,8 +291,12 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { const compressOutcomeSeen = new Set(); 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(); - 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; @@ -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) { @@ -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 { @@ -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> { @@ -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 };} diff --git a/tests/compress-retry.test.ts b/tests/compress-retry.test.ts index 2df8a51..d704c75 100644 --- a/tests/compress-retry.test.ts +++ b/tests/compress-retry.test.ts @@ -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 @@ -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 { @@ -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 }); +});