feat(coding-agent): added snapcompact safety checks and UI integration

- Added validation to scan for non-ASCII characters before performing snap-compaction, falling back to LLM-based summarization if the unrenderable ratio is too high.
- Updated event handling and status reporting to explicitly support snapcompact actions, including specific error warnings and cancellation states in the UI.
- Updated session logic to default to snapcompact strategy when auto-compaction is enabled.
This commit is contained in:
can1357
2026-06-21 00:08:12 +02:00
parent 3c356fc0e0
commit aa9709c47f
4 changed files with 52 additions and 23 deletions
@@ -952,7 +952,9 @@ export class EventController {
? "Auto-handoff"
: event.action === "shake"
? "Auto-shake"
: "Auto context-full maintenance";
: event.action === "snapcompact"
? "Auto-snapcompact"
: "Auto context-full maintenance";
this.ctx.autoCompactionLoader = new Loader(
this.ctx.ui,
spinner => theme.fg("accent", spinner),
@@ -973,13 +975,16 @@ export class EventController {
}
const isHandoffAction = event.action === "handoff";
const isShakeAction = event.action === "shake";
const isSnapcompactAction = event.action === "snapcompact";
if (event.aborted) {
this.ctx.showStatus(
isHandoffAction
? "Auto-handoff cancelled"
: isShakeAction
? "Auto-shake cancelled"
: "Auto context-full maintenance cancelled",
: isSnapcompactAction
? "Auto-snapcompact cancelled"
: "Auto context-full maintenance cancelled",
);
} else if (isShakeAction) {
// Shake produces no CompactionResult; rebuild on success, suppress benign skips.
@@ -1018,6 +1023,8 @@ export class EventController {
} else if (event.skipped) {
// Benign skip: no model selected, no candidate models available, or nothing
// to compact yet. Not a failure — suppress the warning.
} else if (isSnapcompactAction) {
this.ctx.showWarning("Auto-snapcompact maintenance failed; continuing without maintenance");
} else {
this.ctx.showWarning("Auto context-full maintenance failed; continuing without maintenance");
}
@@ -159,7 +159,7 @@ import {
import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
import type { Settings, SkillsSettings } from "../config/settings";
import { onAppendOnlyModeChanged } from "../config/settings";
import { getDefault, onAppendOnlyModeChanged } from "../config/settings";
import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
import { loadCapability } from "../discovery";
import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
@@ -9654,29 +9654,50 @@ export class AgentSession {
// summarizer path.
let snapcompactResult: snapcompact.CompactionResult | undefined;
if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
snapcompactResult = await snapcompact.compact(preparation, {
convertToLlm,
model: this.model,
});
const ctxWindow = this.model?.contextWindow ?? 0;
const budget =
ctxWindow > 0
? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
: Number.POSITIVE_INFINITY;
const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
if (projected > budget) {
logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
const text = snapcompact.serializeConversation(
convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
);
const renderScan = snapcompact.scanRenderability(text);
if (renderScan.isSafe) {
snapcompactResult = await snapcompact.compact(preparation, {
convertToLlm,
model: this.model,
shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
});
} else {
logger.warn("Snapcompact disabled: high non-ASCII rate detected; falling back to an LLM summary", {
model: this.model?.id,
projected,
budget,
unrenderableRatio: renderScan.unrenderableRatio,
});
this.emitNotice(
"warning",
"snapcompact could not bring the context under the limit — using an LLM summary instead",
`snapcompact disabled: high non-ASCII rate detected (${(renderScan.unrenderableRatio * 100).toFixed(1)}%). Falling back to an LLM summary to prevent data loss.`,
"compaction",
);
action = "context-full";
snapcompactResult = undefined;
}
if (snapcompactResult) {
const ctxWindow = this.model?.contextWindow ?? 0;
const budget =
ctxWindow > 0
? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
: Number.POSITIVE_INFINITY;
const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
if (projected > budget) {
logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
model: this.model?.id,
projected,
budget,
});
this.emitNotice(
"warning",
"snapcompact could not bring the context under the limit — using an LLM summary instead",
"compaction",
);
action = "context-full";
snapcompactResult = undefined;
}
}
}
@@ -10081,7 +10102,8 @@ export class AgentSession {
result: undefined,
aborted: false,
willRetry: false,
errorMessage: `Auto-shake failed: ${message}`,
errorMessage: message,
skipped: false,
});
// Overflow still needs recovery even if shake threw.
return reason === "overflow" ? "fallback" : COMPACTION_CHECK_NONE;
@@ -323,7 +323,7 @@ describe("ACP lazy startup", () => {
enabled: false,
subagents: false,
syncBacklog: "off",
immuneTurns: 1,
immuneTurns: 3,
});
}
});
@@ -871,13 +871,13 @@ describe("AgentSession handoff", () => {
expect(events.filter(event => event.type === "auto_compaction_end")).toHaveLength(0);
});
it("restores context-full strategy when enabling auto-compaction from off strategy", () => {
it("restores default strategy when enabling auto-compaction from off strategy", () => {
session.settings.set("compaction.enabled", true);
session.settings.set("compaction.strategy", "off");
expect(session.autoCompactionEnabled).toBe(false);
session.setAutoCompactionEnabled(true);
expect(session.settings.get("compaction.strategy")).toBe("context-full");
expect(session.settings.get("compaction.strategy")).toBe("snapcompact");
expect(session.autoCompactionEnabled).toBe(true);
});