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
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1644,7 +1644,7 @@ Optional surface blocks: `prompts` (inline rule-text overrides), `nudgeSections`
- **Description:** Opt-in **compressed-block lifecycle management**. When enabled, three tools are injected (proxy lanes) / advertised (plugin lane) alongside the ACP tools, each with its own system-prompt section, and a per-session enabled flag is stored so the plugin tool API resolves the same value the wire got:
- `acp_retire` — move active compressed blocks out of the resident view (their summaries no longer ride on every request; `acp_restore` / `decompress` / `search_context` still reach them by id).
- `acp_restore` — put retired blocks back into the resident view.
- `acp_rewrite` — replace a block's summary in place (one-step rollback slot `previousSummary` kept; identical/blank rewrites rejected).
- `acp_rewrite` — replace a block's summary in place (one-step rollback slot `previousSummary` kept; identical/blank rewrites rejected, and so are the summaries `compress` would refuse for the block: outside `compress.minSummaryLength` / `compress.maxSummaryLength`, or not smaller than the content the block folded).
The kernel state mutations are idempotent and batch-able (`blockId` single or `ids` array). **Why off by default:** the three tool schemas are always-on surface while enabled — roughly +0.7k estimated tokens per request on every session of the enabled proxy — so the surface (tool arrays, prompt, manifest) ships byte-identical to pre-#5765 unless you opt in. A stray call while disabled gets a model-facing "not enabled on this bili proxy (compress.blockOps.enabled is not true)" receipt instead of an error and never touches state. Retired/rewritten state persists across restarts in the session state, and the export ledger (markdown handoff + JSONL) reports `retired` status, message-ref spans and original/summary char counts.

#### `ccr`
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1629,7 +1629,7 @@ ACP 原生 agent(当前为 `pi` 扩展)会在每个进程内向代理上报
- **说明:** 可选的**压缩块生命周期管理**。启用后,三个工具与 ACP 工具一并注入(代理通道)/ 上报(插件通道),各有独立 system-prompt 段;同时为每个会话存一份生效开关,保证插件工具 API 解析到与线上相同的值:
- `acp_retire` — 把 active 压缩块移出常驻视图(其摘要不再随每个请求发送;仍可按 id 用 `acp_restore` / `decompress` / `search_context` 取回)。
- `acp_restore` — 把已退场块放回常驻视图。
- `acp_rewrite` — 原地替换块摘要(保留一步回滚位 `previousSummary`;相同/空白摘要拒收)。
- `acp_rewrite` — 原地替换块摘要(保留一步回滚位 `previousSummary`;相同/空白摘要拒收;`compress` 对该块会拒收的摘要同样拒收:长度超出 `compress.minSummaryLength` / `compress.maxSummaryLength`,或不比该块折叠的内容小)。
内核状态变更幂等、可批量(`blockId` 单块或 `ids` 数组)。**为何默认关:** 三个工具 schema 在启用期间是常驻面——每个会话每个请求约 +0.7k 估算 token——所以不显式开启时,线上表面(工具数组、prompt、manifest)与 #5765 之前逐字节一致。关闭时的误调会得到面向模型的「not enabled on this bili proxy (compress.blockOps.enabled is not true)」回执而非报错,且绝不改动状态。退场/改写状态跨重启持久化在会话状态中,导出台账(markdown handoff + JSONL)报告 `retired` 状态、消息 ref 区间与原文/摘要字符数。

#### `ccr`
Expand Down
57 changes: 50 additions & 7 deletions src/acp-block-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// external summary rewrite (acp_rewrite). The kernel owns the state mutation
// (kernel/src/block-ops.ts); this module parses tool args, formats receipts,
// and marks the session dirty so the change persists.
import { retireBlocks, restoreBlocks, rewriteBlockSummary, type Config } from "acp-kernel";
import { defaultCountTokens, parseBlockIdArg, retireBlocks, restoreBlocks, rewriteBlockSummary, type Config } from "acp-kernel";
import { markDirty, type Session } from "./session.js";
import { toolFail, toolOk, type ProxyToolResult } from "./proxy-tool-result.js";

Expand Down Expand Up @@ -81,17 +81,55 @@ export function executeAcpRestore(args: Record<string, unknown>, session: Sessio
return opReceipt("acp_restore", outcome, "restored");
}

export function executeAcpRewrite(args: Record<string, unknown>, session: Session): ProxyToolResult {
/** One block id in either spelling the block ops take: "b5" / "5" or the
* number 5 (same bounds as collectBlockIds). undefined = not an id. */
function blockIdArg(v: unknown): string | undefined {
if (typeof v === "string") return v.trim();
if (typeof v === "number" && Number.isInteger(v) && v >= 0) return String(v);
return undefined;
}

/** Hold a rewrite to the bounds compress puts on a summary for the same
* block, so acp_rewrite cannot store what compress would have refused:
* compress.minSummaryLength / maxSummaryLength of the resolved config (0 or
* absent = unbounded, as in compress) and net shrink — a summary that is not
* smaller than the content the block folded is refused, unless it at least
* shrinks the summary it replaces. Only judges a rewrite the kernel would
* apply: unknown/inactive ids and blank or identical text return undefined
* and keep the kernel's own rejection reasons. */
function rewriteRefusal(session: Session, rawId: string, summary: string, config: Config | undefined): { id: string; reason: string } | undefined {
const blockId = parseBlockIdArg(rawId);
const block = blockId === null ? undefined : session.state.blocks.find((b) => b.blockId === blockId);
const next = summary.trim();
if (!block || !block.active || next.length === 0 || next === block.summary.trim()) return undefined;
const minLen = config?.compress?.minSummaryLength ?? 0;
if (minLen > 0 && next.length < minLen) {
return { id: block.blockId, reason: `Summary too short (${next.length} chars, min ${minLen}). The summary must capture the block's key information — to take a block out of the resident view use acp_retire instead.` };
}
const maxLen = config?.compress?.maxSummaryLength ?? 0;
if (maxLen > 0 && next.length > maxLen) {
return { id: block.blockId, reason: `Summary too long (${next.length} chars, max ${maxLen}). Strip noise — keep critical paths, decisions, errors, and code references.` };
}
const nextTokens = defaultCountTokens(next);
if (block.compressedTokens > 0 && nextTokens >= block.compressedTokens && nextTokens >= defaultCountTokens(block.summary)) {
return { id: block.blockId, reason: `no net shrink — the new summary (~${nextTokens} tokens) is not smaller than the content this block folded (~${block.compressedTokens} tokens). Supply a shorter summary.` };
}
return undefined;
}

export function executeAcpRewrite(args: Record<string, unknown>, session: Session, config?: Config): ProxyToolResult {
type Item = { blockId: string; summary: string };
const items: Item[] = [];
if (typeof args.blockId === "string" && typeof args.summary === "string") {
items.push({ blockId: args.blockId.trim(), summary: args.summary });
const singleId = blockIdArg(args.blockId);
if (singleId !== undefined && typeof args.summary === "string") {
items.push({ blockId: singleId, summary: args.summary });
}
let skippedItems = 0;
if (Array.isArray(args.items)) {
for (const raw of args.items) {
if (raw && typeof raw === "object" && typeof (raw as Item).blockId === "string" && typeof (raw as Item).summary === "string") {
items.push({ blockId: (raw as Item).blockId.trim(), summary: (raw as Item).summary });
const id = raw && typeof raw === "object" ? blockIdArg((raw as { blockId?: unknown }).blockId) : undefined;
if (id !== undefined && typeof (raw as Item).summary === "string") {
items.push({ blockId: id, summary: (raw as Item).summary });
} else {
skippedItems += 1;
}
Expand All @@ -101,6 +139,11 @@ export function executeAcpRewrite(args: Record<string, unknown>, session: Sessio
const lines: string[] = [];
let appliedCount = 0;
for (const item of items) {
const refusal = rewriteRefusal(session, item.blockId, item.summary, config);
if (refusal !== undefined) {
lines.push(`${refusal.id}: ${refusal.reason}`);
continue;
}
const res = rewriteBlockSummary(session.state, item.blockId, item.summary);
if (res.ok) {
appliedCount += 1;
Expand All @@ -109,7 +152,7 @@ export function executeAcpRewrite(args: Record<string, unknown>, session: Sessio
lines.push(`${res.id}: ${res.reason}`);
}
}
if (skippedItems > 0) lines.push(`${skippedItems} item(s) skipped — each entry needs string blockId and summary`);
if (skippedItems > 0) lines.push(`${skippedItems} item(s) skipped — each entry needs a blockId and a string summary`);
if (appliedCount > 0) markDirty(session);
const note = "previous summary kept for rollback; decompress originals are unchanged";
const text = `[acp_rewrite: ${lines.join("; ")}; ${note}]`;
Expand Down
2 changes: 1 addition & 1 deletion src/loop/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ export function executeProxyTool(
}
if (toolName === "acp_rewrite") {
if (!blockOpsOn) return toolOk("acp_rewrite is not enabled on this bili proxy (compress.blockOps.enabled is not true) — no summary was rewritten.");
return executeAcpRewrite(args, ctx.session);
return executeAcpRewrite(args, ctx.session, ctx.config);
}
const absorb = effectiveAbsorbConfig(ctx.session, ctx.config);
if (absorb?.enabled === true && toolName === (absorb.toolName ?? ABSORB_TOOL_NAME)) {
Expand Down
85 changes: 85 additions & 0 deletions tests/issue5765-acp-block-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,88 @@ test("export JSONL: block records carry retired/rewrittenAt/originalChars/summar
assert.equal(r2.retired, true);
assert.equal(r2.rewrittenAt, undefined);
});

// acp_rewrite goes through the compress summary bounds of the resolved config
// (compress.maxSummaryLength / minSummaryLength) and accepts the numeric
// blockId spelling acp_retire already takes.
const ctxWithLimits = (session: Session) => ({
session,
config: { blockOps: { enabled: true }, compress: { maxSummaryLength: 20000, minSummaryLength: 50 } },
}) as unknown as Parameters<typeof executeProxyTool>[2];

test("acp_rewrite: summaries compress would refuse are refused, with the limit in the receipt", () => {
const base = mkdtempSync(path.join(tmpdir(), "issue5765-"));
const snap = isolate(base);
try {
const s = makeSession("t-rewrite-limits");
const original = "a normal summary of the folded range that is comfortably longer than the configured floor";
addBlock(s, "b1", { summary: original });
s.state.blocks[0].compressedTokens = 1500; // a ~6000-char folded range

let r = executeProxyTool("acp_rewrite", { blockId: "b1", summary: "word ".repeat(5000) }, ctxWithLimits(s));
assert.equal(r.outcome, "failure", r.text.slice(0, 200));
assert.match(r.text, /b1: Summary too long \(24999 chars, max 20000\)/);
r = executeProxyTool("acp_rewrite", { blockId: "b1", summary: "word ".repeat(1800) }, ctxWithLimits(s));
assert.equal(r.outcome, "failure", r.text.slice(0, 200));
assert.match(r.text, /b1: no net shrink/);
r = executeProxyTool("acp_rewrite", { blockId: "b1", summary: "x" }, ctxWithLimits(s));
assert.equal(r.outcome, "failure", r.text.slice(0, 200));
assert.match(r.text, /b1: Summary too short \(1 chars, min 50\)/);
assert.equal(s.state.blocks[0].summary, original, "refused rewrites leave the stored summary alone");
assert.equal(s.state.blocks[0].previousSummary, undefined);

// Batch: the refused item is reported, the valid sibling still lands.
addBlock(s, "b2", { summary: "another summary of a folded range, also longer than the floor of fifty" });
const good = "rewritten: the b2 range is settled work, nothing in it is needed again later";
r = executeProxyTool("acp_rewrite", { items: [{ blockId: "b1", summary: "x" }, { blockId: "b2", summary: good }] }, ctxWithLimits(s));
assert.equal(r.outcome, "success", r.text);
assert.ok(r.text.includes("b1: Summary too short") && r.text.includes("b2 summary replaced"), r.text);
assert.equal(s.state.blocks[0].summary, original);
assert.equal(s.state.blocks[1].summary, good);
} finally {
restore(snap);
}
});

test("acp_rewrite: numeric blockId is accepted the way acp_retire accepts it (single and batch)", () => {
const base = mkdtempSync(path.join(tmpdir(), "issue5765-"));
const snap = isolate(base);
try {
const s = makeSession("t-rewrite-numeric");
addBlock(s, "b1", { summary: "a much longer original summary text" });
addBlock(s, "b2", { summary: "another long summary here" });
let r = executeProxyTool("acp_rewrite", { blockId: 1, summary: "short one" }, ctxOf(s));
assert.equal(r.outcome, "success", r.text);
assert.equal(s.state.blocks[0].summary, "short one");
r = executeProxyTool("acp_rewrite", { items: [{ blockId: 2, summary: "short two" }] }, ctxOf(s));
assert.equal(r.outcome, "success", r.text);
assert.equal(s.state.blocks[1].summary, "short two");
// Same bounds as the acp_retire id parser: non-integers and negatives are not ids.
r = executeProxyTool("acp_rewrite", { blockId: 1.5, summary: "nope" }, ctxOf(s));
assert.equal(r.outcome, "failure");
r = executeProxyTool("acp_rewrite", { items: [{ blockId: -1, summary: "nope" }] }, ctxOf(s));
assert.equal(r.outcome, "failure");
assert.equal(s.state.blocks[0].summary, "short one");
} finally {
restore(snap);
}
});

// The net-shrink exception: a rewrite that shrinks the summary it replaces is
// always allowed, even when the stored summary was already larger than the
// folded content (cleaning up a pre-existing bloat is still an improvement).
test("acp_rewrite: shrinking an already-bloated summary is allowed even above the folded budget", () => {
const base = mkdtempSync(path.join(tmpdir(), "issue5765-"));
const snap = isolate(base);
try {
const s = makeSession("t-rewrite-improve");
addBlock(s, "b1", { summary: "bloated ".repeat(400) }); // ~800 tokens, far above the folded budget
s.state.blocks[0].compressedTokens = 100; // the block folded only ~100 tokens
const smaller = "tighter ".repeat(200); // ~400 tokens: still > 100, but < the bloated summary
const r = executeProxyTool("acp_rewrite", { blockId: "b1", summary: smaller }, ctxWithLimits(s));
assert.equal(r.outcome, "success", r.text);
assert.equal(s.state.blocks[0].summary, smaller.trim()); // kernel stores summary.trim()
} finally {
restore(snap);
}
});
Loading