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
16 changes: 16 additions & 0 deletions src/compress-loop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Independent stop-signal injected as a user-role message once a compress loop
// is confirmed within the current user turn (COMPRESS_LOOP_CORRECT_THRESHOLD+
// failed/no-op compress calls without progress). It is appended per context event
// and NOT persisted to the session log, so it self-clears when the turn changes.
// The #308/#6/#250 breakers stop the TOOL from doing damage but cannot stop the
// MODEL from generating another ~10K-token repetitive compress turn; only an
// input-side counter-signal breaks the semantic attractor (issue #330). Follows
// the provider-throttle sentinel pattern (throttle-retry.ts) so system-prompt.ts
// documents how to interpret it.
export const COMPRESS_LOOP_SENTINEL = "[ACP:compress-loop]";

export const COMPRESS_LOOP_CORRECT_THRESHOLD = 2;

export function buildCompressLoopText(failures: number): string {
return `${COMPRESS_LOOP_SENTINEL} You have issued ${failures} compress calls this turn without making progress (identical or already-compressed ranges). STOP calling compress now. Continue your actual task using the context you already have — compression is paused until your next user request.`;
}
2 changes: 1 addition & 1 deletion src/compress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ function cappedRejectionText(snapshot: string): string {
"Current compressible ranges (use these refs exactly as listed):",
snapshot,
"",
"Continue the task; compress becomes available again on the next user message.",
"Continue the task WITHOUT compressing. If none of the ranges above fit, call acp_status for the full picture. Compress becomes available again on the next user message.",
].join("\n");
}

Expand Down
19 changes: 17 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
throttleDelayMs,
} from "./throttle-retry.js";
import { defaultCountTokens } from "acp-kernel";
import { COMPRESS_LOOP_CORRECT_THRESHOLD, buildCompressLoopText } from "./compress-loop.js";
import { formatSystemPromptForEvent, getSystemPromptText } from "./compat.js";
import { applyOutputHeadroom, inspectOverflowMessage } from "./overflow-selfheal.js";
import { isOmpHost, OMP_UNSUPPORTED_MESSAGE } from "./omp.js";
Expand Down Expand Up @@ -412,6 +413,11 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
// must lift the cap on this same fire).
const compressOutcomes = collectCompressOutcomes(entries, turnStartIndex(entries));
const outcome = compressOutcomes.length > 0 ? runtime.noteCompressOutcomes(turnKey, compressOutcomes) : null;
// Failed/no-op compress attempts counted so far THIS user turn (0 when the
// key doesn't match the tracked turn). Drives both nudge suppression and the
// independent stop-signal below. Read AFTER noteCompressOutcomes above so it
// reflects the newest outcome on this same fire.
const compressFails = runtime.compressFailCountFor(turnKey);

// Growth-aware re-inject bookkeeping (issue #269) runs on EVERY context
// event, not only when the kernel wants to inject: the drop re-anchor
Expand Down Expand Up @@ -475,9 +481,12 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
// keeps usage pinned at emergency). Once this turn burned
// MAX_COMPRESS_ATTEMPTS attempts, stop re-injecting the nudge — the
// kernel's emergency truncation still shrinks context mechanically.
const retryCapped = runtime.compressRetryCappedFor(turnKey);
// Nudge suppression engages on the FIRST failed/no-op compress attempt this
// turn, not only at the MAX_COMPRESS_ATTEMPTS cap: once the model has chased a
// failing compression, re-pushing "compress more" reinforces the loop instead of
// helping — the failure toolResult already carries actionable refs (#330).
const reInjectReady = shownAt === undefined || tokenCount - shownAt >= reInjectFloor;
const alreadyShown = retryCapped || (!emergency && runtime.nudgeShownFor(turnKey) && !reInjectReady);
const alreadyShown = compressFails >= 1 || (!emergency && runtime.nudgeShownFor(turnKey) && !reInjectReady);
if (!alreadyShown) {
rebuilt.push(nudgeMessage(turn.nudge, turn.state.blocks.filter((b) => b.active), runtime.prompts));
const rendered = renderNudgeText(turn.nudge, runtime.prompts);
Expand All @@ -504,6 +513,12 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
}
}

if (compressFails >= COMPRESS_LOOP_CORRECT_THRESHOLD) {
rebuilt.push({ role: "user", content: [{ type: "text", text: buildCompressLoopText(compressFails) }], timestamp: Date.now() } as AgentMessage);
logWarn("nudge", { sid, event: "compress-loop-correction", failures: compressFails });
debug.event("compress-loop-correction", { sid, turnKey, failures: compressFails });
}

// Always return the transformed array: every message needs its [mNNNNN] ref
// tag applied, so there is no meaningful "no change" case to short-circuit.
debug.event("context-out", { outMsgs: rebuilt.length, injected: turn.nudge?.shouldInject ?? false, emergency: turn.nudge?.breakdown?.emergencyOverride === 1 });
Expand Down
21 changes: 20 additions & 1 deletion src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type AnyMessage = {
command?: string;
output?: unknown;
summary?: string;
stopReason?: string;
};

const REF_TAG_SOURCE = "(?:\x3cacp\\s[^>]*\x3em\\d{5}\x3c/acp\x3e|\\[m\\d{1,5}\\])";
Expand All @@ -22,6 +23,19 @@ const TRAILING_REF_TAG = new RegExp(`\\n*${REF_TAG_SOURCE}\\s*$`);
// session, but never projected into the sent view.
export const ACP_STATUS_CUSTOM_TYPE = "acp-status";

// An assistant turn aborted or errored by the user never ran its tools, so its
// tool_calls blocks have no matching tool_result. Sending them is an invalid
// sequence for OpenAI-compatible providers (openai-completions 400s on
// tool_calls with no following tool message) and is the hook that drags the model
// back into re-issuing the abandoned call (issue #330). Keying off stopReason —
// not a "missing toolResult" scan — is deliberate: OMP execution roles and
// evicted/undo fixtures carry no stopReason, so a result-presence scan would
// false-positive on their (legitimately paired) tool calls.
const INTERRUPTED_STOP_REASONS = new Set(["aborted", "error"]);
function wasInterrupted(msg: AnyMessage): boolean {
return typeof msg.stopReason === "string" && INTERRUPTED_STOP_REASONS.has(msg.stopReason);
}

export function entriesToCoreMessages(entries: SessionEntry[]): CoreMessage[] {
const out: CoreMessage[] = [];
for (const entry of entries) {
Expand Down Expand Up @@ -59,7 +73,12 @@ function projectMessage(message: AgentMessage, id: string): CoreMessage[] {
}];
}
if (role === "assistant") {
const calls = allToolCalls(msg.content);
let calls = allToolCalls(msg.content);
// Interrupted turn → its tools never ran → drop the unmatched tool_calls so
// the sent view carries no dangling tool_use (see wasInterrupted). If the
// dropped call was the only content, this falls through to the text path
// below, which drops the turn too when there is no visible text.
if (wasInterrupted(msg)) calls = [];
if (calls.length > 0) {
const textParts = extractText(msg.content);
if (calls.length === 1) {
Expand Down
11 changes: 10 additions & 1 deletion src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ export interface AcpRuntime {
* 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;
/** Failed/no-op compress calls counted for this turn, or 0 when turnKey does
* not match the currently tracked turn. Distinguishes "no failure yet" from
* "hard-capped" so nudge suppression can engage on the first failed attempt
* rather than only at MAX_COMPRESS_ATTEMPTS (issue #330). */
compressFailCountFor(turnKey: string): number;
clearNudgeTracking(): void;
clearCompressRetryTracking(): void;
liveContextLimit(ctx: ExtensionContext): number;
Expand Down Expand Up @@ -354,6 +359,10 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
return compressFailTurnKey === turnKey && compressFailCount >= MAX_COMPRESS_ATTEMPTS;
}

function compressFailCountFor(turnKey: string): number {
return compressFailTurnKey === turnKey ? compressFailCount : 0;
}

function clearCompressRetryTracking(): void {
compressOutcomeSeen.clear();
compressFailTurnKey = null;
Expand Down Expand Up @@ -457,4 +466,4 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {

let refused = false;
let refusalMessage: string | null = null;
return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, clearNudgeTokenStamps: () => nudgeShownTokens.clear(), noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };}
return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, clearNudgeTokenStamps: () => nudgeShownTokens.clear(), noteCompressOutcomes, compressRetryCappedFor, compressFailCountFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };}
4 changes: 4 additions & 0 deletions src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ PROVIDER THROTTLE RETRY

A provider rate-limit error (e.g. "Too many tokens, please wait before trying again.") may appear as a failed assistant response followed by a [ACP:provider-throttle] note. The interruption was transient and the system is retrying automatically. After such an interruption, resume the interrupted step exactly where it left off: do not re-run completed steps, do not re-read content already in context, and do not discuss the interruption unless asked.
Retries are capped; when the cap is reached the error is surfaced to the user unchanged. If the user sends new input during a retry wait, the retry is cancelled.

COMPRESS LOOP GUARD

If you see a note beginning with [ACP:compress-loop], you have been repeatedly issuing compress calls this turn without making progress (identical or already-compressed ranges). STOP calling compress immediately and do not try to "fix" it by re-issuing another compress call — that is exactly what is looping. Continue your actual task using the context you already have. Compressing becomes available again on the next user message.
`;
}

Expand Down
19 changes: 19 additions & 0 deletions tests/compress-loop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { COMPRESS_LOOP_SENTINEL, COMPRESS_LOOP_CORRECT_THRESHOLD, buildCompressLoopText } from "../src/compress-loop.js";

test("COMPRESS_LOOP_SENTINEL matches the sentinel documented in the system prompt — issue #330", () => {
assert.equal(COMPRESS_LOOP_SENTINEL, "[ACP:compress-loop]");
});

test("correction threshold fires on 2 failed compress calls within one turn — issue #330", () => {
assert.equal(COMPRESS_LOOP_CORRECT_THRESHOLD, 2);
});

test("buildCompressLoopText carries the sentinel, the count, and an explicit stop instruction", () => {
const text = buildCompressLoopText(2);
assert.ok(text.startsWith(COMPRESS_LOOP_SENTINEL), "sentinel first so system-prompt rules can key off it");
assert.ok(text.includes("2"), "embeds the failure count");
assert.match(text, /STOP calling compress/, "explicit stop instruction");
assert.match(text, /paused until your next user request/, "states when it self-clears");
});
67 changes: 67 additions & 0 deletions tests/compress-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,70 @@ test("emergency nudge stops re-injecting once the turn's cap is burned (issue #6
assert.ok(nudgeCount(rRe) >= 1, "after a successful compress the emergency nudge may resume");
await rm(`${stateFile}.acp.json`, { force: true });
});

// ─── issue #330: semantic-level loop breaker ────────────────────────────────
//
// The #308/#6/#250 breakers stop the TOOL from doing damage but cannot stop the
// MODEL from generating another ~10K-token repetitive compress turn under a
// low-temp attractor. Two input-side additions close that gap:
// 1. nudge suppression starts at the FIRST failure (not the MAX_COMPRESS_ATTEMPTS
// cap) — re-pushing "compress more" reinforces the loop; the failure
// toolResult already carries actionable refs.
// 2. once in-turn failures reach COMPRESS_LOOP_CORRECT_THRESHOLD (2), an
// independent [ACP:compress-loop] user-role stop-signal is injected.
// Both are driven by runtime.compressFailCountFor(turnKey).

test("compressFailCountFor: reports in-turn failures, 0 for other turns, resets on success/new turn (#330)", () => {
const rt = createRuntime({});
const fail = (id: string) => ({ toolCallId: id, isError: true, success: false });
const noop = (id: string) => ({ toolCallId: id, isError: false, success: false, noop: true });
const success = (id: string) => ({ toolCallId: id, isError: false, success: true });

assert.equal(rt.compressFailCountFor("u1"), 0, "no outcomes yet → 0");
rt.noteCompressOutcomes("u1", [fail("t0")]);
assert.equal(rt.compressFailCountFor("u1"), 1);
assert.equal(rt.compressFailCountFor("other"), 0, "unrelated turn → 0");
rt.noteCompressOutcomes("u1", [fail("t0"), noop("n0")]);
assert.equal(rt.compressFailCountFor("u1"), 2, "error + no-op both advance the loop counter");
rt.noteCompressOutcomes("u1", [fail("t0"), noop("n0"), success("s0")]);
assert.equal(rt.compressFailCountFor("u1"), 0, "genuine success resets → suppression/correction clear");
rt.noteCompressOutcomes("u2", [fail("a")]);
assert.equal(rt.compressFailCountFor("u2"), 1);
assert.equal(rt.compressFailCountFor("u1"), 0, "a new turn's failure does not leak into an old turn");
});

test("loop correction: [ACP:compress-loop] injected exactly once per event at ≥2 in-turn failures, self-clears on new turn (#330)", async () => {
const { api, handlers } = captureApi();
createAcpExtension({ modelContextLimit: 200_000 })(api as any);
const stateFile = "/tmp/pai-acp-loop-correct.session.json";
await rm(`${stateFile}.acp.json`, { force: true });

const loopMsgs = (r: any) =>
(r?.messages ?? []).filter((m: any) => m.role === "user" && /\[ACP:compress-loop\]/.test(JSON.stringify(m.content)));

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

// first failed compress → count 1 → below threshold → no correction yet
entries = [...entries, toolResultMsg("e2", "call_1", VALIDATION_ERR, true)];
const r1 = await fire(handlers, ctx);
assert.equal(loopMsgs(r1).length, 0, "1 failure < threshold 2 → no correction");

// second failed compress (no-op panel) → count 2 → correction fires
entries = [...entries, toolResultMsg("e3", "call_2", NOOP_PANEL, false)];
const r2 = await fire(handlers, ctx);
assert.equal(loopMsgs(r2).length, 1, "2 failures ≥ threshold 2 → one correction injected");
assert.match(JSON.stringify(loopMsgs(r2)[0]!.content), /STOP calling compress/, "carries an explicit stop instruction");

// third failure still yields exactly ONE correction per event (not cumulative)
entries = [...entries, toolResultMsg("e4", "call_3", VALIDATION_ERR, true)];
const r3 = await fire(handlers, ctx);
assert.equal(loopMsgs(r3).length, 1, "still exactly one correction per context event");

// new user turn → fresh budget → the stale correction does not linger
entries = [...entries, userMsg("e5", "now do something else")];
const r4 = await fire(handlers, ctx);
assert.equal(loopMsgs(r4).length, 0, "new turn resets the loop counter → no stale correction");
await rm(`${stateFile}.acp.json`, { force: true });
});
Loading
Loading