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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased (master, since v0.1.38)
- **fix(compress): 彻底移除 `content` 参数的 JSON 字符串形式(closes #273)** — 撤销此前为兼容非严格工具 provider 加的 `Type.Union([Array, String])` 兜底:schema 改回仅 `Type.Array(RangeSpec)`,删除 `tailRepair`/`repairContentTail`/`jsonParseError` 及 `describeDiagnostics` 的字符串解析分支,`normalizeRanges` 改为纯数组校验。非数组 `content`(含 JSON 编码的数组字符串)现在直接抛错(pi 标 `isError:true`,计入失败上限)。权衡:会把数组参数字符串化的非严格 provider 现在在 schema 层被拒,不再进兜底解析
- **fix(delegate): 并发完成的 delegate 通知合并为单条批量消息(closes #157)** — 多个 subagent 同时(或主窗口正忙时接连)完成时,每条完成各自 `sendUserMessage(deliverAs: followUp)` 注入一条通知,N 个完成的 delegate 吃掉 N 个完整模型轮次,且模型已收尾后通知仍在持续涌入。现在 finalize 不再直接注入,而是进入 2s 尾沿防抖窗口(自首个排队完成起硬上限 10s,防连续错峰完成饿死投递);窗口关闭时 `flushDelegateNotifications` 发**一条**批量消息:头部计数(`[acp_delegate] 3 delegates finished (2 completed, 1 FAILED)`)+ 每个 run 一节(状态/exit code/超时注记/任务/结果文件/失败错误摘录)+ 单一尾部(仍在跑的 delegate 数、session delegate usage、收尾指令)。窗口期内获得 waiter 或被 `acp_delegate_wait`/`acp_delegate_cancel` 消费的 run 自动出批(不重复投递);发送失败不置 delivered,run 留在未送达集由后续 carrier 补投(`findUndeliveredRuns` 将排队中视为已排程而非丢失);单 run flush 保留原单条格式。净效果:N 个同时完成 → 1 个模型轮次
- **feat(delegate): 失败/取消保留日志 + 失败诊断 + `resumeFrom` 续跑中断的 run (closes #235)** — 此前 cancel 与 spawn error 路径直接删除 `.out`/`.activity` 文件,失败通知只有 exit code(信号被丢弃、stderr 可能为空、activity 轨迹不可见)。现在:① 所有终止路径保留文件(spawn error 把错误写入 `.out`;cancel 回填部分回复,cancel/wait 结果明确给出文件路径);② 失败通知带 exit 信号(`exit SIGTERM`)、stderr、activity 日志尾部(400 字符)与 activity 文件路径;③ pi 宿主 delegate 用 `--session <OUT_DIR>/<runId>.session.jsonl` + `--session-dir` 持久化自身会话(omp 不变,保持 `--no-session`),新增 `resumeFrom: "<runId>"` 参数让新 run 恢复原会话(原任务 + 已执行的 tool calls + 部分结果)从中断处继续——`task` 变为可选(提供时作为本次追加指引);校验:原 run 不得仍在运行、session 文件必须存在、非 pi 宿主拒绝
- **fix(floor): provider-usage floor 跳过压缩后一轮的 stale anchor(#258 评审)** — pi 的 `getContextUsage()` 锚定最后一条有效 assistant usage;成功 compress 落在锚点之后时,下一个 LLM 调用仍以压缩前的大数字 floor,会在刚缩小的上下文上重跑 emergency(nudge 注入 + 工具结果机械截断),面板也继续显示压缩前数字,直到下一条 usage 到达。新增 `src/floor-stale.ts`(`usageAnchorPredatesCompression`):锚点(跳过 aborted/error/全零)早于最后一条成功 compress toolResult(失败/0-block no-op 不算)时跳过 floor,context transform / `acp_status` / `/acp` 三处一致(面板 sessionTokens 同步);对齐 pi 自身 compaction 的 "usage source must be post-compaction" 检查。已知接受:provider 永不报 usage 时(omp #18 tree-sum 回退)floor 仍会永久高位
Expand Down
102 changes: 16 additions & 86 deletions src/compress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { AcpRuntime } from "./runtime.js";
import { MAX_COMPRESS_ATTEMPTS } from "./runtime.js";
import { debug, logError, logInfo, logThrow, logWarn } from "./log.js";
import { estimateTokens, collectCoveredMessageIds, collectImageTokens, modelSupportsImages, lastUserMessageId } from "./tokens.js";
import { defaultCountTokens, parseCompressArgs, viableRanges, formatRanges, type CompressionBlock, type CompressionState, type CompressParseDiagnostics, type NudgeDecision } from "acp-kernel";
import { defaultCountTokens, viableRanges, formatRanges, type CompressionBlock, type CompressionState, type NudgeDecision } from "acp-kernel";
import { getSystemPromptText } from "./compat.js";
import { OMP_UNSUPPORTED_MESSAGE } from "./omp.js";

Expand All @@ -25,16 +25,7 @@ const RangeSpec = Type.Object({

const CompressParams = Type.Object({
topic: Type.Optional(Type.String({ description: "Fallback topic for entries without their own. Omit when each content entry specifies its own topic." })),
content: Type.Union([
Type.Array(RangeSpec),
// Non-strict-tool providers (vLLM openai-completions, supportsStrictTools:
// false) sometimes stringify nested array arguments — session
// 01a00a38 died on exactly this: pi's typebox validation rejected
// "[{\"topic\":...}]" with "content.0: must be object" and the turn's
// only compress attempt was lost. Accept the JSON-encoded form and parse
// it in normalizeRanges below.
Type.String({ description: "JSON-encoded array of ranges — accepted because non-strict-tool providers sometimes stringify array arguments; parsed automatically." }),
], { description: "One or more ranges to compress, each with start/end boundaries and a summary. When compressing multiple unrelated ranges in one call, give each its own topic." }),
content: Type.Array(RangeSpec, { description: "One or more ranges to compress, each with start/end boundaries and a summary. When compressing multiple unrelated ranges in one call, give each its own topic." }),
summaryMaxChars: Type.Optional(Type.Number({ description: "Override max summary length (default max: 20000 chars). Use when content is important and needs more detail — don't lose critical info just to fit the limit." })),
});

Expand Down Expand Up @@ -70,84 +61,23 @@ export function makeCompressTool(runtime: AcpRuntime): ToolDefinition<typeof Com

type RangeEntry = Static<typeof RangeSpec>;

// Normalize the compress args via the kernel's lenient parser (fenced /
// trailing-comma / raw-newline / double-stringified / truncated-salvage).
// Returns an error string on bad input — handleCompress THROWS it so pi marks
// the toolResult isError:true, which is what makes the outcome count toward
// the failure cap (a returned string would land as isError:false and count
// as neutral). An empty array passes through (the call site returns "No
// ranges provided.").
// Array-only (JSON-string form removed per #273). Returns an error string on
// bad input — handleCompress throws it so pi marks the result isError:true and
// it counts toward the failure cap. The Array.isArray guard is defensive for
// direct execute() callers (tests) that bypass pi's typebox validation.
export function normalizeRanges(args: CompressArgs): RangeEntry[] | string {
const effective = repairContentTail(args);
const { ranges, diagnostics } = parseCompressArgs(effective);
if (ranges.length === 0) {
if (Array.isArray(effective.content) && effective.content.length === 0) return [];
return describeDiagnostics(diagnostics, effective.content);
}
return ranges.map((r) => ({ startId: r.startRef, endId: r.endRef, summary: r.summary, topic: r.topic }));
}

// Qwen-family models in non-strict tool-call mode sometimes emit the `content`
// array as a JSON-encoded string whose LAST entry object is missing its closing
// `}` (tail `"]` instead of `"}]`). The kernel's lenient parser then drops that
// last range — or every range, when it is the only one — and reports a
// misleading "truncated"/"no-valid-ranges" diagnostic. Repair the brace before
// delegating so the whole array parses. Args are returned unchanged when the
// repair does not apply.
function repairContentTail(args: CompressArgs): CompressArgs {
if (typeof args.content !== "string") return args;
const repaired = tailRepair(args.content);
return repaired === undefined ? args : { ...args, content: repaired };
}

// Deterministic tail-repair: if the trimmed string ends with `"]` and the char
// before it is a closing `"`, retry the parse with `"}]` appended. A valid JSON
// array never still parses after appending `}`, so this has no false positives.
export function tailRepair(s: string): string | undefined {
const t = s.trimEnd();
if (!t.endsWith("]")) return undefined;
const body = t.slice(0, -1).trimEnd();
if (!body.endsWith('"')) return undefined;
const candidate = body + "}]";
try {
if (Array.isArray(JSON.parse(candidate))) return candidate;
} catch {
// not the missing-brace case
}
return undefined;
}

function describeDiagnostics(diagnostics: CompressParseDiagnostics, content: CompressArgs["content"]): string {
const shape = typeof content === "string"
? "a JSON-encoded string (non-strict-tool providers stringify array arguments)"
: content === null ? "null" : `a ${typeof content}`;
const base = `Invalid compress content (${diagnostics.kind}): got ${shape}`;
if (diagnostics.kind === "truncated") {
return `${base}; the input was truncated and no complete ranges could be recovered. Shorten the summary or split into smaller ranges.`;
}
if (diagnostics.invalidItems > 0) {
return `${base}; ${diagnostics.invalidItems} entr${diagnostics.invalidItems === 1 ? "y was" : "ies were"} dropped as invalid. Each range must be an object with string fields startId, endId, summary.`;
}
const parseErr = jsonParseError(content);
if (parseErr !== undefined) {
return `${base}; the JSON failed to parse: ${parseErr}. Fix the malformed JSON (e.g. a missing closing brace or quote) and retry.`;
const content = args.content;
if (!Array.isArray(content)) {
return `Invalid compress content: content must be an ARRAY of {startId, endId, summary} objects — JSON-encoded string arrays are no longer accepted, pass the array directly. Got ${typeof content}.`;
}
return `${base}. content must be an ARRAY of {startId, endId, summary} objects.`;
}

// Short diagnostic for why a JSON-shaped string fails to parse, or undefined
// when it parses fine or is not JSON-shaped. Gives the model a retryable signal
// (the parser's own position) instead of the misleading "must be an ARRAY".
function jsonParseError(content: CompressArgs["content"]): string | undefined {
if (typeof content !== "string") return undefined;
const t = content.trim();
if (!t.startsWith("{") && !t.startsWith("[")) return undefined;
try {
JSON.parse(t);
return undefined;
} catch (e) {
return e instanceof Error ? e.message : String(e);
if (content.length === 0) return [];
for (let i = 0; i < content.length; i++) {
const r = content[i];
if (!r || typeof r !== "object" || typeof r.startId !== "string" || typeof r.endId !== "string" || typeof r.summary !== "string") {
return `Invalid compress content: entry ${i} is not a valid range. Each range must be an object with string fields startId, endId, summary.`;
}
}
return content.map((r) => ({ startId: r.startId, endId: r.endId, summary: r.summary, topic: r.topic }));
}

/** Panel block count ("… (~N reclaimed, B blocks)"), or -1 for non-panels. */
Expand Down
32 changes: 7 additions & 25 deletions tests/compress-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { isCompressSuccessText, isCompressNoopText } from "../src/compress-tool.
// stringified the array).
//
// Behavior under test:
// 1. compress-tool accepts a JSON-encoded string for content (root cause).
// 1. compress-tool rejects non-array (JSON-string) content with a thrown
// error — the JSON-encoded string form was removed per #273.
// 2. Argument errors THROW (pi only marks thrown tool errors isError:true —
// a returned error string would be isError:false: not counted + counter
// reset).
Expand Down Expand Up @@ -141,28 +142,7 @@ test("noteCompressOutcomes: counts, caps, resets on success, resets per turn, ne

// ─── unit: normalizeRanges via the tool ─────────────────────────────────────

test("compress tool accepts JSON-encoded string content (non-strict-tool providers)", async () => {
const { api, handlers } = captureApi();
createAcpExtension({ modelContextLimit: 200_000 })(api as any);
const stateFile = "/tmp/pai-acp-retry-str.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")!;
const out = await compressTool.execute(
"tc1",
// exactly what session 01a00a38's model sent: a JSON-encoded array string
{ content: JSON.stringify([{ startId: "m00001", endId: "m00001", summary: "compressed from string form" }]) },
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 → counted by the outcome tracker)", async () => {
test("compress tool THROWS on non-array (JSON-string) content (isError:true → counted by the outcome tracker)", async () => {
const { api, handlers } = captureApi();
createAcpExtension({ modelContextLimit: 200_000 })(api as any);
const stateFile = "/tmp/pai-acp-retry-str2.session.json";
Expand All @@ -174,9 +154,11 @@ test("compress tool THROWS on garbage string content (isError:true → counted b
const compressTool = api.tools.find((t: any) => t.name === "compress")!;
// pi-agent-core marks only THROWN tool errors isError:true; returning the
// error string would be isError:false (not counted as a failure + counter
// reset), so the tool must reject.
// reset), so the tool must reject. The JSON-string form was removed per
// #273, so any non-array content (a JSON-encoded array string included) is
// rejected with a clear "must be an ARRAY" error.
await assert.rejects(
() => compressTool.execute("tc1", { content: "not json {" }, undefined, undefined, ctx),
() => compressTool.execute("tc1", { content: JSON.stringify([{ startId: "m00001", endId: "m00001", summary: "s" }]) }, undefined, undefined, ctx),
/Invalid compress content[\s\S]*ARRAY/,
);
await rm(`${stateFile}.acp.json`, { force: true });
Expand Down
Loading
Loading