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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "acp-kernel",
"version": "0.0.41",
"version": "0.0.42",
"description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.",
"license": "MIT",
"author": "ranxianglei",
Expand Down
30 changes: 29 additions & 1 deletion src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import {
blockVisibleInRange,
} from "./boundaries.js";
import type { ResolvedRange } from "./boundaries.js";
import { truncateLargeToolOutputs } from "./truncate-tools.js";
import {
truncateLargeToolOutputs,
capLargeToolResults,
} from "./truncate-tools.js";
import { hideConsumedCompressCalls } from "./hide-consumed.js";
import { applyMessageFilters, listMessageFilters } from "./filter/index.js";
import { createRenderRefsNode } from "./render-refs.js";
Expand Down Expand Up @@ -373,6 +376,7 @@ export function createCore(ports: Ports = {}): CompressionCore {
messages: result.messages,
state: result.state,
nudge: result.effects.nudge,
toolResultCappedCount: result.effects.toolResultCappedCount,
};
}

Expand Down Expand Up @@ -426,6 +430,7 @@ export function createCore(ports: Ports = {}): CompressionCore {
pruneNode,
filterNode,
hideCompressCallsNode,
toolResultCapNode,
recommendNode,
nudgeNode,
emergencyTruncateNode,
Expand Down Expand Up @@ -589,6 +594,29 @@ const nudgeNode: PipelineNode = {
},
};

// Hard per-tool-result token cap. Runs on EVERY turn (no usage gate) and
// ignores recency protection: a single oversized tool-result can consume a
// double-digit share of the window while total usage still reads low, so no
// percentage-gated valve can catch it. Placed after prune (no point rewriting
// messages about to disappear) and before recommend/nudge (so downstream
// token estimates reflect the capped sent view).
const toolResultCapNode: PipelineNode = {
name: "tool-result-cap",
run(io, ctx) {
const capped = capLargeToolResults(
io.messages,
ctx.config,
ctx.countTokens,
);
if (capped.cappedCount === 0) return io;
return {
...io,
messages: capped.messages,
effects: { ...io.effects, toolResultCappedCount: capped.cappedCount },
};
},
};

const emergencyTruncateNode: PipelineNode = {
name: "emergency-truncate",
run(io, ctx) {
Expand Down
11 changes: 10 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function defaultConfig(
tier2GrowthMultiplier: 1.5,
},
promotionThreshold: 5,
truncate: { threshold: 0.95 },
truncate: { threshold: 0.95, maxToolResultTokens: null },
compress: {
minCompressRange: 5000,
maxSummaryLength: 20000,
Expand Down Expand Up @@ -66,6 +66,15 @@ export function validateConfig(config: Config): string[] {
if (config.truncate.threshold <= 0 || config.truncate.threshold > 1) {
errors.push("truncate.threshold must be in (0, 1]");
}
if (
config.truncate.maxToolResultTokens != null &&
(!Number.isFinite(config.truncate.maxToolResultTokens) ||
config.truncate.maxToolResultTokens < 0)
) {
errors.push(
"truncate.maxToolResultTokens must be null (auto), 0 (disabled), or a positive number",
);
}
for (const tier of [config.tiers.tier2Trigger, config.tiers.tier3Trigger]) {
if (tier < 1) errors.push("tier triggers must be >= 1");
}
Expand Down
12 changes: 10 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,16 @@ export {
} from "./compression-rules.js";
export { defaultPrompts, resolvePrompts } from "./prompts.js";
export type { Prompts, ResolvePromptsOptions } from "./prompts.js";
export { truncateLargeToolOutputs } from "./truncate-tools.js";
export type { TruncateOptions, TruncateResult } from "./truncate-tools.js";
export {
truncateLargeToolOutputs,
capLargeToolResults,
resolveToolResultCap,
} from "./truncate-tools.js";
export type {
TruncateOptions,
TruncateResult,
ToolResultCapResult,
} from "./truncate-tools.js";
export {
parseBlockIdArg,
findBlocksOverlappingMessages,
Expand Down
1 change: 1 addition & 0 deletions src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface NodeEffects {
nudge?: NudgeDecision;
recommendation?: import("./types.js").Recommendation;
truncatedCount?: number;
toolResultCappedCount?: number;
readonly [key: string]: unknown;
}

Expand Down
140 changes: 137 additions & 3 deletions src/truncate-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,132 @@ export interface TruncateResult {
savedTokens: number;
}

export interface ToolResultCapResult {
messages: CoreMessage[];
cappedCount: number;
capTokens: number;
}

const TRUNCATION_MARKER = "[truncated for context space]";
const CAP_MARKER_PREFIX = "[acp: tool-result truncated";
const MAX_TOOL_RESULT_CAP = 16384;
const AUTO_CAP_LIMIT_RATIO = 0.1;
/** Auto-cap quantization step (power of two): learning the context limit only
* moves the cap across power-of-two boundaries, not on every limit change. */
const AUTO_CAP_QUANT_STEP = 1024;
/** A stored capped view may re-estimate slightly above the cap across turns
* (tokenizer drift); still count as already-capped below this margin. */
const ALREADY_CAPPED_MARGIN = 1.25;
/** Char prefilter: no tokenizer plausibly exceeds 4 tokens per char. */
const MAX_TOKENS_PER_CHAR = 4;
const MIN_KEEP_CHARS = 64;
const DEFAULTS = {
minOutputTokens: 1000,
keepPrefixChars: 2000,
keepSuffixChars: 2000,
protectRecentMessages: 3,
} as const;

/** Effective per-tool-result token cap. `maxToolResultTokens` null (default)
* means auto: min(10% of the model context limit, 16384), quantized down to
* a power of two. 0 disables. When the context limit is unknown (<= 0 or
* non-finite), auto falls back to the absolute ceiling — the cap is the one
* valve that must still fire. Non-finite config values also fall through to
* auto: a NaN cap compares false against every bound and would replace
* EVERY tool-result with the bare marker. */
export function resolveToolResultCap(config: Config): number {
const configured = config.truncate.maxToolResultTokens;
if (configured != null && Number.isFinite(configured)) {
return configured <= 0 ? 0 : Math.max(1, Math.floor(configured));
}
const limit = config.modelContextLimit;
if (!Number.isFinite(limit) || limit <= 0) return MAX_TOOL_RESULT_CAP;
const raw = Math.min(
MAX_TOOL_RESULT_CAP,
Math.floor(limit * AUTO_CAP_LIMIT_RATIO),
);
if (raw <= 0) return 1;
// Quantize down to a power of two (>= AUTO_CAP_QUANT_STEP) so the cap —
// and with it the truncation point of already-sent messages — moves only
// when the learned limit crosses a power-of-two boundary. Hosts that
// re-derive modelContextLimit per request would otherwise shift the cap
// every turn and break the provider prefix cache each time.
return Math.min(
raw,
Math.max(
AUTO_CAP_QUANT_STEP,
2 ** Math.floor(Math.log2(raw)),
),
);
}

/** Hard per-message guard: no single tool-result may exceed the cap in the
* outgoing context, regardless of total usage and regardless of recency
* (recent/protected messages included). Byte-based host limits and the
* usage-gated emergency truncation both missed the 2026-08-23 incident — a
* 31K-token tool-result under every byte cap while usage read 51.8%. */
export function capLargeToolResults(
messages: CoreMessage[],
config: Config,
countTokens: (text: string) => number,
): ToolResultCapResult {
const cap = resolveToolResultCap(config);
if (cap <= 0) return { messages, cappedCount: 0, capTokens: cap };

const edits = new Map<number, string>();
for (let index = 0; index < messages.length; index++) {
const message = messages[index]!;
if (message.contentType !== "tool-result") continue;
const text = message.text ?? "";
if (text.length === 0) continue;
if (text.includes(CAP_MARKER_PREFIX)) {
// Host stored the capped view back into the session: skip while
// the stored form still fits. A legitimate oversized result that
// merely QUOTES the marker string must still be capped — the bare
// substring test let marker-quoting results escape the cap
// entirely (review finding #1, 2026-08-23).
if (countTokens(text) <= cap * ALREADY_CAPPED_MARGIN) continue;
} else if (text.length * MAX_TOKENS_PER_CHAR <= cap) {
// Cheap prefilter: skips re-tokenizing every small tool-result on
// every turn (matters for host-provided BPE tokenizers).
continue;
}
const tokens = countTokens(text);
if (tokens <= cap) continue;
edits.set(index, capToTokens(text, tokens, cap, countTokens));
}

if (edits.size === 0) return { messages, cappedCount: 0, capTokens: cap };
const updated = messages.map((message, index) =>
edits.has(index) ? { ...message, text: edits.get(index)! } : message,
);
return { messages: updated, cappedCount: edits.size, capTokens: cap };
}

/** Head+tail rewrite that fits the token cap. The initial per-side char
* budget assumes ~4 chars/token; CJK-dense text (1 char/token) overshoots,
* so the loop halves until the CJK-aware count fits. */
function capToTokens(
text: string,
tokens: number,
cap: number,
countTokens: (text: string) => number,
): string {
const marker =
`\n\n...${CAP_MARKER_PREFIX}, original ~${tokens} tokens]...\n\n`;
let keepChars = Math.max(
MIN_KEEP_CHARS,
Math.floor(((cap - countTokens(marker)) / 2) * 4),
);
while (keepChars >= MIN_KEEP_CHARS) {
const replacement =
text.slice(0, keepChars) + marker + text.slice(-keepChars);
if (countTokens(replacement) <= cap) return replacement;
keepChars = Math.floor(keepChars / 2);
}
return marker;
}

export function truncateLargeToolOutputs(
messages: CoreMessage[],
tokenCount: number,
Expand All @@ -34,15 +152,31 @@ export function truncateLargeToolOutputs(
const threshold = config.truncate.threshold * config.modelContextLimit;
if (tokenCount < threshold) return { messages, truncatedCount: 0, savedTokens: 0 };

const protectedIndex = messages.length - opts.protectRecentMessages;
// Collect ALL tool-result messages as candidates — including the most recent
// ones. The old hard protection of the last N messages was counterproductive
// when the most recent messages were the largest (e.g. decompress inline
// results): it prevented truncating the very messages causing the overflow.
// Instead, the size-based sort below naturally preserves small recent
// messages and truncates large ones regardless of recency. `protectRecentMessages`
// is kept in the API for backward compatibility but no longer hard-excludes
// recent messages from candidacy.
const candidates: Array<{ index: number; tokens: number }> = [];

for (let index = 0; index < messages.length; index++) {
if (index >= protectedIndex) break;
const message = messages[index]!;
if (message.contentType !== "tool-result") continue;
const text = message.text ?? "";
if (text.length === 0 || text.includes(TRUNCATION_MARKER)) continue;
if (
text.length === 0 ||
text.includes(TRUNCATION_MARKER)
)
continue;
// NOTE: cap-marked messages ("[acp: tool-result truncated") are NOT
// skipped here. At >=95% usage the prefix is already being broken by
// design; letting emergency shrink capped-but-still-large messages is
// the last valve (review finding #4, 2026-08-23). Stacked markers are
// acceptable — this path runs at most until usage drops below the
// threshold.
const tokens = countTokens(text);
if (tokens < opts.minOutputTokens) continue;
candidates.push({ index, tokens });
Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ export interface TruncateConfig {
// context limit. Removed GC age-deactivation/summary-truncation are gone;
// this is the only "context near full" fallback that remains.
threshold: number;
/** Hard per-tool-result token cap. A single tool-result may never exceed
* this many tokens in the outgoing context, regardless of total usage or
* recency (the 2026-08-23 incident: one 31K-token minified-JS tool-result
* entered whole while total usage read 51.8%, so no usage-gated valve
* fired). null = auto: min(10% of modelContextLimit, 16384). 0 disables. */
maxToolResultTokens?: number | null;
}

export interface CompressValidationConfig {
Expand Down Expand Up @@ -259,6 +265,10 @@ export interface ProcessTurnResult {
messages: CoreMessage[];
state: CompressionState;
nudge?: NudgeDecision;
/** Tool-results rewritten by the hard per-message cap this turn. Hosts
* should log/alert on this — a recurrence of the 31K-token incident
* would otherwise be invisible. */
toolResultCappedCount?: number;
}

export interface StatusReport {
Expand Down
10 changes: 10 additions & 0 deletions tests/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,23 @@ test("defaultNodes exposes the canonical ordered pipeline", () => {
"prune",
"filter",
"hide-compress-calls",
"tool-result-cap",
"recommend",
"nudge-inject",
"emergency-truncate",
"render-refs",
]);
});

test("tool-result-cap runs after prune and before recommend/emergency-truncate", () => {
const core = createCore();
const nodes = core.defaultNodes();
const idx = (name: string) => nodes.findIndex((n) => n.name === name);
assert.ok(idx("tool-result-cap") > idx("prune"));
assert.ok(idx("tool-result-cap") < idx("recommend"));
assert.ok(idx("tool-result-cap") < idx("emergency-truncate"));
});

test("emergency-truncate is the last token-reducing node; render-refs is final", () => {
const core = createCore();
const nodes = core.defaultNodes();
Expand Down
Loading