diff --git a/package-lock.json b/package-lock.json index f41f1ee..506a00d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "devDependencies": { "@earendil-works/pi-coding-agent": "0.83.0", "@types/node": "^26.1.2", - "acp-kernel": "0.0.32", + "acp-kernel": "file:../acp-kernel/acp-kernel-0.0.33.tgz", "billion-context-kit": "0.2.0", "tsup": "^8.5.1", "tsx": "^4.23.1", @@ -3186,9 +3186,9 @@ } }, "node_modules/acp-kernel": { - "version": "0.0.32", - "resolved": "https://registry.npmjs.org/acp-kernel/-/acp-kernel-0.0.32.tgz", - "integrity": "sha512-iOJPF+X6NMGkpGsAbxHV0Fcvymzf9a0c5Mf/z3padNVgPgMNxwG1snxYravVccRePgHOzWgpNYikqtpGYhiInA==", + "version": "0.0.33", + "resolved": "file:../acp-kernel/acp-kernel-0.0.33.tgz", + "integrity": "sha512-zTuOCINsAGhR58gVrniKyTeFdL77Jj1ZmMC91iUZLr0puFmuhAEpau/LT2yXYubU8/20owdijO0DFH1f4QkV8w==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 96dcfa0..ed8474e 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "devDependencies": { "@earendil-works/pi-coding-agent": "0.83.0", "@types/node": "^26.1.2", - "acp-kernel": "0.0.32", + "acp-kernel": "file:../acp-kernel/acp-kernel-0.0.33.tgz", "billion-context-kit": "0.2.0", "tsup": "^8.5.1", "tsx": "^4.23.1", diff --git a/src/compress-tool.ts b/src/compress-tool.ts index 43900ac..bd38961 100644 --- a/src/compress-tool.ts +++ b/src/compress-tool.ts @@ -7,7 +7,7 @@ import type { import type { AcpRuntime } from "./runtime.js"; import { debug, logError, logInfo, logThrow, logWarn } from "./log.js"; import { estimateTokens, collectCoveredMessageIds, calibrateTokens } from "./tokens.js"; -import { defaultCountTokens, type CompressionBlock } from "acp-kernel"; +import { defaultCountTokens, salvageParseRanges, type CompressionBlock } from "acp-kernel"; import { getSystemPromptText } from "./compat.js"; function formatK(n: number): string { @@ -78,6 +78,20 @@ function normalizeRanges(content: CompressArgs["content"]): RangeEntry[] | strin try { ranges = JSON.parse(ranges); } catch (e) { + // Weak/local models emit truncated or malformed JSON here ~50% of the + // time (see billion-context-omp#121). Before giving up, run the kernel's + // salvage ladder (fences / comma+newline repairs / truncated-array + // prefix / field-regex). Recovered entries proceed as normal ranges. + const sal = salvageParseRanges(ranges as string); + logWarn("compress", { + event: "content-salvage", + layer: sal.layer, + note: sal.note, + ranges: sal.ranges.length, + }); + if (sal.ranges.length > 0) { + return sal.ranges.map((r) => ({ startId: r.startRef, endId: r.endRef, summary: r.summary, ...(r.topic ? { topic: r.topic } : {}) })) as RangeEntry[]; + } return `Invalid content: not valid JSON (${e instanceof Error ? e.message : String(e)}). content must be an ARRAY of {startId, endId, summary} objects — pass the array directly, not a string.`; } } diff --git a/tests/compress-retry.test.ts b/tests/compress-retry.test.ts index 2df8a51..b5b264b 100644 --- a/tests/compress-retry.test.ts +++ b/tests/compress-retry.test.ts @@ -175,6 +175,26 @@ test("compress tool accepts JSON-encoded string content (non-strict-tool provide await rm(`${stateFile}.acp.json`, { force: true }); }); +test("compress tool SALVAGES truncated JSON string content (omp#121)", async () => { + const { api, handlers } = captureApi(); + createAcpExtension({ modelContextLimit: 200_000 })(api as any); + const stateFile = "/tmp/pai-acp-retry-salvage.session.json"; + await rm(`${stateFile}.acp.json`, { force: true }); + const entries = [userMsg("e1", ZH)]; + const ctx = fakeCtx(() => entries, stateFile); + await fire(handlers, ctx); // assigns refs + + const compressTool = api.tools.find((t: any) => t.name === "compress")!; + // truncated mid-second-entry: strict parse fails, the first complete entry + // must be salvaged by the kernel ladder instead of throwing + const truncated = + '[{"startId":"m00001","endId":"m00001","summary":"first entry fully intact and salvageable"},{"startId":"m0'; + const out = await compressTool.execute("tc1", { content: truncated }, undefined, undefined, ctx); + const text = typeof out === "string" ? out : out.content?.[0]?.text ?? String(out); + assert.ok(/ACP \|/.test(text), `expected success panel: ${text}`); + await rm(`${stateFile}.acp.json`, { force: true }); +}); + test("compress tool THROWS on garbage string content (isError:true → retry nudge fires)", async () => { const { api, handlers } = captureApi(); createAcpExtension({ modelContextLimit: 200_000 })(api as any);