Skip to content
Closed
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
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
21 changes: 18 additions & 3 deletions src/compress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -72,7 +72,7 @@ type RangeEntry = Static<typeof RangeSpec>;
// 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 {
Expand Down Expand Up @@ -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
Expand Down
57 changes: 48 additions & 9 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, normalizeRanges } 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 @@ -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 });
}
}
}
Expand Down Expand Up @@ -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<string, unknown>();
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;
}
Expand All @@ -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];
Expand Down
44 changes: 40 additions & 4 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -289,9 +298,15 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
const compressOutcomeSeen = new Set<string>();
let compressFailTurnKey: string | null = null;
let compressFailCount = 0;
const compressFailSpecs = new Map<string, Set<string>>();

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;
}
Expand All @@ -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<string>();
compressFailSpecs.set(turnKey, specs);
}
specs.add(rangeSpecKey(o.ranges));
}
} else if (o.success) {
compressFailCount = 0;
compressFailSpecs.delete(turnKey);
}
// neutral: counter untouched
}
Expand All @@ -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> {
Expand Down Expand Up @@ -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 };}
Loading
Loading