fix(agent): dynamically fallback from snapcompact to text summary on high CJK/non-ASCII rates
This commit is contained in:
@@ -7645,13 +7645,24 @@ export class AgentSession {
|
||||
// both take the summarizer path (the latter loudly).
|
||||
const wantsSnapcompact =
|
||||
compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions;
|
||||
const snapcompactReady = wantsSnapcompact && this.model.input.includes("image");
|
||||
let snapcompactReady = wantsSnapcompact && this.model.input.includes("image");
|
||||
if (wantsSnapcompact && !snapcompactReady) {
|
||||
this.emitNotice(
|
||||
"warning",
|
||||
`snapcompact needs a vision-capable model (${this.model.id} is text-only) — using an LLM summary instead`,
|
||||
"compaction",
|
||||
);
|
||||
} else if (snapcompactReady) {
|
||||
const text = snapcompact.serializeConversation(convertToLlm(preparation.messagesToSummarize));
|
||||
const renderScan = snapcompact.scanRenderability(text);
|
||||
if (!renderScan.isSafe) {
|
||||
this.emitNotice(
|
||||
"warning",
|
||||
`snapcompact disabled: high non-ASCII rate detected (${(renderScan.unrenderableRatio * 100).toFixed(1)}%). Falling back to an LLM summary to prevent data loss.`,
|
||||
"compaction",
|
||||
);
|
||||
snapcompactReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
let summary: string;
|
||||
|
||||
@@ -989,6 +989,47 @@ export function normalize(text: string): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan text to determine the proportion of graphic characters that will hit the
|
||||
* `?` fallback during {@link normalize}. Used as a preflight check to abort
|
||||
* snapcompact and fall back to the text summarizer when the input is heavily
|
||||
* non-renderable (e.g., CJK).
|
||||
*/
|
||||
export function scanRenderability(text: string): { isSafe: boolean; unrenderableRatio: number } {
|
||||
const stripped = text.includes("\u001b") ? Bun.stripANSI(text) : text;
|
||||
const collapsed = stripped
|
||||
.replace(COLLAPSIBLE, run => (LINE_BREAK.test(run) ? NEWLINE_GLYPH : /[^\p{Cf}]/u.test(run) ? " " : ""))
|
||||
.replace(EDGE_RUNS, "");
|
||||
let totalGraphics = 0;
|
||||
let fallbackCount = 0;
|
||||
for (const ch of collapsed) {
|
||||
const cp = ch.codePointAt(0) as number;
|
||||
if ((cp >= 0x20 && cp < 0x7f) || (cp >= 0xa0 && cp <= 0xff)) {
|
||||
totalGraphics++;
|
||||
continue;
|
||||
}
|
||||
if (ch === DIM_ON || ch === DIM_OFF || ch === NEWLINE_GLYPH) {
|
||||
continue;
|
||||
}
|
||||
const fold = CHAR_FOLD[ch];
|
||||
if (fold !== undefined) {
|
||||
totalGraphics++;
|
||||
} else if (cp >= 0x2500 && cp <= 0x257f) {
|
||||
totalGraphics++;
|
||||
} else {
|
||||
const folded = foldToAscii(ch);
|
||||
if (folded !== undefined) {
|
||||
totalGraphics++;
|
||||
} else if (!UNRENDERABLE.test(ch)) {
|
||||
totalGraphics++;
|
||||
fallbackCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const unrenderableRatio = totalGraphics > 0 ? fallbackCount / totalGraphics : 0;
|
||||
return { isSafe: unrenderableRatio <= 0.05, unrenderableRatio };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Stopword dimming
|
||||
// ============================================================================
|
||||
|
||||
@@ -67,6 +67,36 @@ function makePreparation(
|
||||
};
|
||||
}
|
||||
|
||||
describe("scanRenderability", () => {
|
||||
it("considers pure ASCII text safe", () => {
|
||||
const res = snapcompact.scanRenderability("function hello() { return 'world'; }");
|
||||
expect(res.isSafe).toBe(true);
|
||||
expect(res.unrenderableRatio).toBe(0);
|
||||
});
|
||||
|
||||
it("considers Latin-1 text safe", () => {
|
||||
const res = snapcompact.scanRenderability("café résumé naïve");
|
||||
expect(res.isSafe).toBe(true);
|
||||
expect(res.unrenderableRatio).toBe(0);
|
||||
});
|
||||
|
||||
it("detects high unrenderable rates in CJK text and marks it unsafe", () => {
|
||||
// Mix of ASCII and CJK: "const a = '你好世界';"
|
||||
// Total graphics: ~15. Unrenderable: 4. Ratio > 5% (0.05).
|
||||
const res = snapcompact.scanRenderability("const a = '你好世界';");
|
||||
expect(res.isSafe).toBe(false);
|
||||
expect(res.unrenderableRatio).toBeGreaterThan(0.05);
|
||||
});
|
||||
|
||||
it("ignores whitespace, ANSI, and zero-width markers in ratio calculations", () => {
|
||||
// \u001b[31m is ANSI.
|
||||
// \u000e \u000f are DIM markers.
|
||||
const res = snapcompact.scanRenderability("\u001b[31mhello \u000e \u000f \n\t world\u001b[0m");
|
||||
expect(res.isSafe).toBe(true);
|
||||
expect(res.unrenderableRatio).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeFileLists", () => {
|
||||
it("drops scheme:// URLs from legacy fileOps before rendering <files>", () => {
|
||||
const fileOps = snapcompact.createFileOps();
|
||||
|
||||
Reference in New Issue
Block a user