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
26 changes: 22 additions & 4 deletions src/absorb.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { rawForRef, refForRaw, BLOCKED_REF } from "./refs.js";
import { ACP_TOOL_NAMES, ABSORB_TOOL_NAME } from "./compress-tools.js";
import { isMessageProtected, matchToolPattern } from "./protected.js";
import {
collectLatestProtected,
isMessageLatestProtected,
isMessageProtected,
matchToolPattern,
type LatestProtected,
} from "./protected.js";
import type {
AbsorbConfig,
AbsorbRecord,
Expand Down Expand Up @@ -74,11 +80,16 @@ function isAcpOrConfiguredTool(

/** True when a tool-result message is in scope for absorption prompting:
* a tool-result of a non-ACP, non-excluded, non-protected tool. */
export function isAbsorbCandidate(msg: CoreMessage, config: Config): boolean {
export function isAbsorbCandidate(
msg: CoreMessage,
config: Config,
latest?: LatestProtected,
): boolean {
if (msg.contentType !== "tool-result" || !msg.toolCallId) return false;
const cfg = resolveAbsorbConfig(config);
if (isAcpOrConfiguredTool(msg.toolName, cfg)) return false;
if (isMessageProtected(msg, config)) return false;
if (latest && isMessageLatestProtected(msg, latest)) return false;
for (const pattern of cfg.excludeTools) {
if (msg.toolName && matchToolPattern(msg.toolName, pattern)) return false;
}
Expand Down Expand Up @@ -136,8 +147,9 @@ export function appendAbsorbPrompts(
}

let promptedCount = 0;
const latest = collectLatestProtected(messages, config);
const out = messages.map((msg) => {
if (!isAbsorbCandidate(msg, config)) return msg;
if (!isAbsorbCandidate(msg, config, latest)) 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 @@ -284,7 +296,13 @@ export function applyAbsorb(input: AbsorbInput): AbsorbOutcome {
resultText: `absorb failed: ${target.toolName} is an ACP-managed tool result — it is not absorbable.`,
};
}
if (isMessageProtected(target, input.config)) {
if (
isMessageProtected(target, input.config) ||
isMessageLatestProtected(
target,
collectLatestProtected(input.messages, input.config),
)
) {
return {
state: input.state,
ok: false,
Expand Down
24 changes: 20 additions & 4 deletions src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import { appendAbsorbPrompts, hideAbsorbedMessages } from "./absorb.js";
import { applyMessageFilters, listMessageFilters } from "./filter/index.js";
import { createRenderRefsNode } from "./render-refs.js";
import type { RenderStrategy } from "./render-refs.js";
import { isMessageProtected } from "./protected.js";
import {
collectLatestProtected,
isMessageLatestProtected,
isMessageProtected,
} from "./protected.js";
import { adjustBoundariesForToolPairs } from "./tool-pairs.js";
import { adjustBoundariesForReasoningPairs } from "./reasoning-pairs.js";
import {
Expand Down Expand Up @@ -500,9 +504,16 @@ const assignRefsNode: PipelineNode = {
name: "assign-refs",
run(io, ctx) {
const hasProtection =
ctx.config.protectedTools.length > 0 || !!ctx.config.isToolProtected;
ctx.config.protectedTools.length > 0 ||
!!ctx.config.isToolProtected ||
(ctx.config.protectedLatestTools?.length ?? 0) > 0;
const latest = hasProtection
? collectLatestProtected(io.messages, ctx.config)
: undefined;
const protectedFn = hasProtection
? (m: CoreMessage) => isMessageProtected(m, ctx.config)
? (m: CoreMessage) =>
isMessageProtected(m, ctx.config) ||
(latest ? isMessageLatestProtected(m, latest) : false)
: undefined;
const refResult = assignRefs(io.messages, {
existing: io.state.messageRefs,
Expand Down Expand Up @@ -992,6 +1003,8 @@ function filterProtectedToolMessages(
// nothing auto-appended.
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) {
protectedCallIds.add(msg.toolCallId);
Expand All @@ -1001,7 +1014,10 @@ function filterProtectedToolMessages(
for (const id of directMessageIds) {
const msg = messages.find((m) => m.id === id);
if (!msg) continue;
if (isMessageProtected(msg, config)) {
if (
isMessageProtected(msg, config) ||
isMessageLatestProtected(msg, latest)
) {
removedIds.add(id);
if (msg.toolCallId) protectedCallIds.add(msg.toolCallId);
}
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function defaultConfig(
minSummaryLength: 50,
},
protectedTools: [],
protectedLatestTools: [],
preserveRecentMessages: 5,
preserveRecentTokens: 5000,
modelContextLimit,
Expand Down
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,13 @@ export {
getSearchAlgorithm,
listSearchAlgorithms,
} from "./search.js";
export { isMessageProtected, matchToolPattern } from "./protected.js";
export {
collectLatestProtected,
isMessageLatestProtected,
isMessageProtected,
matchToolPattern,
type LatestProtected,
} from "./protected.js";
export {
runPipeline,
makeIO,
Expand Down
57 changes: 57 additions & 0 deletions src/protected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,60 @@ export function isMessageProtectedWithPairing(
}
return false;
}

/** "Latest only" protection set: for each protectedLatestTools pattern, the
* LAST tool-call matching it (in message order) plus its paired result. Older
* instances of the same tool stay compressible. Use for cumulative-snapshot
* tools (e.g. todo_list) where only the newest result is the source of truth
* and every older result is strictly redundant.
*
* `callIds` holds the latest calls' toolCallIds (pairing covers the result
* half, including results projected without a toolName); `msgIds` holds
* latest calls that lack a toolCallId (pairing impossible — protect by id). */
export interface LatestProtected {
callIds: Set<string>;
msgIds: Set<string>;
}

export function collectLatestProtected(
messages: CoreMessage[],
config: Pick<Config, "protectedLatestTools">,
): LatestProtected {
const callIds = new Set<string>();
const msgIds = new Set<string>();
const patterns = config.protectedLatestTools ?? [];
if (patterns.length === 0) return { callIds, msgIds };
for (const pattern of patterns) {
let last: CoreMessage | undefined;
for (const m of messages) {
if (
m.contentType === "tool-call" &&
m.toolName &&
matchToolPattern(m.toolName, pattern)
) {
last = m;
}
}
if (!last) continue;
if (last.toolCallId) callIds.add(last.toolCallId);
else msgIds.add(last.id);
}
return { callIds, msgIds };
}

/** True when msg is a latest-protected tool-call, or the tool-result paired to
* one (by toolCallId). */
export function isMessageLatestProtected(
msg: CoreMessage,
latest: LatestProtected,
): boolean {
if (msg.contentType === "tool-call" && latest.msgIds.has(msg.id)) return true;
if (
(msg.contentType === "tool-call" || msg.contentType === "tool-result") &&
msg.toolCallId &&
latest.callIds.has(msg.toolCallId)
) {
return true;
}
return false;
}
12 changes: 11 additions & 1 deletion src/recommend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import type {
} from "./types.js";
import type { CompressionState } from "./types.js";
import {
collectLatestProtected,
collectProtectedToolCallIds,
isMessageLatestProtected,
isMessageProtectedWithPairing,
isNeverPreserveRecent,
} from "./protected.js";
Expand Down Expand Up @@ -164,6 +166,11 @@ 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);
// 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).
const latest = collectLatestProtected(messages, config);
for (const id of latest.callIds) protectedCallIds.add(id);

// Segmentation is array adjacency, never ref arithmetic: surface-replacing
// hosts leave holes in the ref map (compressed messages leave the array, refs
Expand All @@ -184,7 +191,10 @@ export function buildCompressibleRanges(
continue;
}

if (isMessageProtectedWithPairing(msg, config, protectedCallIds)) {
if (
isMessageProtectedWithPairing(msg, config, protectedCallIds) ||
isMessageLatestProtected(msg, latest)
) {
protectedMsgs.push({
ref,
gapBefore: skipSinceProtected,
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ export interface Config {
compress: CompressValidationConfig;
protectedTools: string[];
isToolProtected?: (toolName: string, toolInputText?: string) => boolean;
/** Tool-name patterns (glob suffix allowed) protected in their LATEST
* instance only: the newest matching tool-call and its paired tool-result
* are protected from compression; older instances remain compressible. For
* cumulative-snapshot tools (e.g. todo_list) where only the newest result
* is the source of truth. */
protectedLatestTools?: string[];
preserveRecentMessages: number;
preserveRecentTokens: number;
modelContextLimit: number;
Expand Down
Loading
Loading