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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion src/compress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.`;
}
}
Expand Down
20 changes: 20 additions & 0 deletions tests/compress-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading