Skip to content
Merged
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
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.153",
"version": "0.0.155",
"description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.",
"license": "MIT",
"author": "ranxianglei",
Expand Down
16 changes: 15 additions & 1 deletion kernel/src/absorb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { rawForRef, refForRaw, BLOCKED_REF } from "./refs.js";
import { ACP_TOOL_NAMES, ABSORB_TOOL_NAME } from "./compress-tools.js";
import { isStoredPlaceholderText } from "./ccr.js";
import {
collectFailedCompressCallIds,
collectLatestProtected,
collectProtectedToolCallIds,
hasMediaPayload,
Expand Down Expand Up @@ -92,6 +93,7 @@ export function isAbsorbCandidate(
config: Config,
latest?: LatestProtected,
protectedCallIds?: Set<string>,
failedCompressCallIds?: Set<string>,
): boolean {
if (msg.contentType !== "tool-result" || !msg.toolCallId) return false;
if (isStoredPlaceholderText(msg.text ?? "")) return false;
Expand All @@ -102,6 +104,7 @@ export function isAbsorbCandidate(
msg,
config,
protectedCallIds ?? NO_PROTECTED_CALLS,
failedCompressCallIds,
)
)
return false;
Expand Down Expand Up @@ -166,8 +169,18 @@ export function appendAbsorbPrompts(
let promptedCount = 0;
const latest = collectLatestProtected(messages, config);
const protectedCallIds = collectProtectedToolCallIds(messages, config);
const failedCompressCallIds = collectFailedCompressCallIds(messages, config);
const out = messages.map((msg) => {
if (!isAbsorbCandidate(msg, config, latest, protectedCallIds)) return msg;
if (
!isAbsorbCandidate(
msg,
config,
latest,
protectedCallIds,
failedCompressCallIds,
)
)
return msg;
if (absorbedIds.has(msg.id)) return msg;
const text = msg.text ?? "";
if (text.includes(ABSORB_PROMPT_MARKER)) return msg;
Expand Down Expand Up @@ -331,6 +344,7 @@ export function applyAbsorb(input: AbsorbInput): AbsorbOutcome {
target,
input.config,
collectProtectedToolCallIds(input.messages, input.config),
collectFailedCompressCallIds(input.messages, input.config),
) ||
isMessageLatestProtected(
target,
Expand Down
14 changes: 13 additions & 1 deletion kernel/src/ccr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
import { refForRaw, BLOCKED_REF } from "./refs.js";
import { ACP_TOOL_NAMES } from "./compress-tools.js";
import {
collectFailedCompressCallIds,
collectProtectedToolCallIds,
isMessageProtectedWithPairing,
matchToolPattern,
Expand Down Expand Up @@ -469,6 +470,10 @@ export function storeLargeResults(
input.messages,
input.config,
);
const failedCompressCallIds = collectFailedCompressCallIds(
input.messages,
input.config,
);

const updated = input.messages.map((message) => {
if (message.contentType !== "tool-result") return message;
Expand All @@ -495,7 +500,14 @@ export function storeLargeResults(
) {
return message;
}
if (isMessageProtectedWithPairing(message, input.config, protectedCallIds))
if (
isMessageProtectedWithPairing(
message,
input.config,
protectedCallIds,
failedCompressCallIds,
)
)
return message;
if (!ref || ref === BLOCKED_REF) return message;
const existing = current.byRef[ref];
Expand Down
20 changes: 18 additions & 2 deletions kernel/src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { activeBlockSpans } from "./block-map.js";
import { createRenderRefsNode } from "./render-refs.js";
import type { RenderStrategy } from "./render-refs.js";
import {
collectFailedCompressCallIds,
collectLatestProtected,
collectProtectedToolCallIds,
hasUnrecoverableMediaPayload,
Expand Down Expand Up @@ -1009,6 +1010,16 @@ const assignRefsNode: PipelineNode = {
const hasPathPatterns = (ctx.config.protectedTools ?? []).some((p) =>
p.includes("/"),
);
// #4289: compress exchanges whose result applied ZERO ranges (receipt
// starts "[Compression FAILED") are refuse, not metadata — they fold like
// ordinary messages so a failing compress loop cannot permanently bloat
// context. Both halves are recognized here via the failed-call id set in
// BOTH ref layouts (direct and path-pattern pairing), dialect-aware
// through ctx.config.isCompressTool (MCP-lane forms).
const failedCompressCallIds = collectFailedCompressCallIds(
io.messages,
ctx.config,
);
const pathProtectedCallIds =
hasProtection && hasPathPatterns
? collectProtectedToolCallIds(io.messages, ctx.config)
Expand All @@ -1022,8 +1033,13 @@ const assignRefsNode: PipelineNode = {
hasUnrecoverableMediaPayload(m) ||
(hasProtection
? (pathProtectedCallIds
? isMessageProtectedWithPairing(m, ctx.config, pathProtectedCallIds)
: isMessageProtected(m, ctx.config)) ||
? isMessageProtectedWithPairing(
m,
ctx.config,
pathProtectedCallIds,
failedCompressCallIds,
)
: isMessageProtected(m, ctx.config, failedCompressCallIds)) ||
(latest ? isMessageLatestProtected(m, latest) : false)
: false);
const refResult = assignRefs(io.messages, {
Expand Down
102 changes: 94 additions & 8 deletions kernel/src/protected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,68 @@ import type { Config, CoreMessage } from "./types.js";
* treats past compress calls as load-bearing metadata. acp_rule calls record
* persistent user rules that are re-injected every turn — compressing them
* away would silently lose what the rules state. Letting any of these be
* compressed away breaks decompress and the "summary is historical" contract. */
* compressed away breaks decompress and the "summary is historical" contract.
*
* #4289 exception: a compress exchange that applied ZERO ranges is refuse,
* not metadata — no block carries its arguments and decompress never
* consults it. Hosts render such results with FAILED_COMPRESS_RESULT_PREFIX;
* see isFailedCompressResult. Those exchanges fold like ordinary messages
* so a failing compress loop cannot permanently bloat context. */
export const ALWAYS_PROTECTED_TOOLS = ["compress", "acp_rule"] as const;

/** #4289: hosts render a compress tool-result that applied zero ranges with
* this prefix (the proxy's compressResult contract: every refused / errored /
* zero-block path in src/stream.ts, src/external-summary-compress.ts and the
* ACP tool lane funnels into a "[Compression FAILED: …]" receipt). Successful
* and partial receipts start with "[Compressed" and stay protected — a
* partial fold's arguments still cover the ranges that DID apply. */
export const FAILED_COMPRESS_RESULT_PREFIX = "[Compression FAILED";

/** True when toolName denotes a compress call in ANY hosted dialect (#4289):
* the canonical bare "compress" plus whatever the host's isCompressTool
* recognizer claims (MCP lanes expose mcp__bili__compress — #2578/#2970).
* Union semantics, never narrower than bare: a host without the hook
* behaves exactly as before. */
function isCompressToolName(
toolName: string | undefined,
config?: Pick<Config, "isCompressTool">,
): boolean {
if (!toolName) return false;
return toolName === "compress" || !!config?.isCompressTool?.(toolName);
}

/** True for the tool-result half of a compress exchange that applied zero
* ranges (#4289). Such an exchange is not load-bearing metadata: no block
* carries its arguments, its summary draft is dead weight, and keeping it
* permanently un-foldable turns a failing compress loop into permanent
* context bloat (live incident: 70 failed calls = 116KB of residue a session
* could never reclaim). Dialect-aware via config.isCompressTool when given. */
export function isFailedCompressResult(
msg: CoreMessage,
config?: Pick<Config, "isCompressTool">,
): boolean {
return (
msg.contentType === "tool-result" &&
isCompressToolName(msg.toolName, config) &&
(msg.text ?? "").startsWith(FAILED_COMPRESS_RESULT_PREFIX)
);
}

/** toolCallIds of failed compress exchanges (#4289). The tool-CALL half has
* no outcome of its own — pass this set as isMessageProtected's optional
* third argument so both halves drop their protection together. Computed
* from the full message list, which every fold-side consumer already holds. */
export function collectFailedCompressCallIds(
messages: readonly CoreMessage[],
config?: Pick<Config, "isCompressTool">,
): Set<string> {
const ids = new Set<string>();
for (const m of messages) {
if (isFailedCompressResult(m, config) && m.toolCallId) ids.add(m.toolCallId);
}
return ids;
}

/** Tool results that must NEVER participate in the soft-protected recent zone
* (preserveRecentMessages / preserveRecentTokens / last user message).
*
Expand Down Expand Up @@ -201,12 +260,29 @@ export function virtualToolPathOf(

export function isMessageProtected(
msg: CoreMessage,
config: Pick<Config, "protectedTools" | "isToolProtected" | "isMessageProtected" | "messagePathOf">,
config: Pick<Config, "protectedTools" | "isToolProtected" | "isMessageProtected" | "messagePathOf" | "isCompressTool">,
failedToolCallIds?: ReadonlySet<string>,
): boolean {
// Host-declared message-level protection (#2419): applies to ANY content
// type and outranks everything below — the host owns the decision.
if (config.isMessageProtected?.(msg)) return true;

// #4289: a compress exchange that applied ZERO ranges is refuse, not
// metadata — no block carries its arguments and decompress never consults
// it. Exempt both halves from EVERY built-in source below (ALWAYS table,
// protectedTools patterns, host tool-hooks): MCP-lane forms ride the
// injected isToolProtected hook and would otherwise stay pinned forever.
// The result half self-proves via its receipt prefix, the call half rides
// the failedToolCallIds set. Recognition is dialect-aware (isCompressTool
// union — bare always counts).
if (
isFailedCompressResult(msg, config) ||
(msg.toolCallId !== undefined &&
failedToolCallIds?.has(msg.toolCallId) === true)
) {
return false;
}

// tool-result carries the same toolName as its tool-call (the host projects
// it), so checking toolName covers both sides of a tool exchange.
if (
Expand All @@ -227,6 +303,7 @@ export function isMessageProtected(
}

// Hard-coded protection: ACP metadata tools are never compressible.
// (#4289 failed-compress exemption runs ABOVE all built-in sources.)
if ((ALWAYS_PROTECTED_TOOLS as readonly string[]).includes(msg.toolName)) {
return true;
}
Expand All @@ -243,17 +320,22 @@ export function isMessageProtected(
/** Build the set of toolCallIds whose tool-call is protected. Use this to also
* protect tool-results that lack a toolName (common when the host projects a
* tool-result with only toolCallId). Without it, the result half of a
* protected tool exchange leaks into compressible ranges. */
* protected tool exchange leaks into compressible ranges.
*
* #4289: failed compress calls are excluded (via the failed set passed into
* isMessageProtected), so their results are not re-protected here through
* pairing. Dialect-aware through config.isCompressTool. */
export function collectProtectedToolCallIds(
messages: CoreMessage[],
config: Pick<Config, "protectedTools" | "isToolProtected">,
config: Pick<Config, "protectedTools" | "isToolProtected" | "isCompressTool">,
): Set<string> {
const ids = new Set<string>();
const failedCompressCallIds = collectFailedCompressCallIds(messages, config);
for (const m of messages) {
if (
m.contentType === "tool-call" &&
m.toolCallId &&
isMessageProtected(m, config)
isMessageProtected(m, config, failedCompressCallIds)
) {
ids.add(m.toolCallId);
}
Expand All @@ -262,16 +344,20 @@ export function collectProtectedToolCallIds(
}

/** Like isMessageProtected, but also matches tool-results by toolCallId against
* the protected call set. Use when you have the full message list available. */
* the protected call set. Use when you have the full message list available.
* Pass the failed-compress set (#4289) as the fourth argument — without it
* the call half of a failed exchange stays protected on this path. */
export function isMessageProtectedWithPairing(
msg: CoreMessage,
config: Pick<Config, "protectedTools" | "isToolProtected">,
config: Pick<Config, "protectedTools" | "isToolProtected" | "isCompressTool">,
protectedCallIds: Set<string>,
failedToolCallIds?: ReadonlySet<string>,
): boolean {
if (isMessageProtected(msg, config)) return true;
if (isMessageProtected(msg, config, failedToolCallIds)) return true;
if (
msg.contentType === "tool-result" &&
msg.toolCallId &&
!isFailedCompressResult(msg, config) && // #4289: a failed compress result is never re-protected via pairing
protectedCallIds.has(msg.toolCallId)
) {
return true;
Expand Down
12 changes: 11 additions & 1 deletion kernel/src/recommend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { isToolMessage } from "./message-kind.js";
import { coveredMessageIds, isLiveCheckpointCarrier } from "./state.js";
import { SUMMARY_HEADER } from "./prune.js";
import {
collectFailedCompressCallIds,
collectLatestProtected,
collectProtectedToolCallIds,
hasUnrecoverableMediaPayload,
Expand Down Expand Up @@ -293,6 +294,10 @@ export function buildCompressibleRanges(
// Pairing: a tool-result may carry only toolCallId (no toolName). Collect the
// callIds of protected tool-calls first, then protect matching results too.
const protectedCallIds = collectProtectedToolCallIds(messages, config);
// #4289: failed compress exchanges (zero ranges applied) must be advertised
// as compressible like ordinary messages — without this set their call half
// stays "protected" here while assignRefs already gave it a foldable ref.
const failedCompressCallIds = collectFailedCompressCallIds(messages, config);
// Latest-only protected calls: their results are covered by the pairing
// union; the calls themselves need the explicit check below (pairing only
// matches tool-results).
Expand Down Expand Up @@ -355,7 +360,12 @@ export function buildCompressibleRanges(
}

if (
isMessageProtectedWithPairing(msg, config, protectedCallIds) ||
isMessageProtectedWithPairing(
msg,
config,
protectedCallIds,
failedCompressCallIds,
) ||
isMessageLatestProtected(msg, latest)
) {
protectedMsgs.push({
Expand Down
12 changes: 10 additions & 2 deletions kernel/src/unit-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { activeBlocks, blockById, isLiveCheckpointCarrier } from "./state.js";
import { resolveBoundaries, blockVisibleInRange } from "./boundaries.js";
import { isSummaryMessageId } from "./prune.js";
import {
collectFailedCompressCallIds,
collectLatestProtected,
hasUnrecoverableMediaPayload,
isMessageLatestProtected,
Expand Down Expand Up @@ -458,12 +459,19 @@ function filterProtectedToolMessages(
// visible context and are simply dropped from the compressible set. They are
// NOT folded into the summary — the summary reflects what the author wrote,
// nothing auto-appended.
// #4289: failed compress exchanges (result = "[Compression FAILED…", zero
// ranges applied) are NOT protected — they are dead drafts that must stay
// foldable so a failing compress loop cannot permanently bloat context.
const failedCompressCallIds = collectFailedCompressCallIds(messages, config);
const protectedCallIds = new Set<string>();
const removedIds = new Set<string>();
const latest = collectLatestProtected(messages, config);
for (const id of latest.callIds) protectedCallIds.add(id);
for (const msg of messages) {
if (isMessageProtected(msg, config) && msg.toolCallId) {
if (
isMessageProtected(msg, config, failedCompressCallIds) &&
msg.toolCallId
) {
protectedCallIds.add(msg.toolCallId);
}
}
Expand All @@ -473,7 +481,7 @@ function filterProtectedToolMessages(
if (!msg) continue;
if (
hasUnrecoverableMediaPayload(msg) ||
isMessageProtected(msg, config) ||
isMessageProtected(msg, config, failedCompressCallIds) ||
isMessageLatestProtected(msg, latest)
) {
removedIds.add(id);
Expand Down
Loading
Loading