Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/compress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ async function handleCompress(args: CompressArgs, runtime: AcpRuntime, ctx: Exte
});
const turnKey = lastUserMessageId(entries) ?? sid;
const snapshot = compressibleSnapshotText(turn.nudge);
if (runtime.compressRetryCappedFor(turnKey)) {
if (runtime.compressRetryCappedFor(sid, turnKey)) {
logWarn("compress", { sid, event: "capped-reject", turnKey });
return cappedRejectionText(snapshot);
}
Expand Down
25 changes: 14 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,9 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
}
if (standDownIfProxied(ctx)) return;
runtime.store.invalidate();
runtime.clearNudgeTracking();
runtime.clearNudgeTracking(ctx.sessionManager.getSessionId());
runtime.throttleFor(ctx.sessionManager.getSessionId()).reset();
runtime.clearCompressRetryTracking();
runtime.clearCompressRetryTracking(ctx.sessionManager.getSessionId());
resetDelegateUsage();
setDelegateDisplayUsage("separate");
setDelegatePolicy(DEFAULT_DELEGATE_POLICY);
Expand Down Expand Up @@ -233,8 +233,11 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
delegateStatusWidget.setContext(ctx, runningRunsSnapshot);
});
pi.on("session_shutdown", (_event, ctx) => {
runtime.clearDeadCompress(ctx.sessionManager.getSessionId());
runtime.dropTokenScale(ctx.sessionManager.getSessionId());
const sid = ctx.sessionManager.getSessionId();
runtime.clearDeadCompress(sid);
runtime.dropTokenScale(sid);
runtime.clearNudgeTracking(sid);
runtime.clearCompressRetryTracking(sid);
delegateStatusWidget.dispose();
closeLogStream();
});
Expand Down Expand Up @@ -353,7 +356,7 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
state.nudge.lastNudgeShownTokens = 0;
state.nudge.lastPerMessageNudgeTokens = 0;
state.nudge.lastShownByTier = {};
runtime.clearNudgeTokenStamps();
runtime.clearNudgeTokenStamps(sid);
logInfo("growth-scale", { sid, event: "scale-flip-reset", anchorStale: !hostFloorActive });
}
debug.event("context-in", {
Expand Down Expand Up @@ -474,7 +477,7 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
// the cap suppression sees the newest outcome (a success on this fire
// must lift the cap on this same fire).
const compressOutcomes = collectCompressOutcomes(entries, turnStartIndex(entries));
const outcome = compressOutcomes.length > 0 ? runtime.noteCompressOutcomes(turnKey, compressOutcomes) : null;
const outcome = compressOutcomes.length > 0 ? runtime.noteCompressOutcomes(sid, turnKey, compressOutcomes) : null;

// 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 All @@ -493,14 +496,14 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
Math.max(config.nudge.growthFloor, Math.round(config.modelContextLimit * config.nudge.growthRatio)),
);
const reInjectFloor = Math.max(config.nudge.minGrowthFloor, config.nudge.minGrowthRatio * adaptiveGrowth);
let shownAt = runtime.nudgeShownTokensFor(turnKey);
let shownAt = runtime.nudgeShownTokensFor(sid, turnKey);
if (shownAt !== undefined && tokenCount < shownAt - adaptiveGrowth) {
// Mirror the kernel's drop re-anchor (nudgeNode): after a successful
// compress the meter collapses; growth since the last shown must
// restart from the new baseline, not from the old peak.
logInfo("nudge", { sid: ctx.sessionManager.getSessionId(), event: "drop-reanchor", turnKey, from: shownAt, to: tokenCount });
shownAt = tokenCount;
runtime.markNudgeShown(turnKey, tokenCount);
runtime.markNudgeShown(sid, turnKey, tokenCount);
}

if (turn.nudge?.shouldInject) {
Expand Down Expand Up @@ -538,9 +541,9 @@ 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);
const retryCapped = runtime.compressRetryCappedFor(sid, turnKey);
const reInjectReady = shownAt === undefined || tokenCount - shownAt >= reInjectFloor;
const alreadyShown = retryCapped || (!emergency && runtime.nudgeShownFor(turnKey) && !reInjectReady);
const alreadyShown = retryCapped || (!emergency && runtime.nudgeShownFor(sid, 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 @@ -552,7 +555,7 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
if (debugOn && ctx.hasUI) {
ctx.ui.notify(`[ACP nudge → context]${emergency ? " [EMERGENCY]" : ""}\n${rendered.text}${example}`);
}
if (!emergency) runtime.markNudgeShown(turnKey, tokenCount);
if (!emergency) runtime.markNudgeShown(sid, turnKey, tokenCount);
debug.event("nudge-injected", { sid: ctx.sessionManager.getSessionId(), voice: rendered.voice, channels: ["context", debugOn ? "terminal" : null].filter(Boolean), emergency, turnKey, reInject: shownAt !== undefined, text: rendered.text + example });
} else {
debug.event("nudge-suppressed", { sid: ctx.sessionManager.getSessionId(), turnKey, reason: turn.nudge.reason, shownAt: shownAt ?? null, tokenCount, adaptiveGrowth, reInjectFloor });
Expand Down
95 changes: 63 additions & 32 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,25 +72,25 @@ export interface AcpRuntime {
setAdapter(adapter: AdapterConfig): void;
prompts: Prompts;
setPrompts(prompts: Prompts): void;
markNudgeShown(turnKey: string, tokenCount?: number): void;
nudgeShownFor(turnKey: string): boolean;
markNudgeShown(sid: string, turnKey: string, tokenCount?: number): void;
nudgeShownFor(sid: string, turnKey: string): boolean;
/** tokenCount at the last actual nudge injection for this turn, for growth-aware re-inject (issue #269). */
nudgeShownTokensFor(turnKey: string): number | undefined;
nudgeShownTokensFor(sid: string, turnKey: string): number | undefined;
/** Clears the token-count stamps recorded by markNudgeShown — used on a token-scale flip (issue #267) so the same-turn re-inject floor (#269 / PR #316) is not computed against an old-scale stamp. */
clearNudgeTokenStamps(): void;
clearNudgeTokenStamps(sid: string): void;
/** 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 and
* whether the cap was just reached. */
noteCompressOutcomes(turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean }>): { count: number; cappedNow: boolean };
noteCompressOutcomes(sid: string, turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean }>): { count: number; 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;
clearNudgeTracking(): void;
clearCompressRetryTracking(): void;
compressRetryCappedFor(sid: string, turnKey: string): boolean;
clearNudgeTracking(sid: string): void;
clearCompressRetryTracking(sid: string): void;
liveContextLimit(ctx: ExtensionContext): number;
configFor(ctx: ExtensionContext): Config;
/** [#336] Effective compress.reasoning drop settings for the active model
Expand Down Expand Up @@ -272,8 +272,31 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
let adapterRef = adapter;
let lastUserConfigKey: string | undefined;
let promptsRef: Prompts = defaultPrompts;
const nudgeShownTurns = new Set<string>();
const nudgeShownTokens = new Map<string, number>();
const nudgeShownTurns = new Map<string, Set<string>>();
const nudgeShownTokens = new Map<string, Map<string, number>>();
function markNudgeShown(sid: string, turnKey: string, tokenCount?: number): void {
let turns = nudgeShownTurns.get(sid);
if (!turns) { turns = new Set(); nudgeShownTurns.set(sid, turns); }
turns.add(turnKey);
if (tokenCount !== undefined) {
let toks = nudgeShownTokens.get(sid);
if (!toks) { toks = new Map(); nudgeShownTokens.set(sid, toks); }
toks.set(turnKey, tokenCount);
}
}
function nudgeShownFor(sid: string, turnKey: string): boolean {
return nudgeShownTurns.get(sid)?.has(turnKey) ?? false;
}
function nudgeShownTokensFor(sid: string, turnKey: string): number | undefined {
return nudgeShownTokens.get(sid)?.get(turnKey);
}
function clearNudgeTracking(sid: string): void {
nudgeShownTurns.delete(sid);
nudgeShownTokens.delete(sid);
}
function clearNudgeTokenStamps(sid: string): void {
nudgeShownTokens.delete(sid);
}
// Per-session overflow self-heal state (learned window + armed emergency).
const overflowEpisodes = new Map<string, OverflowEpisode>();
function overflowFor(sid: string): OverflowEpisode {
Expand Down Expand Up @@ -331,38 +354,46 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
// caller feeds only CURRENT-turn outcomes; success resets the counter,
// neutral outcomes (non-error text that is not a success panel) leave it
// frozen so mixed failure modes cannot bypass the cap.
const compressOutcomeSeen = new Set<string>();
let compressFailTurnKey: string | null = null;
let compressFailCount = 0;

function noteCompressOutcomes(turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean }>): { count: number; cappedNow: boolean } {
if (compressFailTurnKey !== turnKey) {
compressFailTurnKey = turnKey;
compressFailCount = 0;
interface CompressOutcomeTracker {
seen: Set<string>;
failTurnKey: string | null;
failCount: number;
}
const compressOutcomes = new Map<string, CompressOutcomeTracker>();
function compressTrackerFor(sid: string): CompressOutcomeTracker {
let t = compressOutcomes.get(sid);
if (!t) { t = { seen: new Set(), failTurnKey: null, failCount: 0 }; compressOutcomes.set(sid, t); }
return t;
}

function noteCompressOutcomes(sid: string, turnKey: string, outcomes: ReadonlyArray<{ toolCallId: string; isError: boolean; success: boolean; noop?: boolean }>): { count: number; cappedNow: boolean } {
const t = compressTrackerFor(sid);
if (t.failTurnKey !== turnKey) {
t.failTurnKey = turnKey;
t.failCount = 0;
}
const prevCount = compressFailCount;
const prevCount = t.failCount;
for (const o of outcomes) {
if (compressOutcomeSeen.has(o.toolCallId)) continue;
compressOutcomeSeen.add(o.toolCallId);
if (t.seen.has(o.toolCallId)) continue;
t.seen.add(o.toolCallId);
if (o.isError || o.noop === true) {
compressFailCount += 1;
t.failCount += 1;
} else if (o.success) {
compressFailCount = 0;
t.failCount = 0;
}
// neutral: counter untouched
}
const cappedNow = compressFailCount >= MAX_COMPRESS_ATTEMPTS && prevCount < MAX_COMPRESS_ATTEMPTS;
return { count: compressFailCount, cappedNow };
const cappedNow = t.failCount >= MAX_COMPRESS_ATTEMPTS && prevCount < MAX_COMPRESS_ATTEMPTS;
return { count: t.failCount, cappedNow };
}

function compressRetryCappedFor(turnKey: string): boolean {
return compressFailTurnKey === turnKey && compressFailCount >= MAX_COMPRESS_ATTEMPTS;
function compressRetryCappedFor(sid: string, turnKey: string): boolean {
const t = compressOutcomes.get(sid);
return t !== undefined && t.failTurnKey === turnKey && t.failCount >= MAX_COMPRESS_ATTEMPTS;
}

function clearCompressRetryTracking(): void {
compressOutcomeSeen.clear();
compressFailTurnKey = null;
compressFailCount = 0;
function clearCompressRetryTracking(sid: string): void {
compressOutcomes.delete(sid);
}

async function acquireLock(sid: string): Promise<() => void> {
Expand Down Expand Up @@ -467,4 +498,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, reasoningDropFor, 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, nudgeShownFor, nudgeShownTokensFor, clearNudgeTracking, clearNudgeTokenStamps, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reasoningDropFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };}
Loading
Loading