From 426e21580af716fdaae2663bc9809c9e662b8010 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Sat, 22 Aug 2026 07:51:31 +0800 Subject: [PATCH 1/2] Break the compress fixed-point loop (issue #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session 01a02542 re-issued a byte-identical no-op compress 3,849 times (5h13m): the kernel hides failed compress calls from the sent view, so at cap the visible context stops changing and a deterministic model can never observe its failure. Two defenses: - Tool-side circuit breaker: once the turn burned MAX_COMPRESS_ATTEMPTS failed/no-op attempts, re-submitting an exact already-failed range set is refused without executing anything (no stateFor/processTurn/save). - Post-cap STOP message: while the newest outcome is a failure, every context fire re-injects a count-bearing stop instruction — visible in headless mode and varying per new failure, so the sent view never sits at a fixed point. Failed outcomes now carry their call's parsed ranges (from the assistant toolCall block) to key the breaker. --- src/compress-tool.ts | 21 +++- src/index.ts | 57 ++++++++-- src/runtime.ts | 44 +++++++- tests/compress-breaker.test.ts | 192 +++++++++++++++++++++++++++++++++ 4 files changed, 298 insertions(+), 16 deletions(-) create mode 100644 tests/compress-breaker.test.ts diff --git a/src/compress-tool.ts b/src/compress-tool.ts index 43900ac..a1c2ee2 100644 --- a/src/compress-tool.ts +++ b/src/compress-tool.ts @@ -4,9 +4,9 @@ import type { ExtensionContext, ToolDefinition, } from "@earendil-works/pi-coding-agent"; -import type { AcpRuntime } from "./runtime.js"; +import { readContextEntries, type AcpRuntime } from "./runtime.js"; import { debug, logError, logInfo, logThrow, logWarn } from "./log.js"; -import { estimateTokens, collectCoveredMessageIds, calibrateTokens } from "./tokens.js"; +import { estimateTokens, collectCoveredMessageIds, calibrateTokens, lastUserMessageId } from "./tokens.js"; import { defaultCountTokens, type CompressionBlock } from "acp-kernel"; import { getSystemPromptText } from "./compat.js"; @@ -72,7 +72,7 @@ type RangeEntry = Static; // handleCompress THROWS it so pi marks the toolResult isError:true and the // retry nudge (src/index.ts) can quote it back (returning it normally would // produce isError:false, which both skips the nudge and resets the counter). -function normalizeRanges(content: CompressArgs["content"]): RangeEntry[] | string { +export function normalizeRanges(content: unknown): RangeEntry[] | string { let ranges: unknown = content ?? []; if (typeof ranges === "string") { try { @@ -142,6 +142,21 @@ async function handleCompress(args: CompressArgs, runtime: AcpRuntime, ctx: Exte if (typeof maybeRanges === "string") throw new Error(maybeRanges); const ranges = maybeRanges; if (ranges.length === 0) return "No ranges provided."; + // Circuit breaker (issue #9): refuse an exact re-submission of an + // already-failed range set once this turn burned its failed-attempt cap — + // without this guard a deterministic model re-issued the byte-identical + // no-op call 3,849 times because the kernel hides failed calls from the + // sent view, pinning it at a fixed point. + const breakerEntries = readContextEntries(ctx.sessionManager); + const breakerTurnKey = lastUserMessageId(breakerEntries) ?? ctx.sessionManager.getSessionId(); + if (runtime.compressSpecBlocked(breakerTurnKey, ranges)) { + const spans = ranges.map((r) => `${r.startId}..${r.endId}`).join(", "); + const fails = runtime.compressFailCountFor(breakerTurnKey); + logWarn("compress", { sid: ctx.sessionManager.getSessionId(), event: "compress-breaker-refused", turnKey: breakerTurnKey, spans, fails }); + throw new Error( + `Compress circuit breaker OPEN: ${fails} failed/no-op compress calls this turn and these exact ranges (${spans}) already failed. Nothing was executed. Do NOT call compress again with these ranges — the messages are already covered by an existing block. STOP calling compress for the rest of this turn and proceed with your actual task now; compression re-enables on the next user message. Use acp_status only after resuming real work if you need current compressible ranges.`, + ); + } const { state: initialState, coreMessages } = await runtime.stateFor(ctx); const config = runtime.configFor(ctx); // Sent-view arbitration — the same scale as the context transform and diff --git a/src/index.ts b/src/index.ts index fd3b872..5d7a005 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, normalizeRanges } from "./compress-tool.js"; import { makeDecompressTool } from "./decompress-tool.js"; import { makeSearchTool } from "./search-tool.js"; import { makeStatusTool } from "./status-tool.js"; @@ -345,11 +345,26 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime): void { 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.`); + } 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.`); + } + } + // Post-cap STOP message (issue #9): the kernel HIDES failed compress + // calls from the sent view, so once the cap burns and the retry nudge + // goes silent the visible context stops changing — a deterministic + // model then re-issues the identical no-op call forever (3,849 + // iterations, 5h13m). Keep injecting a hard stop while the newest + // outcome is still a failure; the count changes with every new + // failure, so the view never sits at a fixed point. + const latestOutcome = compressOutcomes[compressOutcomes.length - 1]; + if (outcome.count >= MAX_COMPRESS_ATTEMPTS && latestOutcome && (latestOutcome.isError || latestOutcome.noop)) { + rebuilt.push(compressStopMessage(outcome.count)); + logWarn("nudge", { sid, event: "compress-stop-inject", failures: outcome.count }); + debug.event("compress-stop-injected", { sid, turnKey, failures: outcome.count }); } } } @@ -530,15 +545,30 @@ 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; ranges?: Array<{ startId: string; endId: string }>; text: string }> { + // Failed outcomes carry the exact range set of their compress call (parsed + // from the assistant toolCall block) so the tool-side circuit breaker + // (compressSpecBlocked) can refuse a byte-identical re-submission (issue #9). + const callContentById = new Map(); + for (const entry of entries) { + if (entry.type !== "message" || !entry.message) continue; + const m = entry.message as { role?: string; content?: unknown }; + if (m.role !== "assistant" || !Array.isArray(m.content)) continue; + for (const block of m.content) { + const b = block as { type?: string; name?: string; id?: string; arguments?: { content?: unknown } }; + if (b.type === "toolCall" && b.name === "compress" && b.id) callContentById.set(b.id, b.arguments?.content); + } + } + const out: Array<{ toolCallId: string; isError: boolean; success: boolean; noop: boolean; ranges?: Array<{ startId: string; endId: string }>; 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 }); + const parsed = normalizeRanges(callContentById.get(m.toolCallId)); + const ranges = Array.isArray(parsed) && parsed.length > 0 ? parsed.map((r) => ({ startId: r.startId, endId: r.endId })) : undefined; + out.push({ toolCallId: m.toolCallId, isError: m.isError === true, success: m.isError !== true && isCompressSuccessText(text), noop: m.isError !== true && isCompressNoopText(text), ranges, text }); } return out; } @@ -563,6 +593,15 @@ function compressRetryMessage(errorText: string, attempt: number, maxAttempts: n return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() } as AgentMessage; } +function compressStopMessage(failCount: number): AgentMessage { + const text = [ + `[ACP] Compress circuit breaker OPEN — ${failCount} failed/no-op compress calls this turn (cap ${MAX_COMPRESS_ATTEMPTS}).`, + "Repeat attempts with the same ranges are refused without executing.", + "STOP calling compress for the rest of this turn. Proceed with your actual task now — compression re-enables on the next user message.", + ].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..12a97f1 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -74,12 +74,21 @@ export interface AcpRuntime { * 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 }; + * when none, capped, or count 0), and whether the cap was just reached. + * Failed outcomes may carry the parsed ranges of their compress call — + * recorded so the tool-side breaker (compressSpecBlocked) can refuse an + * exact re-submission of an already-failed range set (issue #9). */ + noteCompressOutcomes(turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean; ranges?: ReadonlyArray<{ startId: string; endId: string }> }>): { count: number; retryFor: 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). */ compressRetryCappedFor(turnKey: string): boolean; + /** True when the EXACT range set already failed this turn AND the turn's + * retry cap is burned — the compress tool refuses such calls without + * executing anything (issue #9 fixed-point loop). */ + compressSpecBlocked(turnKey: string, ranges: ReadonlyArray<{ startId: string; endId: string }>): boolean; + /** Failed/no-op compress count for the turn (0 when unknown). */ + compressFailCountFor(turnKey: string): number; clearNudgeTracking(): void; clearCompressRetryTracking(): void; liveContextLimit(ctx: ExtensionContext): number; @@ -289,9 +298,15 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { const compressOutcomeSeen = new Set(); let compressFailTurnKey: string | null = null; let compressFailCount = 0; + const compressFailSpecs = new Map>(); - function noteCompressOutcomes(turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean }>): { count: number; retryFor: string | null; cappedNow: boolean } { + function rangeSpecKey(ranges: ReadonlyArray<{ startId: string; endId: string }>): string { + return JSON.stringify(ranges.map((r) => [r.startId, r.endId])); + } + + function noteCompressOutcomes(turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean; ranges?: ReadonlyArray<{ startId: string; endId: string }> }>): { count: number; retryFor: string | null; cappedNow: boolean } { if (compressFailTurnKey !== turnKey) { + if (compressFailTurnKey !== null) compressFailSpecs.delete(compressFailTurnKey); compressFailTurnKey = turnKey; compressFailCount = 0; } @@ -301,8 +316,17 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { compressOutcomeSeen.add(o.toolCallId); if (o.isError || o.noop === true) { compressFailCount += 1; + if (o.ranges && o.ranges.length > 0) { + let specs = compressFailSpecs.get(turnKey); + if (!specs) { + specs = new Set(); + compressFailSpecs.set(turnKey, specs); + } + specs.add(rangeSpecKey(o.ranges)); + } } else if (o.success) { compressFailCount = 0; + compressFailSpecs.delete(turnKey); } // neutral: counter untouched } @@ -319,10 +343,22 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { return compressFailTurnKey === turnKey && compressFailCount >= MAX_COMPRESS_ATTEMPTS; } + function compressSpecBlocked(turnKey: string, ranges: ReadonlyArray<{ startId: string; endId: string }>): boolean { + if (!compressRetryCappedFor(turnKey)) return false; + const specs = compressFailSpecs.get(turnKey); + if (!specs || ranges.length === 0) return false; + return specs.has(rangeSpecKey(ranges)); + } + + function compressFailCountFor(turnKey: string): number { + return compressFailTurnKey === turnKey ? compressFailCount : 0; + } + function clearCompressRetryTracking(): void { compressOutcomeSeen.clear(); compressFailTurnKey = null; compressFailCount = 0; + compressFailSpecs.clear(); } async function acquireLock(sid: string): Promise<() => void> { @@ -410,4 +446,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), clearNudgeTracking: () => { nudgeShownTurns.clear(); }, noteCompressOutcomes, compressRetryCappedFor, compressSpecBlocked, compressFailCountFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, throttleFor, throttleDrop };} diff --git a/tests/compress-breaker.test.ts b/tests/compress-breaker.test.ts new file mode 100644 index 0000000..00628ec --- /dev/null +++ b/tests/compress-breaker.test.ts @@ -0,0 +1,192 @@ +import { test } from "node:test"; +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"; + +// Issue #9 post-mortem (session 01a02542): after a successful compress pinned +// context at 22.6K, the model re-issued the BYTE-IDENTICAL no-op compress call +// 3,849 times over 5h13m. The kernel hides failed compress calls from the sent +// view (KEEP_LAST_ORPHANED=0), so the visible context reached a fixed point the +// deterministic model could never observe — each iteration ran the full +// stateFor/processTurn/save pipeline for nothing. +// +// Fixes under test: +// 1. Tool-side circuit breaker: once the turn burned MAX_COMPRESS_ATTEMPTS +// failed/no-op attempts, re-submitting an EXACT already-failed range set is +// refused without executing anything. +// 2. Post-cap STOP message: while the newest outcome is still a failure, every +// context fire re-injects a count-bearing stop instruction — visible in +// headless mode and varying per new failure, so the sent view never sits at +// a fixed point again. +// 3. Failure outcomes carry the parsed ranges of their compress call (from +// the assistant toolCall block), keyed by toolCallId. + +function captureApi() { + const handlers = new Map any)[]>(); + const api = { + on(event: string, handler: (e: any, ctx: any) => any) { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + tools: [] as any[], + commands: new Map(), + registerTool(tool: any) { this.tools.push(tool); }, + registerCommand(name: string, options: any) { this.commands.set(name, options); }, + }; + return { api, handlers }; +} + +function userMsg(id: string, text: string) { + return { type: "message", id, parentId: null, timestamp: "", message: { role: "user", content: text, timestamp: Date.now() } }; +} + +function compressCallMsg(id: string, toolCallId: string, ranges: Array<{ startId: string; endId: string }>) { + return { + type: "message", id, parentId: null, timestamp: "", + message: { + role: "assistant", + content: [{ + type: "toolCall", id: toolCallId, name: "compress", + arguments: { content: ranges.map((r) => ({ startId: r.startId, endId: r.endId, summary: "s" })) }, + }], + timestamp: Date.now(), + }, + }; +} + +function toolResultMsg(id: string, toolCallId: string, text: string, isError: boolean) { + return { + type: "message", id, parentId: null, timestamp: "", + message: { + role: "toolResult", toolCallId, toolName: "compress", + content: [{ type: "text", text }], isError, timestamp: Date.now(), + }, + }; +} + +const NOOP_PANEL = "▣ ACP | 58.5K → 58.5K tokens (~0 reclaimed, 0 blocks)\nErrors: range m00001..m00001: Requested range(s) already compressed; nothing to compress"; +const SUCCESS_PANEL = "▣ ACP | 58.5K → 5.7K tokens (~52.8K reclaimed, 4 blocks)"; + +function fakeCtx(getEntries: () => any[], stateFile: string) { + return { + mode: "rpc", + hasUI: false, + ui: { notify: () => {}, confirm: async () => true, select: async () => undefined, input: async () => "", setStatus: () => {} }, + model: { contextWindow: 200_000, id: "test-model" }, + getContextUsage: () => null, + sessionManager: { + buildContextEntries: () => getEntries(), + getSessionId: () => "breaker-test-session", + getSessionFile: () => stateFile, + }, + }; +} + +const fire = (handlers: Map any)[]>, ctx: any) => + handlers.get("context")![0]!({ type: "context", messages: [] }, ctx); + +const stopMsgs = (r: any) => + (r?.messages ?? []).filter((m: any) => m.role === "user" && /Compress circuit breaker OPEN/.test(JSON.stringify(m.content))); + +const retryMsgs = (r: any) => + (r?.messages ?? []).filter((m: any) => m.role === "user" && /compress call FAILED/.test(JSON.stringify(m.content))); + +const ZH = "中".repeat(6000); + +// ─── unit: spec-keyed breaker state ───────────────────────────────────────── + +test("compressSpecBlocked: exact re-submission refused only after cap, different ranges pass, resets", () => { + const rt = createRuntime({}); + const R1 = [{ startId: "m00748", endId: "m00771" }]; + const R2 = [{ startId: "m00900", endId: "m00910" }]; + const fail = (id: string, ranges: typeof R1) => ({ toolCallId: id, isError: true, success: false, noop: false, ranges }); + + for (let i = 0; i < MAX_COMPRESS_ATTEMPTS; i++) { + rt.noteCompressOutcomes("u1", [fail(`t${i}`, R1)]); + } + assert.equal(rt.compressFailCountFor("u1"), MAX_COMPRESS_ATTEMPTS); + assert.equal(rt.compressSpecBlocked("u1", R1), true, "exact re-submission of a failed range set is refused"); + assert.equal(rt.compressSpecBlocked("u1", R2), false, "a NEW range set is still allowed at cap"); + assert.equal(rt.compressSpecBlocked("u2", R1), false, "other turns are unaffected"); + + rt.noteCompressOutcomes("u1", [{ toolCallId: "ts", isError: false, success: true, noop: false }]); + assert.equal(rt.compressSpecBlocked("u1", R1), false, "success lifts the cap and clears recorded specs"); + + rt.noteCompressOutcomes("u2", [fail("x0", R1)]); + assert.equal(rt.compressSpecBlocked("u2", R1), false, "below cap the breaker stays closed"); +}); + +// ─── integration: tool refuses the 4th identical no-op ────────────────────── + +test("the compress tool refuses a byte-identical re-submission after the cap burned (issue #9)", async () => { + const { api, handlers } = captureApi(); + createAcpExtension({ modelContextLimit: 200_000 })(api as any); + const stateFile = "/tmp/pai-acp-breaker-tool.session.json"; + await rm(`${stateFile}.acp.json`, { force: true }); + + const R1 = [{ startId: "m00001", endId: "m00001" }]; + let entries: any[] = [userMsg("e1", ZH)]; + const ctx = fakeCtx(() => entries, stateFile); + await fire(handlers, ctx); // assigns refs, fresh state + + for (let i = 1; i <= MAX_COMPRESS_ATTEMPTS; i++) { + entries = [...entries, compressCallMsg(`ea${i}`, `call_${i}`, R1), toolResultMsg(`er${i}`, `call_${i}`, NOOP_PANEL, false)]; + } + const rCap = await fire(handlers, ctx); + assert.equal(retryMsgs(rCap).length, 0, "retry prompts capped"); + + const compressTool = api.tools.find((t: any) => t.name === "compress")!; + await assert.rejects( + () => compressTool.execute("call_4", { content: R1.map((r) => ({ ...r, summary: "identical retry" })) }, undefined, undefined, ctx), + /Compress circuit breaker OPEN[\s\S]*m00001\.\.m00001[\s\S]*already failed/, + "4th identical call is refused without executing", + ); + + const out = await compressTool.execute( + "call_5", + { content: [{ startId: "m00002", endId: "m00002", summary: "different ranges still run" }] }, + undefined, undefined, ctx, + ); + const text = typeof out === "string" ? out : out.content?.[0]?.text ?? String(out); + assert.ok(!/circuit breaker/.test(text), `different ranges must not hit the breaker: ${text}`); + await rm(`${stateFile}.acp.json`, { force: true }); +}); + +// ─── integration: post-cap STOP message ───────────────────────────────────── + +test("post-cap STOP message re-injects with rising count until a success (breaks the fixed point)", async () => { + const { api, handlers } = captureApi(); + createAcpExtension({ modelContextLimit: 200_000 })(api as any); + const stateFile = "/tmp/pai-acp-breaker-stop.session.json"; + await rm(`${stateFile}.acp.json`, { force: true }); + + const R1 = [{ startId: "m00001", endId: "m00001" }]; + let entries: any[] = [userMsg("e1", ZH)]; + const ctx = fakeCtx(() => entries, stateFile); + await fire(handlers, ctx); + + entries = [...entries, compressCallMsg("ea1", "call_1", R1), toolResultMsg("er1", "call_1", NOOP_PANEL, false)]; + const r1 = await fire(handlers, ctx); + assert.equal(retryMsgs(r1).length, 1, "first no-op → corrective retry nudge"); + assert.equal(stopMsgs(r1).length, 0, "no STOP message below cap"); + + entries = [...entries, compressCallMsg("ea2", "call_2", R1), toolResultMsg("er2", "call_2", NOOP_PANEL, false)]; + entries = [...entries, compressCallMsg("ea3", "call_3", R1), toolResultMsg("er3", "call_3", NOOP_PANEL, false)]; + const r3 = await fire(handlers, ctx); + assert.equal(stopMsgs(r3).length, 1, "cap burned → STOP message injected"); + assert.match(JSON.stringify(stopMsgs(r3)[0].content), /3 failed\/no-op compress calls/); + + const rAgain = await fire(handlers, ctx); + assert.equal(stopMsgs(rAgain).length, 1, "STOP message re-injects on every fire while unaddressed (no fixed point)"); + + entries = [...entries, compressCallMsg("ea4", "call_4", R1), toolResultMsg("er4", "call_4", NOOP_PANEL, false)]; + const r4 = await fire(handlers, ctx); + assert.match(JSON.stringify(stopMsgs(r4)[0].content), /4 failed\/no-op compress calls/, "count rises per new failure"); + + entries = [...entries, toolResultMsg("er5", "call_5", SUCCESS_PANEL, false)]; + const r5 = await fire(handlers, ctx); + assert.equal(stopMsgs(r5).length, 0, "a genuine success closes the breaker"); + await rm(`${stateFile}.acp.json`, { force: true }); +}); From af34fab8c213ae76baee10c828879afaba1f22d7 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Sat, 22 Aug 2026 09:01:57 +0800 Subject: [PATCH 2/2] release v0.1.46 --- package-lock.json | 12 ++++++------ package.json | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index ce684aa..890f434 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "billion-context-pi", - "version": "0.1.44", + "version": "0.1.46", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "billion-context-pi", - "version": "0.1.44", + "version": "0.1.46", "license": "MIT", "devDependencies": { "@earendil-works/pi-coding-agent": "0.83.0", "@types/node": "^26.1.2", - "acp-kernel": "0.0.30", + "acp-kernel": "0.0.32", "billion-context-kit": "0.2.0", "tsup": "^8.5.1", "tsx": "^4.23.1", @@ -3186,9 +3186,9 @@ } }, "node_modules/acp-kernel": { - "version": "0.0.30", - "resolved": "https://registry.npmjs.org/acp-kernel/-/acp-kernel-0.0.30.tgz", - "integrity": "sha512-eE9F7sDHxbylQnRG9CIRxObKwNWKnEbPdpc9YjWaY8nqalMIc0w+RcMg9kDoNfpjOhMYaYGykAffk0QC1N+CJA==", + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/acp-kernel/-/acp-kernel-0.0.32.tgz", + "integrity": "sha512-iOJPF+X6NMGkpGsAbxHV0Fcvymzf9a0c5Mf/z3padNVgPgMNxwG1snxYravVccRePgHOzWgpNYikqtpGYhiInA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index a98e4f3..9f7e982 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "billion-context-pi", - "version": "0.1.44", + "version": "0.1.46", "description": "One billion, not one million. Model-driven context management for the Pi coding agent.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -60,7 +60,7 @@ "devDependencies": { "@earendil-works/pi-coding-agent": "0.83.0", "@types/node": "^26.1.2", - "acp-kernel": "0.0.30", + "acp-kernel": "0.0.32", "billion-context-kit": "0.2.0", "tsup": "^8.5.1", "tsx": "^4.23.1",