Compare commits

...
8 changed files with 1528 additions and 6 deletions
@@ -6,6 +6,10 @@ import type {
CoreCompactionSummarizerConfig,
} from "../../types/config";
import type { ProviderConfig } from "../../types/provider-settings";
import {
buildBudgetProjection,
type BudgetProjectionResult,
} from "./budget-projection";
import {
buildSummaryMessage,
buildSummaryRequest,
@@ -20,6 +24,43 @@ import {
serializeConversation,
} from "./compaction-shared";
const MIN_AGENTIC_SUMMARY_INPUT_TOKENS = 1_024;
function resolveProviderMaxInputTokens(
providerConfig: ProviderConfig,
): number | undefined {
const explicit = providerConfig.maxInputTokens;
if (typeof explicit === "number" && Number.isFinite(explicit)) {
return explicit;
}
const modelInfoLimit =
providerConfig.modelInfo?.maxInputTokens ??
providerConfig.modelInfo?.contextWindow;
if (typeof modelInfoLimit === "number" && Number.isFinite(modelInfoLimit)) {
return modelInfoLimit;
}
const knownModelInfo = providerConfig.knownModels?.[providerConfig.modelId];
const knownModelLimit =
knownModelInfo?.maxInputTokens ?? knownModelInfo?.contextWindow;
if (typeof knownModelLimit === "number" && Number.isFinite(knownModelLimit)) {
return knownModelLimit;
}
return undefined;
}
export function buildAgenticSummaryInputBudget(options: {
messages: CoreCompactionContext["messages"];
targetTokens: number;
estimateMessageTokens: EstimateMessageTokens;
}): BudgetProjectionResult {
return buildBudgetProjection({
messages: options.messages,
targetTokens: Math.max(1, options.targetTokens),
policyIntent: "agentic_summary",
estimateMessageTokens: options.estimateMessageTokens,
});
}
async function generateSummary(options: {
providerConfig: ProviderConfig;
request: string;
@@ -92,8 +133,80 @@ export async function runAgenticCompaction(options: {
return undefined;
}
const fileOps = extractFileOps(messagesToSummarize);
const conversationText = serializeConversation(newMessagesToFold);
const preProjectionFileOps = extractFileOps(messagesToSummarize);
const summarizerProviderConfig = resolveSummarizerConfig({
activeProviderConfig: options.providerConfig,
summarizer: options.summarizer,
});
const resolvedSummarizerInputLimit = resolveProviderMaxInputTokens(
summarizerProviderConfig,
);
const canUseActiveContextLimit = options.summarizer === undefined;
const activeCompactionInputLimit = Math.max(
options.context.maxInputTokens,
options.context.triggerTokens,
MIN_AGENTIC_SUMMARY_INPUT_TOKENS,
);
if (
resolvedSummarizerInputLimit === undefined &&
!canUseActiveContextLimit
) {
options.logger?.log(
"Agentic compaction summarizer has no known input limit; using conservative summary budget",
{
severity: "warn",
summarizerProviderId: summarizerProviderConfig.providerId,
summarizerModelId: summarizerProviderConfig.modelId,
fallbackInputLimit: MIN_AGENTIC_SUMMARY_INPUT_TOKENS,
},
);
}
const summarizerInputLimit =
resolvedSummarizerInputLimit ??
(canUseActiveContextLimit
? activeCompactionInputLimit
: MIN_AGENTIC_SUMMARY_INPUT_TOKENS);
const summaryRequestOverheadTokens = estimateTokens(
buildSummaryRequest({
previousSummary,
conversationText: "",
fileOps: preProjectionFileOps,
}).length,
);
const availableSummaryInputTokens =
summarizerInputLimit - summaryRequestOverheadTokens;
if (availableSummaryInputTokens <= 0) {
options.logger?.debug("Skipped agentic compaction: summarizer budget exhausted", {
summarizerProviderId: summarizerProviderConfig.providerId,
summarizerModelId: summarizerProviderConfig.modelId,
summarizerInputLimit,
summaryRequestOverheadTokens,
});
return undefined;
}
const summaryInputBudget = buildAgenticSummaryInputBudget({
messages: newMessagesToFold,
targetTokens: availableSummaryInputTokens,
estimateMessageTokens: options.estimateMessageTokens,
});
if (summaryInputBudget.status === "failed") {
options.logger?.log(
"Skipped agentic compaction: summary input budget failed",
{
severity: "warn",
budgetWarnings: summaryInputBudget.warnings.map(
(warning) => warning.code,
),
summaryInputEstimatedTokens: summaryInputBudget.estimatedTokens,
targetTokens: availableSummaryInputTokens,
summarizerProviderId: summarizerProviderConfig.providerId,
summarizerModelId: summarizerProviderConfig.modelId,
},
);
return undefined;
}
const fileOps = extractFileOps(summaryInputBudget.messages);
const conversationText = serializeConversation(summaryInputBudget.messages);
const summaryRequest = buildSummaryRequest({
previousSummary,
conversationText,
@@ -108,14 +221,20 @@ export async function runAgenticCompaction(options: {
summaryRequestChars: summaryRequest.length,
summaryRequestEstimatedTokens: estimateTokens(summaryRequest.length),
newMessagesJsonChars: safeJsonSize(newMessagesToFold),
summaryInputEstimatedTokens: summaryInputBudget.estimatedTokens,
summaryInputActions: summaryInputBudget.actions.length,
summaryInputWarnings: summaryInputBudget.warnings.map(
(warning) => warning.code,
),
summaryRequestOverheadTokens,
summarizerProviderId: summarizerProviderConfig.providerId,
summarizerModelId: summarizerProviderConfig.modelId,
summarizerInputLimit,
maxInputTokens: options.context.maxInputTokens,
triggerTokens: options.context.triggerTokens,
});
const rawSummary = await generateSummary({
providerConfig: resolveSummarizerConfig({
activeProviderConfig: options.providerConfig,
summarizer: options.summarizer,
}),
providerConfig: summarizerProviderConfig,
request: summaryRequest,
logger: options.logger,
});
@@ -0,0 +1,17 @@
export {
buildBudgetProjection,
findLatestTypedUserMessageIndex,
} from "./project";
export type {
BlockBudgetClass,
BudgetAction,
BudgetActionKind,
BudgetActionReason,
BudgetPath,
BudgetPolicyIntent,
BudgetProjectionOptions,
BudgetProjectionResult,
BudgetProjectionWarning,
ContentBlockBudgetClassification,
LiveTailHandling,
} from "./types";
@@ -0,0 +1,476 @@
import type { MessageWithMetadata } from "@cline/shared";
import { describe, expect, it } from "vitest";
import {
buildBudgetProjection,
findLatestTypedUserMessageIndex,
} from "./project";
const estimateChars = (message: MessageWithMetadata) =>
JSON.stringify(message).length;
describe("buildBudgetProjection", () => {
it("fails explicitly for impossible budgets", () => {
const result = buildBudgetProjection({
messages: [{ role: "user", content: "keep me" }],
targetTokens: 0,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
expect(result.status).toBe("failed");
expect(result.messages).toHaveLength(1);
expect(result.warnings[0]?.code).toBe("budget_impossible");
});
it("drops unsafe image and redacted thinking blocks instead of truncating them", () => {
const result = buildBudgetProjection({
messages: [
{
role: "assistant",
content: [
{ type: "text", text: "old context" },
{
type: "redacted_thinking",
data: "x".repeat(500),
},
],
},
{
role: "assistant",
content: [
{
type: "image",
data: "y".repeat(500),
mediaType: "image/png",
},
],
},
{ role: "user", content: "latest task" },
],
targetTokens: 150,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).not.toContain("redacted_thinking");
expect(serialized).not.toContain("image/png");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "dropped_block",
reason: "unsafe_to_truncate",
}),
]),
);
expect(result.liveTailHandling).toBe("included_degraded");
});
it("keeps unsafe blocks when input is already under budget", () => {
const result = buildBudgetProjection({
messages: [
{
role: "user",
content: [
{ type: "text", text: "look at this" },
{
type: "image",
data: "small-image",
mediaType: "image/png",
},
],
},
],
targetTokens: 1_000,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
expect(result.status).toBe("ok");
expect(result.actions).toEqual([]);
expect(result.liveTailHandling).toBe("included_verbatim");
expect(JSON.stringify(result.messages)).toContain("small-image");
});
it("preserves unsafe blocks in the latest typed user message", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "old task " + "x".repeat(500) },
{
role: "user",
content: [
{ type: "text", text: "what is in this image?" },
{
type: "image",
data: "live-image",
mediaType: "image/png",
},
],
},
],
targetTokens: 120,
policyIntent: "basic_compaction_projection",
estimateMessageTokens: estimateChars,
});
expect(JSON.stringify(result.messages)).toContain("live-image");
expect(result.actions).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "dropped_block" }),
]),
);
});
it("protects latest typed user after thinking-only messages are pruned", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "old task" },
{
role: "assistant",
content: [{ type: "thinking", thinking: "discard me" }],
},
{
role: "user",
content: [
{ type: "text", text: "what is in this image?" },
{
type: "image",
data: "live-image",
mediaType: "image/png",
},
],
},
],
targetTokens: 1_000,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).toContain("live-image");
expect(serialized).not.toContain("discard me");
});
it("keeps tool-use and tool-result pairs coherent when dropping history", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "original task" },
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_1",
name: "read_files",
input: { file_paths: ["/tmp/a.ts"] },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "read_files",
content: "x".repeat(1000),
},
],
},
{ role: "user", content: "latest task" },
],
targetTokens: 140,
policyIntent: "basic_compaction_projection",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).not.toContain("tool_1");
expect(serialized).toContain("latest task");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({ reason: "tool_pair_boundary" }),
]),
);
});
it("records budget action paths against original message indexes", () => {
const result = buildBudgetProjection({
messages: [
{
role: "assistant",
content: [{ type: "image", data: "x", mediaType: "image/png" }],
},
{ role: "user", content: "old task " + "x".repeat(500) },
{ role: "assistant", content: "old answer " + "y".repeat(500) },
{ role: "user", content: "latest task" },
],
targetTokens: 80,
policyIntent: "basic_compaction_projection",
estimateMessageTokens: estimateChars,
});
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "preserved",
path: expect.objectContaining({ messageIndex: 1 }),
}),
expect.objectContaining({
kind: "dropped_message",
path: expect.objectContaining({ messageIndex: 2 }),
}),
]),
);
});
it("detects the latest typed user message when tool results follow it", () => {
const messages: MessageWithMetadata[] = [
{ role: "user", content: "old task" },
{ role: "user", content: "latest typed prompt" },
{
role: "assistant",
content: [
{ type: "tool_use", id: "tool_1", name: "read", input: {} },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "read",
content: "result",
},
],
},
];
expect(findLatestTypedUserMessageIndex(messages)).toBe(1);
});
it("preserves the latest typed prompt under pressure", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "old task " + "x".repeat(500) },
{ role: "user", content: "latest typed prompt" },
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "read",
content: "result " + "y".repeat(500),
},
],
},
],
targetTokens: 120,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
expect(JSON.stringify(result.messages)).toContain("latest typed prompt");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({ reason: "protected_live_tail" }),
]),
);
});
it("drops completed tool pairs after the latest typed prompt", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "old task " + "x".repeat(500) },
{ role: "user", content: "latest typed prompt" },
{
role: "assistant",
content: [
{ type: "tool_use", id: "tool_after", name: "read", input: {} },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_after",
name: "read",
content: "huge result " + "y".repeat(2_000),
},
],
},
],
targetTokens: 140,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).toContain("latest typed prompt");
expect(serialized).not.toContain("tool_after");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "dropped_message",
reason: "tool_pair_boundary",
path: expect.objectContaining({ messageIndex: 2 }),
}),
expect.objectContaining({
kind: "dropped_message",
reason: "tool_pair_boundary",
path: expect.objectContaining({ messageIndex: 3 }),
}),
]),
);
});
it("preserves unresolved tool use after the latest typed prompt", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "old task " + "x".repeat(500) },
{ role: "user", content: "latest typed prompt" },
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_live",
name: "run_command",
input: { command: "sleep 1" },
},
],
},
],
targetTokens: 80,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).toContain("latest typed prompt");
expect(serialized).toContain("tool_live");
expect(result.status).toBe("failed");
expect(result.warnings[0]?.code).toBe(
"budget_unachievable_with_protections",
);
});
it("does not preserve later text or file blocks after tool-result budget is exhausted", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "latest typed prompt" },
{
role: "assistant",
content: [
{ type: "tool_use", id: "tool_live", name: "read", input: {} },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_live",
name: "read",
content: [
{ type: "text", text: "a".repeat(200) },
{ type: "file", path: "/tmp/huge.txt", content: "b".repeat(1_000) },
],
},
],
},
],
targetTokens: 260,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).toContain("latest typed prompt");
expect(serialized).not.toContain("b".repeat(100));
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "truncated_text",
reason: "over_budget",
}),
]),
);
});
it("drops thinking blocks instead of mutating provider-native reasoning", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "latest typed prompt" },
{
role: "assistant",
content: [
{ type: "text", text: "a".repeat(1_000) },
{ type: "thinking", thinking: "b".repeat(1_000) },
],
},
],
targetTokens: 900,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const assistant = result.messages.find(
(message) => message.role === "assistant",
);
expect(JSON.stringify(assistant)).not.toContain("b".repeat(100));
expect(JSON.stringify(assistant)).not.toContain("\"thinking\"");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "dropped_block",
reason: "unsafe_to_truncate",
}),
]),
);
});
it("drops nested unsafe tool-result blocks outside the protected tail", () => {
const result = buildBudgetProjection({
messages: [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_old",
name: "read",
content: [
{ type: "text", text: "old output" },
{
type: "image",
data: "old-image-data",
mediaType: "image/png",
},
],
},
],
},
{ role: "user", content: "latest typed prompt" },
],
targetTokens: 1_000,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).toContain("old output");
expect(serialized).not.toContain("old-image-data");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "dropped_block",
reason: "unsafe_to_truncate",
}),
]),
);
});
});
@@ -0,0 +1,670 @@
import type {
ContentBlock,
MessageWithMetadata,
ToolResultContent,
} from "@cline/shared";
import type {
BudgetAction,
BudgetMutationAction,
BudgetProjectionOptions,
BudgetProjectionResult,
BudgetProjectionWarning,
BudgetPolicyIntent,
} from "./types";
type EstimateMessageTokens = (message: MessageWithMetadata) => number;
interface ProjectionPolicy {
protectLatestTypedUser: boolean;
protectLiveTailFromDrop: boolean;
dropUnsafeOutsideLiveTail: boolean;
dropThinkingBlocks: boolean;
}
function resolveProjectionPolicy(
intent: BudgetPolicyIntent,
): ProjectionPolicy {
switch (intent) {
case "agentic_summary":
case "basic_compaction_projection":
return {
protectLatestTypedUser: true,
protectLiveTailFromDrop: true,
dropUnsafeOutsideLiveTail: true,
dropThinkingBlocks: true,
};
case "normal_provider_request":
return {
protectLatestTypedUser: true,
protectLiveTailFromDrop: true,
dropUnsafeOutsideLiveTail: false,
dropThinkingBlocks: false,
};
}
}
function cloneMessages(messages: MessageWithMetadata[]): MessageWithMetadata[] {
return messages.map((message) => ({
...message,
content: Array.isArray(message.content)
? message.content.map((block) => ({ ...block }) as ContentBlock)
: message.content,
...(message.metadata ? { metadata: { ...message.metadata } } : {}),
}));
}
function safeJsonSize(value: unknown): number {
try {
return JSON.stringify(value).length;
} catch {
return String(value).length;
}
}
function totalTokens(
messages: MessageWithMetadata[],
estimateMessageTokens: EstimateMessageTokens,
): number {
return messages.reduce(
(total, message) => total + estimateMessageTokens(message),
0,
);
}
function isToolResultOnlyUserMessage(message: MessageWithMetadata): boolean {
return (
message.role === "user" &&
Array.isArray(message.content) &&
message.content.length > 0 &&
message.content.every((block) => block.type === "tool_result")
);
}
export function findLatestTypedUserMessageIndex(
messages: MessageWithMetadata[],
): number {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role === "user" && !isToolResultOnlyUserMessage(message)) {
return index;
}
}
return -1;
}
function findFirstTypedUserMessageIndex(
messages: MessageWithMetadata[],
): number {
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
if (message.role === "user" && !isToolResultOnlyUserMessage(message)) {
return index;
}
}
return -1;
}
function collectToolIds(message: MessageWithMetadata): Set<string> {
const ids = new Set<string>();
if (!Array.isArray(message.content)) {
return ids;
}
for (const block of message.content) {
if (block.type === "tool_use") {
ids.add(block.id);
} else if (block.type === "tool_result") {
ids.add(block.tool_use_id);
}
}
return ids;
}
function buildToolPairIndex(
messages: MessageWithMetadata[],
): Map<string, Set<number>> {
const index = new Map<string, Set<number>>();
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
for (const id of collectToolIds(messages[messageIndex])) {
const existing = index.get(id);
if (existing) {
existing.add(messageIndex);
} else {
index.set(id, new Set([messageIndex]));
}
}
}
return index;
}
function findProtectedTailStartIndex(messages: MessageWithMetadata[]): number {
const resolvedToolUseIds = new Set<string>();
for (const message of messages) {
if (!Array.isArray(message.content)) {
continue;
}
for (const block of message.content) {
if (block.type === "tool_result") {
resolvedToolUseIds.add(block.tool_use_id);
}
}
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (!Array.isArray(message.content)) {
continue;
}
if (
message.content.some(
(block) =>
block.type === "tool_use" && !resolvedToolUseIds.has(block.id),
)
) {
return index;
}
}
return messages.length;
}
function collectMessageClosure(
messages: MessageWithMetadata[],
startIndex: number,
): Set<number> {
const pairIndex = buildToolPairIndex(messages);
const removal = new Set<number>();
const queue = [startIndex];
while (queue.length > 0) {
const index = queue.shift();
if (index === undefined || removal.has(index)) {
continue;
}
removal.add(index);
for (const id of collectToolIds(messages[index])) {
for (const linked of pairIndex.get(id) ?? []) {
if (!removal.has(linked)) {
queue.push(linked);
}
}
}
}
return removal;
}
function isUnsafeBlock(block: ContentBlock): boolean {
return block.type === "image" || block.type === "redacted_thinking";
}
function isNestedUnsafeToolResultBlock(
block: Extract<ToolResultContent["content"], unknown[]>[number],
): boolean {
return block.type === "image";
}
function shouldDropWholeBlock(
block: ContentBlock,
policy: ProjectionPolicy,
isProtected: boolean,
): boolean {
if (policy.dropThinkingBlocks && block.type === "thinking") {
return true;
}
return policy.dropUnsafeOutsideLiveTail && !isProtected && isUnsafeBlock(block);
}
function pruneEmptyMessages(
messages: MessageWithMetadata[],
originalIndexes: number[],
actions: BudgetAction[],
reason: BudgetMutationAction["reason"] = "over_budget",
): { messages: MessageWithMetadata[]; originalIndexes: number[] } {
const next: MessageWithMetadata[] = [];
const nextOriginalIndexes: number[] = [];
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
if (Array.isArray(message.content) && message.content.length === 0) {
actions.push({
kind: "dropped_message",
path: { messageIndex: originalIndexes[index] },
reason,
originalSize: safeJsonSize(message),
finalSize: 0,
});
continue;
}
next.push(message);
nextOriginalIndexes.push(originalIndexes[index]);
}
return { messages: next, originalIndexes: nextOriginalIndexes };
}
function dropUnsafeBlocks(
messages: MessageWithMetadata[],
originalIndexes: number[],
actions: BudgetAction[],
latestTypedUserIndex: number,
protectedTailStartIndex: number,
policy: ProjectionPolicy,
): MessageWithMetadata[] {
return messages.map((message, messageIndex) => {
if (!Array.isArray(message.content)) {
return message;
}
let changed = false;
const protectedBlock =
messageIndex === latestTypedUserIndex ||
messageIndex >= protectedTailStartIndex;
const content = message.content.flatMap((block, blockIndex) => {
if (shouldDropWholeBlock(block, policy, protectedBlock)) {
changed = true;
actions.push({
kind: "dropped_block",
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
reason: "unsafe_to_truncate",
originalSize: safeJsonSize(block),
finalSize: 0,
});
return [];
}
if (block.type === "tool_result" && Array.isArray(block.content)) {
const nestedContent = block.content.filter((nestedBlock) => {
if (
policy.dropUnsafeOutsideLiveTail &&
!protectedBlock &&
isNestedUnsafeToolResultBlock(nestedBlock)
) {
return false;
}
return true;
});
if (nestedContent.length !== block.content.length) {
changed = true;
const nextBlock = { ...block, content: nestedContent };
actions.push({
kind: "dropped_block",
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
reason: "unsafe_to_truncate",
originalSize: safeJsonSize(block),
finalSize: safeJsonSize(nextBlock),
});
return [nextBlock];
}
}
return [block];
});
return changed ? { ...message, content } : message;
});
}
function dropThinkingBlocks(
messages: MessageWithMetadata[],
originalIndexes: number[],
actions: BudgetAction[],
): MessageWithMetadata[] {
return messages.map((message, messageIndex) => {
if (!Array.isArray(message.content)) {
return message;
}
let changed = false;
const content = message.content.filter((block, blockIndex) => {
if (block.type !== "thinking") {
return true;
}
changed = true;
actions.push({
kind: "dropped_block",
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
reason: "unsafe_to_truncate",
originalSize: safeJsonSize(block),
finalSize: 0,
});
return false;
});
return changed ? { ...message, content } : message;
});
}
function truncateText(text: string, maxChars: number): string {
if (maxChars <= 0) {
return "";
}
if (text.length <= maxChars) {
return text;
}
if (maxChars <= 16) {
return text.slice(0, Math.max(1, maxChars));
}
const estimateMarker = `\n...[truncated ${text.length - maxChars} chars]`;
const keep = Math.max(1, maxChars - estimateMarker.length);
const marker = `\n...[truncated ${text.length - keep} chars]`;
return `${text.slice(0, keep)}${marker}`;
}
function truncateToolResultContent(
content: ToolResultContent["content"],
maxChars: number,
): ToolResultContent["content"] {
if (typeof content === "string") {
return truncateText(content, maxChars);
}
let remaining = maxChars;
return content.map((block) => {
if (remaining <= 0) {
if (block.type === "text") {
return { ...block, text: "" };
}
if (block.type === "file") {
return { ...block, content: "" };
}
return block;
}
if (block.type === "text") {
const text = truncateText(block.text, remaining);
remaining -= text.length;
return { ...block, text };
}
if (block.type === "file") {
const content = truncateText(block.content, remaining);
remaining -= content.length;
return { ...block, content };
}
return block;
});
}
function toolResultTextLength(content: ToolResultContent["content"]): number {
if (typeof content === "string") {
return content.length;
}
return content.reduce((total, block) => {
if (block.type === "text") {
return total + block.text.length;
}
if (block.type === "file") {
return total + block.content.length;
}
return total;
}, 0);
}
function truncateMessageText(
message: MessageWithMetadata,
maxChars: number,
): MessageWithMetadata {
if (typeof message.content === "string") {
return { ...message, content: truncateText(message.content, maxChars) };
}
let remaining = maxChars;
return {
...message,
content: message.content.map((block) => {
if (remaining <= 0) {
if (block.type === "text") {
return { ...block, text: "" };
}
if (block.type === "file") {
return { ...block, content: "" };
}
if (block.type === "tool_result") {
return {
...block,
content: truncateToolResultContent(block.content, 0),
};
}
return block;
}
if (block.type === "text") {
const text = truncateText(block.text, remaining);
remaining -= text.length;
return { ...block, text };
}
if (block.type === "file") {
const content = truncateText(block.content, remaining);
remaining -= content.length;
return { ...block, content };
}
if (block.type === "tool_result") {
const content = truncateToolResultContent(block.content, remaining);
remaining -= toolResultTextLength(content);
return { ...block, content };
}
return block;
}),
};
}
function hasTruncatableText(message: MessageWithMetadata): boolean {
if (typeof message.content === "string") {
return message.content.length > 0;
}
return message.content.some(
(block) =>
block.type === "text" ||
block.type === "file" ||
block.type === "tool_result",
);
}
function removeMessagesAt(
messages: MessageWithMetadata[],
originalIndexes: number[],
removal: Set<number>,
): { messages: MessageWithMetadata[]; originalIndexes: number[] } {
return {
messages: messages.filter((_, index) => !removal.has(index)),
originalIndexes: originalIndexes.filter((_, index) => !removal.has(index)),
};
}
function closureTouchesProtectedTail(
closure: Set<number>,
protectedStartIndex: number,
): boolean {
if (protectedStartIndex < 0) {
return false;
}
for (const removalIndex of closure) {
if (removalIndex >= protectedStartIndex) {
return true;
}
}
return false;
}
function closureTouchesPinnedMessage(
closure: Set<number>,
pinnedIndex: number,
): boolean {
return pinnedIndex >= 0 && closure.has(pinnedIndex);
}
export function buildBudgetProjection(
options: BudgetProjectionOptions,
): BudgetProjectionResult {
const actions: BudgetAction[] = [];
const warnings: BudgetProjectionWarning[] = [];
const policy = resolveProjectionPolicy(options.policyIntent);
if (options.targetTokens <= 0) {
return {
status: "failed",
messages: cloneMessages(options.messages),
actions,
liveTailHandling: "preserved_out_of_band",
estimatedTokens: totalTokens(
options.messages,
options.estimateMessageTokens,
),
warnings: [
{
code: "budget_impossible",
message: "Target budget must be greater than zero.",
},
],
};
}
let messages = cloneMessages(options.messages);
let originalIndexes = messages.map((_, index) => index);
if (policy.dropThinkingBlocks) {
const prunedThinking = pruneEmptyMessages(
dropThinkingBlocks(messages, originalIndexes, actions),
originalIndexes,
actions,
"unsafe_to_truncate",
);
messages = prunedThinking.messages;
originalIndexes = prunedThinking.originalIndexes;
}
const latestTypedUserIndex = policy.protectLatestTypedUser
? findLatestTypedUserMessageIndex(messages)
: -1;
const protectedTailStartIndex = policy.protectLiveTailFromDrop
? findProtectedTailStartIndex(messages)
: messages.length;
if (policy.dropUnsafeOutsideLiveTail) {
const prunedUnsafe = pruneEmptyMessages(
dropUnsafeBlocks(
messages,
originalIndexes,
actions,
latestTypedUserIndex,
protectedTailStartIndex,
policy,
),
originalIndexes,
actions,
);
messages = prunedUnsafe.messages;
originalIndexes = prunedUnsafe.originalIndexes;
}
let estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
if (estimatedTokens <= options.targetTokens) {
return {
status: "ok",
messages,
actions,
liveTailHandling:
actions.length > 0 ? "included_degraded" : "included_verbatim",
estimatedTokens,
warnings,
};
}
for (
let index = messages.length - 1;
index >= 0 && estimatedTokens > options.targetTokens;
index -= 1
) {
const latestTypedUserIndex = findLatestTypedUserMessageIndex(messages);
if (index === latestTypedUserIndex) {
continue;
}
if (
policy.protectLiveTailFromDrop &&
index >= findProtectedTailStartIndex(messages)
) {
continue;
}
if (!hasTruncatableText(messages[index])) {
continue;
}
const originalSize = safeJsonSize(messages[index]);
const charsPerToken = Math.max(
1,
originalSize /
Math.max(1, options.estimateMessageTokens(messages[index])),
);
const targetChars = Math.max(
16,
Math.floor(
(options.targetTokens * charsPerToken) /
Math.max(1, messages.length),
),
);
messages[index] = truncateMessageText(messages[index], targetChars);
actions.push({
kind: "truncated_text",
path: { messageIndex: originalIndexes[index] },
reason: "over_budget",
originalSize,
finalSize: safeJsonSize(messages[index]),
});
estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
}
for (
let index = 0;
index < messages.length && estimatedTokens > options.targetTokens;
) {
const firstTypedUserIndex = findFirstTypedUserMessageIndex(messages);
const latestTypedUserIndex = findLatestTypedUserMessageIndex(messages);
const protectedStartIndex = policy.protectLiveTailFromDrop
? findProtectedTailStartIndex(messages)
: messages.length;
if (index === firstTypedUserIndex || index === latestTypedUserIndex) {
actions.push({
kind: "preserved",
path: { messageIndex: originalIndexes[index] },
reason: "protected_live_tail",
originalSize: safeJsonSize(messages[index]),
finalSize: safeJsonSize(messages[index]),
});
index += 1;
continue;
}
const closure = collectMessageClosure(messages, index);
if (closureTouchesPinnedMessage(closure, firstTypedUserIndex)) {
index += 1;
continue;
}
if (closureTouchesPinnedMessage(closure, latestTypedUserIndex)) {
index += 1;
continue;
}
if (closureTouchesProtectedTail(closure, protectedStartIndex)) {
index += 1;
continue;
}
for (const removalIndex of closure) {
actions.push({
kind: "dropped_message",
path: { messageIndex: originalIndexes[removalIndex] },
reason:
closure.size > 1 || collectToolIds(messages[removalIndex]).size > 0
? "tool_pair_boundary"
: "over_budget",
originalSize: safeJsonSize(messages[removalIndex]),
finalSize: 0,
});
}
const removed = removeMessagesAt(messages, originalIndexes, closure);
messages = removed.messages;
originalIndexes = removed.originalIndexes;
estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
}
if (estimatedTokens > options.targetTokens) {
warnings.push({
code: "budget_unachievable_with_protections",
message:
"Projection could not reach budget without violating protected content.",
});
return {
status: "failed",
messages,
actions,
liveTailHandling: "included_degraded",
estimatedTokens,
warnings,
};
}
return {
status: "ok",
messages,
actions,
liveTailHandling:
actions.length > 0 ? "included_degraded" : "included_verbatim",
estimatedTokens,
warnings,
};
}
@@ -0,0 +1,103 @@
import type { ContentBlock, MessageWithMetadata } from "@cline/shared";
export type BudgetPolicyIntent =
| "agentic_summary"
| "basic_compaction_projection"
| "normal_provider_request";
export type BudgetActionKind =
| "truncated_text"
| "dropped_block"
| "dropped_message"
| "preserved";
export type BudgetActionReason =
| "over_budget"
| "unsafe_to_truncate"
| "tool_pair_boundary"
| "protected_live_tail";
export type LiveTailHandling =
| "included_verbatim"
| "included_degraded"
| "summarized_as_context"
| "omitted_with_warning"
| "preserved_out_of_band";
export type BlockBudgetClass =
| "text"
| "thinking"
| "tool_use"
| "tool_result"
| "unsafe_binary"
| "unsafe_encrypted"
| "opaque";
export interface BudgetPath {
messageIndex: number;
blockIndex?: number;
}
interface BaseBudgetAction {
path: BudgetPath;
originalSize: number;
finalSize: number;
}
export type BudgetMutationAction =
| (BaseBudgetAction & {
kind: "truncated_text";
reason: Extract<BudgetActionReason, "over_budget">;
})
| (BaseBudgetAction & {
kind: "dropped_block";
path: Required<BudgetPath>;
reason: Exclude<BudgetActionReason, "protected_live_tail">;
})
| (BaseBudgetAction & {
kind: "dropped_message";
reason: Exclude<BudgetActionReason, "protected_live_tail">;
});
export interface BudgetPreservedAction extends BaseBudgetAction {
kind: "preserved";
reason: Extract<
BudgetActionReason,
"protected_live_tail" | "tool_pair_boundary"
>;
}
export type BudgetAction = BudgetMutationAction | BudgetPreservedAction;
export type BudgetProjectionWarningCode =
| "budget_impossible"
| "budget_unachievable_with_protections";
export interface BudgetProjectionWarning {
code: BudgetProjectionWarningCode;
message: string;
path?: BudgetPath;
}
export interface BudgetProjectionOptions {
messages: MessageWithMetadata[];
targetTokens: number;
policyIntent: BudgetPolicyIntent;
estimateMessageTokens: (message: MessageWithMetadata) => number;
}
export interface BudgetProjectionResult {
status: "ok" | "failed";
messages: MessageWithMetadata[];
actions: BudgetAction[];
liveTailHandling: LiveTailHandling;
estimatedTokens: number;
warnings: BudgetProjectionWarning[];
}
export interface ContentBlockBudgetClassification {
block: ContentBlock;
budgetClass: BlockBudgetClass;
canStringTruncate: boolean;
canDropWholeBlock: boolean;
}
@@ -485,6 +485,7 @@ export function resolveSummarizerConfig(options: {
apiKey: summarizer.apiKey ?? baseProviderConfig?.apiKey,
baseUrl: summarizer.baseUrl ?? baseProviderConfig?.baseUrl,
headers: summarizer.headers ?? baseProviderConfig?.headers,
modelInfo: summarizer.modelInfo ?? baseProviderConfig?.modelInfo,
knownModels: summarizer.knownModels ?? baseProviderConfig?.knownModels,
maxOutputTokens:
summarizer.maxOutputTokens ?? DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS,
@@ -3,6 +3,7 @@ import type { MessageWithMetadata } from "@cline/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createSessionCompactionState } from "../../session/models/session-compaction";
import type { CoreCompactionContext } from "../../types/config";
import { buildAgenticSummaryInputBudget } from "./agentic-compaction";
import { runBasicCompaction } from "./basic-compaction";
import {
createCompactionStateAwarePrepareTurn,
@@ -10,6 +11,7 @@ import {
} from "./compaction";
import {
createTokenEstimator,
estimateTokens,
resolveSummarizerConfig,
serializeMessage,
TOOL_RESULT_CHAR_LIMIT,
@@ -520,6 +522,23 @@ describe("createContextCompactionPrepareTurn", () => {
expect(anthropicConfig.maxOutputTokens).toBe(1_024);
});
it("preserves summarizer modelInfo without a nested providerConfig", () => {
const resolved = resolveSummarizerConfig({
activeProviderConfig: {
providerId: "anthropic",
modelId: "primary-model",
modelInfo: { id: "primary-model", maxInputTokens: 100_000 },
} as LlmsProviders.ProviderConfig,
summarizer: {
providerId: "openai",
modelId: "small-summary",
modelInfo: { id: "small-summary", maxInputTokens: 600 },
},
});
expect(resolved.modelInfo?.maxInputTokens).toBe(600);
});
it("summarizes older messages and keeps recent messages", async () => {
const emitStatusNotice = vi.fn();
createHandlerMock.mockReturnValue({
@@ -772,6 +791,43 @@ describe("createContextCompactionPrepareTurn", () => {
expect(summarizerPrompt.length).toBeLessThan(longToolOutput.length);
});
it("budgets agentic summary input before serialization", () => {
const result = buildAgenticSummaryInputBudget({
messages: [
{ role: "user", content: "Run a large command" },
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-large",
name: "execute_command",
input: { command: "print-large-output" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-large",
name: "execute_command",
content: "x".repeat(50_000),
},
],
},
{ role: "user", content: "Latest typed prompt" },
],
targetTokens: 400,
estimateMessageTokens: estimateJsonTokens,
});
expect(result.estimatedTokens).toBeLessThanOrEqual(400);
expect(JSON.stringify(result.messages)).toContain("Latest typed prompt");
expect(result.actions.length).toBeGreaterThan(0);
});
it("never lands the agentic cut in the middle of a tool pair", async () => {
// Repro for the "No tool call found for function call output" provider
// error: findCutIndex used to walk back by token budget and could land
@@ -946,6 +1002,79 @@ describe("createContextCompactionPrepareTurn", () => {
);
});
it("budgets agentic summary input against the configured summarizer context window", async () => {
let summaryRequest = "";
createHandlerMock.mockReturnValue({
createMessage: vi.fn((_system: string, messages: LlmsProviders.Message[]) => {
summaryRequest = String(messages[0]?.content ?? "");
return streamChunks([
{ type: "text", id: "summary-small", text: "## Goal\nSummarized" },
{ type: "done", id: "summary-small", success: true },
]);
}),
});
const summarizerLimit = 600;
const oversizedAssistant = "assistant details ".repeat(5_000);
const prepareTurn = createContextCompactionPrepareTurn({
providerId: "anthropic",
modelId: "primary-model",
providerConfig: {
providerId: "anthropic",
modelId: "primary-model",
modelInfo: { id: "primary-model", maxInputTokens: 10_000 },
} as LlmsProviders.ProviderConfig,
compaction: {
enabled: true,
strategy: "agentic",
preserveRecentTokens: 1,
reserveTokens: 5,
summarizer: {
providerId: "openai",
modelId: "small-summary",
modelInfo: {
id: "small-summary",
maxInputTokens: summarizerLimit,
},
},
},
logger: undefined,
});
await prepareTurn?.({
agentId: "agent-1",
conversationId: "conv-1",
parentAgentId: null,
iteration: 1,
abortSignal: new AbortController().signal,
systemPrompt: "You are helpful.",
tools: [],
messages: [
{ role: "user", content: "Old request" },
{ role: "assistant", content: oversizedAssistant },
{ role: "user", content: "Latest turn" },
{ role: "assistant", content: "Latest answer" },
],
apiMessages: [
{ role: "user", content: "Old request" },
{ role: "assistant", content: oversizedAssistant },
{ role: "user", content: "Latest turn" },
{ role: "assistant", content: "Latest answer" },
],
model: {
id: "primary-model",
provider: "anthropic",
info: { id: "primary-model", maxInputTokens: 10_000 },
},
});
expect(createHandlerMock).toHaveBeenCalledTimes(1);
expect(estimateTokens(summaryRequest.length)).toBeLessThanOrEqual(
summarizerLimit,
);
expect(summaryRequest).not.toContain(oversizedAssistant);
});
it("uses basic compaction without calling the summarizer", async () => {
const emitStatusNotice = vi.fn();
const prepareTurn = createContextCompactionPrepareTurn({
+7
View File
@@ -79,6 +79,13 @@ export interface CoreCompactionSummarizerConfig {
apiKey?: string;
baseUrl?: string;
headers?: Record<string, string>;
/**
* Optional pre-resolved model metadata for the summarizer. Supplying either
* this or `knownModels` lets agentic compaction budget summary input against
* the summarizer model's actual context window instead of falling back to the
* active model's window.
*/
modelInfo?: ModelInfo;
knownModels?: Record<string, ModelInfo>;
providerConfig?: ProviderConfig;
maxOutputTokens?: number;