Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
403ac93
kernel: block retirement and external summary rewrite (#5765)
Nov 6, 2026
4aef1ea
feat: acp_retire/acp_restore/acp_rewrite host surface + export ledger…
Nov 6, 2026
912fa39
Merge remote-tracking branch 'origin/master' into wt-5767-ework-daemon
Nov 6, 2026
0971b11
fix(test): classify #5765 block-op tools in the kernel partition guard
Nov 6, 2026
2887f54
test(wire-contract): regenerate goldens for the #5765 tool-surface ad…
Nov 6, 2026
4397a90
chore(kernel): align CompressionBlock field indentation to 2-space co…
Nov 6, 2026
eb23b75
chore(tests): name #5765 regression tests after the GitHub issue number
Nov 6, 2026
4989ef2
fix(acp-block-ops): normalize retire receipt block ids via parseBlock…
Nov 6, 2026
83cacf5
chore(web): color the new block-op tool chips like acp_cache
Nov 6, 2026
9889f83
Merge remote-tracking branch 'origin/master' into ci-fix-5774-b
Nov 6, 2026
7d7f8fa
fix: block-op receipts report only what actually changed (#5765 review)
Nov 6, 2026
2089361
fix: export ledger range fallback avoids arg-spread crash on huge blo…
Nov 6, 2026
3de75ff
test: pin D-lane retired-block anchor suppression (#5765 review)
Nov 6, 2026
552b62a
chore: merge master ahead of #5765 review fixes
Nov 10, 2026
8a94375
test: update pinned ACP tool lists + prompt text for the #5765 tool s…
Nov 10, 2026
454a71b
test: regenerate storage-render goldens for the #5765 tool surface
Nov 10, 2026
ce9ba55
test: retune payload-calibrated e2e pins for the #5765 body growth
Nov 10, 2026
24ff4e2
docs(architecture): register src/acp-block-ops.ts in the file map (#5…
Nov 10, 2026
6b33fd2
feat: gate the #5765 block-lifecycle tools behind compress.blockOps (…
Nov 11, 2026
efa3df7
chore: merge master ahead of the #5765 blockOps gate (kernel 0.0.195)
Nov 11, 2026
3fbc2f2
fix(guard): drop two INTERNAL_ONLY exports left by the #5765 mcp-note…
Nov 11, 2026
1d5f911
fix(guards): lock-sync + lane-guard for the #5765 blockOps gate
Nov 11, 2026
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
13 changes: 13 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ This index is generated from `website/config-reference/*.yaml` — edit the seed
| `compress.injectNudge` | boolean | true | ACP_COMPRESS_NUDGE | Send growth nudges as the window fills (global level only). |
| `compress.reasoning` | { drop?, threshold? } | drop true · threshold 2048 | — | Drop finished-round reasoning runs longer than 2048 chars (drop: true by default); strict-reasoning upstreams need drop:false. |
| `compress.absorb` | object | opt-in (disabled) | — | Opt-in instant distillation of tool results into short abstracts; originals go to the content store. By default only image-bearing results qualify. |
| `compress.blockOps` | object | off | — | Opt-in block-lifecycle tools (acp_retire / acp_restore / acp_rewrite) for compressed-block management; default-off keeps the wire byte-identical to pre-#5765. |
| `compress.ccr` | object | enabled in proxy mode since v2 | — | Content cache & retrieve: large outputs stored losslessly beside the session, replaced by head excerpt + pointer fetched back via acp_retrieve; no cap or eviction. |
| `compress.search` | object | off | — | Search-behavior settings for search_context; currently holds planAware re-ranking. |
| `compress.search.planAware` | boolean | false | — | When on, search_context candidates are re-ranked against current plan state; off keeps byte-identical lexical results. |
Expand All @@ -325,6 +326,7 @@ This index is generated from `website/config-reference/*.yaml` — edit the seed
| `compress.absorb.excludeTools` | string[] | [] | — | Tool names excluded from absorption. |
| `compress.absorb.toolName` | string | "absorb" | — | Registered name of the absorb tool. |
| `compress.absorb.mediaOnly` | boolean | true | — | Restrict absorption to tool results that carry media (images). Image bytes are exempt from minToolTokens since they are not counted in text. Set false to restore legacy full-text absorption of any large result. |
| `compress.blockOps.enabled` | boolean | false | — | Enable the three block-lifecycle tools (injection + prompt + manifest). Off = no tools, no prompt section, zero per-request cost. |
| `compress.ccr.enabled` | boolean | true (proxy mode) | — | Enable the content-cache-and-retrieve block. |
| `compress.ccr.minToolTokens` | number | ≈4000 | — | Minimum estimated token size of a tool result eligible for CCR storage. |
| `compress.ccr.excludeTools` | string[] | [] | — | Tool names excluded from CCR storage. |
Expand Down Expand Up @@ -1634,6 +1636,17 @@ Optional surface blocks: `prompts` (inline rule-text overrides), `nudgeSections`
- `toolName: string` — rename the injected tool (default `"absorb"`); the advertised/injected schema, the system-prompt section and per-session adjudication all follow the name in both lanes. **Lane governance (#1359):** plugin mode governs the *entire* `absorb` block by the **base** config, so a renamed tool is advertised under that name and executed under it (they can never diverge); provider/model-level `absorb.*` overrides are **proxy-lane-only** (the proxy injects and adjudicates the merged name). A load-time warning lists any provider/model `absorb.*` field whose value diverges from base.
Injection follows the wire's native-tool surface: proxy mode injects the tool (under the per-request resolved name) + a system-prompt section on the anthropic/openai/responses native-tools wires, plugin mode advertises it in the plugin manifest **only while enabled** (hosts register the manifest's tools verbatim — advertising a disabled tool breaks them, #1192; the MCP shell picks it up for free). Responses **marker/text-protocol** routes are not supported (no native tool surface — the REQUIRED absorb instruction would be unsatisfiable), and title-generation requests (`max_tokens ≤ 200`) skip injection like the compress prompt does. Absorbed pairs stay hidden across restarts (persisted in the session state).

#### `blockOps`

- **Type:** `object` (`{ enabled? }`)
- **Default:** *(off — the tools are injected only while `enabled: true`)*
- **Status:** ACTIVE (issue #5765)
- **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).
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`

- **Type:** `object` (`{ enabled?, minToolTokens?, excludeTools?, toolName?, maxHeadChars? }`)
Expand Down
13 changes: 13 additions & 0 deletions docs/configuration.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@
| `compress.injectNudge` | boolean | true | ACP_COMPRESS_NUDGE | 窗口填满过程中发送增长提醒(仅全局层级生效)。 |
| `compress.reasoning` | { drop?, threshold? } | drop true · threshold 2048 | — | 丢弃超过 2048 字符的已结束轮次推理块(drop 默认 true);严格推理上游需设 drop:false。 |
| `compress.absorb` | object | opt-in (disabled) | — | 可选即时蒸馏:把工具结果蒸馏成短摘要,原文进内容库。默认仅图片类结果参与。 |
| `compress.blockOps` | object | off | — | 可选的压缩块生命周期工具(acp_retire / acp_restore / acp_rewrite);默认关闭,关闭时线上字节与 #5765 之前完全一致。 |
| `compress.ccr` | object | enabled in proxy mode since v2 | — | 内容缓存与回取:大输出无损存到会话旁、替换为首段摘录+指针,模型用 acp_retrieve 取回原文;无上限、永不清除。 |
| `compress.search` | object | off | — | search_context 的检索行为设置;当前含 planAware 重排。 |
| `compress.search.planAware` | boolean | false | — | 开启后 search_context 候选按当前计划状态重排;关闭时结果逐字节不变。 |
Expand All @@ -325,6 +326,7 @@
| `compress.absorb.excludeTools` | string[] | [] | — | 排除出吸收范围的工具名。 |
| `compress.absorb.toolName` | string | "absorb" | — | absorb 工具的注册名。 |
| `compress.absorb.mediaOnly` | boolean | true | — | 仅吸收携带媒体(图片)的工具结果;图片字节不计入文本 token,故豁免 minToolTokens 门槛。置 false 恢复对任意大结果的全文吸收(旧行为)。 |
| `compress.blockOps.enabled` | boolean | false | — | 启用三个压缩块生命周期工具(注入 + prompt + manifest)。关闭 = 无工具、无 prompt 段、零每请求开销。 |
| `compress.ccr.enabled` | boolean | true (proxy mode) | — | 启用内容缓存与回取块。 |
| `compress.ccr.minToolTokens` | number | ≈4000 | — | 可进入 CCR 存储的工具结果的最小估算 token 大小。 |
| `compress.ccr.excludeTools` | string[] | [] | — | 排除出 CCR 存储的工具名。 |
Expand Down Expand Up @@ -1619,6 +1621,17 @@ ACP 原生 agent(当前为 `pi` 扩展)会在每个进程内向代理上报
- `toolName: string` — 重命名注入工具(默认 `"absorb"`);声明/注入的模式、系统提示段与按会话裁决在两条车道中都跟随该名称。**车道治理(#1359):** 插件模式用**基础**配置治理整个 `absorb` 块,因此重命名后的工具既以该名声明、也以该名执行(二者永不背离);provider/model 层的 `absorb.*` 覆盖**仅限代理车道**(代理注入并裁决合并后的名称)。加载时会输出一条警告,列出任何取值与基础值不同的 provider/model `absorb.*` 字段。
注入跟随线上原生工具面:代理模式在 anthropic/openai/responses 原生工具线上注入工具(按每请求解析的名称)+ 系统提示段,插件模式在插件清单中广告它(MCP shell 自动拾取)。Responses **marker/文本协议**路由不支持(无原生工具面 — 强制的 absorb 指令不可满足),标题生成请求(`max_tokens ≤ 200`)跳过注入如压缩提示一样。吸收配对在重启后保持隐藏(在会话状态持久化)。

#### `blockOps`

- **类型:** `object`(`{ enabled? }`)
- **默认:** *(关 — 只有 `enabled: true` 时才注入工具)*
- **状态:** 已启用(issue #5765)
- **说明:** 可选的**压缩块生命周期管理**。启用后,三个工具与 ACP 工具一并注入(代理通道)/ 上报(插件通道),各有独立 system-prompt 段;同时为每个会话存一份生效开关,保证插件工具 API 解析到与线上相同的值:
- `acp_retire` — 把 active 压缩块移出常驻视图(其摘要不再随每个请求发送;仍可按 id 用 `acp_restore` / `decompress` / `search_context` 取回)。
- `acp_restore` — 把已退场块放回常驻视图。
- `acp_rewrite` — 原地替换块摘要(保留一步回滚位 `previousSummary`;相同/空白摘要拒收)。
内核状态变更幂等、可批量(`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`

- **类型:** `object`(`{ enabled?, minToolTokens?, excludeTools?, toolName?, maxHeadChars? }`)
Expand Down
4 changes: 2 additions & 2 deletions kernel/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 kernel/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "acp-kernel",
"version": "0.0.193",
"version": "0.0.195",
"description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.",
"license": "MIT",
"author": "ranxianglei",
Expand Down
6 changes: 4 additions & 2 deletions kernel/src/absorb.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { rawForRef, refForRaw, BLOCKED_REF } from "./refs.js";
import { ACP_TOOL_NAMES, ABSORB_TOOL_NAME } from "./compress-tools.js";
import { ACP_TOOL_NAMES, ABSORB_TOOL_NAME, BLOCK_OP_TOOL_NAMES } from "./compress-tools.js";
import { isStoredPlaceholderText } from "./ccr.js";
import {
collectFailedCompressCallIds,
Expand Down Expand Up @@ -81,7 +81,9 @@ function isAcpOrConfiguredTool(
): boolean {
if (!toolName) return false;
if (toolName === cfg.toolName) return true;
return ACP_TOOL_NAMES.has(toolName);
// #5765: opt-in block ops are out of ACP_TOOL_NAMES but still bili-owned —
// their receipts must not become absorb candidates when the feature is on.
return ACP_TOOL_NAMES.has(toolName) || BLOCK_OP_TOOL_NAMES.has(toolName);
}

const NO_PROTECTED_CALLS = new Set<string>();
Expand Down
117 changes: 117 additions & 0 deletions kernel/src/block-ops.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { parseBlockIdArg } from "./decompress.js";
import type { CompressionState } from "./types.js";

/** #5765: per-id outcome of a retire/restore/rewrite operation.
* `applied` = state changed; `noop` = already in the requested state
* (idempotent); `failed` = rejected with a model-readable reason. */
export interface BlockOpOutcome {
applied: string[];
noop: string[];
failed: Array<{ id: string; reason: string }>;
}

interface ResolvedTarget {
blockId: string;
state: CompressionState;
}

/** Normalize a user-supplied block ref ("b5", "005", "5") and resolve it to an
* ACTIVE block. Active-only on purpose: a consumed/inactive block's summary is
* not resident (it was distilled into a parent tier), so retiring/restoring/
* rewriting it would have no visible effect — reject instead of faking one. */
function resolveActiveBlock(
state: CompressionState,
raw: string,
): ResolvedTarget | { error: string } {
const normalized = typeof raw === "string" ? raw.trim() : "";
const blockId = parseBlockIdArg(normalized);
if (!blockId) {
return { error: `"${normalized}" is not a valid block id (expected e.g. b5)` };
}
const block = state.blocks.find((b) => b.blockId === blockId);
if (!block) {
return { error: `${blockId} does not exist in this session — run acp_status for the current block list` };
}
if (!block.active) {
return { error: `${blockId} is not active (distilled into a higher tier or deactivated) — only active blocks can be operated on` };
}
return { blockId, state };
}

/** #5765 retire: mark active blocks retired — their summaries leave the
* resident view (prune skips their anchors) while folded messages stay
* covered and decompress/search_context keep working. Idempotent. */
export function retireBlocks(
state: CompressionState,
rawIds: string[],
): BlockOpOutcome {
const outcome: BlockOpOutcome = { applied: [], noop: [], failed: [] };
for (const raw of rawIds) {
const target = resolveActiveBlock(state, raw);
if ("error" in target) {
outcome.failed.push({ id: String(raw).trim(), reason: target.error });
continue;
}
const block = state.blocks.find((b) => b.blockId === target.blockId)!;
if (block.retired) {
outcome.noop.push(block.blockId);
} else {
block.retired = true;
outcome.applied.push(block.blockId);
}
}
return outcome;
}

/** #5765 restore: undo retire — the block's summary re-enters the resident
* view on the next rebuild. Idempotent. */
export function restoreBlocks(
state: CompressionState,
rawIds: string[],
): BlockOpOutcome {
const outcome: BlockOpOutcome = { applied: [], noop: [], failed: [] };
for (const raw of rawIds) {
const target = resolveActiveBlock(state, raw);
if ("error" in target) {
outcome.failed.push({ id: String(raw).trim(), reason: target.error });
continue;
}
const block = state.blocks.find((b) => b.blockId === target.blockId)!;
if (block.retired) {
block.retired = false;
outcome.applied.push(block.blockId);
} else {
outcome.noop.push(block.blockId);
}
}
return outcome;
}

/** #5765 rewrite: replace an active block's summary with externally supplied
* text — no re-compression, no regeneration. The previous summary is kept one
* step deep (`previousSummary`) so the operation is roll-backable; a further
* rewrite overwrites that slot. `compressedTokens` is untouched (it records
* the original fold's input size, not the summary's). Returns the char counts
* before/after for the receipt. */
export function rewriteBlockSummary(
state: CompressionState,
raw: string,
newSummary: string,
):
| { ok: true; blockId: string; prevChars: number; newChars: number }
| { ok: false; id: string; reason: string } {
const target = resolveActiveBlock(state, raw);
if ("error" in target) return { ok: false, id: String(raw).trim(), reason: target.error };
const block = state.blocks.find((b) => b.blockId === target.blockId)!;
const next = typeof newSummary === "string" ? newSummary.trim() : "";
if (!next) {
return { ok: false, id: block.blockId, reason: "summary must be non-empty text" };
}
if (next === block.summary.trim()) {
return { ok: false, id: block.blockId, reason: "new summary is identical to the current one — nothing to do" };
}
block.previousSummary = block.summary;
block.summary = next;
block.rewrittenAt = Date.now();
return { ok: true, blockId: block.blockId, prevChars: block.previousSummary.length, newChars: next.length };
}
8 changes: 6 additions & 2 deletions kernel/src/ccr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
CoreMessage,
} from "./types.js";
import { refForRaw, BLOCKED_REF } from "./refs.js";
import { ACP_TOOL_NAMES } from "./compress-tools.js";
import { ACP_TOOL_NAMES, BLOCK_OP_TOOL_NAMES } from "./compress-tools.js";
import {
collectFailedCompressCallIds,
collectProtectedToolCallIds,
Expand Down Expand Up @@ -488,9 +488,13 @@ export function storeLargeResults(
if (placeholder && (!ref || placeholder.ref === ref)) return message;
if (!message.toolCallId) return message;
const toolName = message.toolName;
// #5765: opt-in block ops are out of ACP_TOOL_NAMES but still bili-owned
// receipts — they bypass ccr storage like every other ACP tool result.
if (
toolName &&
(ACP_TOOL_NAMES.has(toolName) || toolName === cfg.toolName)
(ACP_TOOL_NAMES.has(toolName) ||
BLOCK_OP_TOOL_NAMES.has(toolName) ||
toolName === cfg.toolName)
) {
return message;
}
Expand Down
Loading
Loading