Compare commits

...
Author SHA1 Message Date
Robin Newhouse 5252621c40 fix(core): account compaction tokens consistently 2026-07-07 13:23:55 -07:00
Robin Newhouse 6c7339775b Preserve compaction status notice reasons 2026-07-07 13:23:55 -07:00
Robin Newhouse 71f707b2af Tighten compaction budget telemetry types 2026-07-07 13:23:55 -07:00
Robin Newhouse 0edf18e447 Emit compaction budget emergency telemetry 2026-07-07 13:23:55 -07:00
Robin Newhouse 8333e1e86b Align basic compaction budget expectation 2026-07-07 13:23:54 -07:00
Robin Newhouse 25fa84c9e6 Align basic sanitization image expectation 2026-07-07 13:23:54 -07:00
Robin Newhouse da35f08f03 Clarify basic projection budget logging 2026-07-07 13:23:54 -07:00
Robin Newhouse 02bc1bc432 Budget basic compaction projections 2026-07-07 13:23:54 -07:00
Robin Newhouse 49c0d1b6a3 Align agentic file ops with projected input 2026-07-07 13:23:54 -07:00
Robin Newhouse 92dc5dfed3 Align agentic compaction test tool result 2026-07-07 13:23:53 -07:00
Robin Newhouse ce85e49c7b Harden agentic summary budget fallback 2026-07-07 13:23:53 -07:00
Robin Newhouse dfecadbcbd Budget agentic compaction summary input 2026-07-07 13:23:53 -07:00
Robin Newhouse 519a22c5d5 Fix budget projection action accounting 2026-07-07 13:23:53 -07:00
Robin Newhouse fbdfa77bb9 fix(core): narrow compaction protected tail 2026-07-07 13:23:53 -07:00
Robin Newhouse 5ad8d33977 Clean up budget projection fixture indentation 2026-07-07 13:23:53 -07:00
Robin Newhouse 5226b107ba Align budget projection test tool results 2026-07-07 13:23:53 -07:00
Robin Newhouse 26f015fbd1 Recompute protected tail after thinking pruning 2026-07-07 13:23:52 -07:00
Robin Newhouse bdce31deea Drop provider-native blocks during budget projection 2026-07-07 13:23:52 -07:00
Robin Newhouse 406674d27f Fix budget projection truncation accounting 2026-07-07 13:23:52 -07:00
Robin Newhouse 24303ab0cb Add pure compaction budget projection engine 2026-07-07 13:23:52 -07:00
Robin Newhouse f48ba92357 Tighten dropped block action paths 2026-07-07 13:23:52 -07:00
Robin Newhouse 3693d2f867 Tighten budget projection contract types 2026-07-07 13:23:52 -07:00
Robin Newhouse 86aca36d03 Add compaction budget projection contract 2026-07-07 13:23:52 -07:00
17 changed files with 1947 additions and 33 deletions
+37 -1
View File
@@ -1,9 +1,45 @@
import type { AgentEvent, TeamEvent } from "@cline/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { handleEvent, handleTeamEvent } from "./events";
import {
handleEvent,
handleTeamEvent,
resolveStatusNoticeLabel,
} from "./events";
import { setCurrentOutputMode } from "./output";
import type { Config } from "./types";
describe("resolveStatusNoticeLabel", () => {
it("maps compaction status reasons to stable labels", () => {
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "auto-compacting",
reason: "auto_compaction",
} as AgentEvent),
).toBe("auto-compacting");
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "manual",
reason: "manual_compaction",
} as AgentEvent),
).toBe("compacting");
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "compaction-budget-adjusted",
reason: "compaction_budget_emergency",
} as AgentEvent),
).toBe("context budget adjusted");
});
});
describe("handleEvent text formatting", () => {
let output = "";
+7 -2
View File
@@ -27,8 +27,13 @@ export function resolveStatusNoticeLabel(
if (event.type !== "notice" || event.displayRole !== "status") {
return undefined;
}
if (event.reason === "auto_compaction") {
return "auto-compacting";
switch (event.reason) {
case "auto_compaction":
return "auto-compacting";
case "manual_compaction":
return "compacting";
case "compaction_budget_emergency":
return "context budget adjusted";
}
return event.message.trim() || undefined;
}
@@ -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,
});
@@ -149,5 +268,18 @@ export async function runAgenticCompaction(options: {
tokensAfter,
maxInputTokens: options.context.maxInputTokens,
});
return { messages: resultMessages };
const budgetActionCount = summaryInputBudget.actions.filter(
(action) =>
action.reason === "over_budget" ||
action.reason === "tool_pair_boundary",
).length;
return {
messages: resultMessages,
budget: {
policyIntent: "agentic_summary",
actionCount: budgetActionCount,
warningCount: summaryInputBudget.warnings.length,
liveTailHandling: summaryInputBudget.liveTailHandling,
},
};
}
@@ -7,6 +7,7 @@ import type {
CoreCompactionContext,
CoreCompactionResult,
} from "../../types/config";
import { buildBudgetProjection } from "./budget-projection";
import {
DEFAULT_TARGET_RATIO,
type EstimateMessageTokens,
@@ -444,21 +445,59 @@ export function runBasicCompaction(options: {
...candidates.map((candidate) => candidate.message),
...protectedTail,
];
if (!haveMessagesChanged(options.context.messages, nextMessages)) {
const budgeted = buildBudgetProjection({
messages: nextMessages,
targetTokens: totalTargetTokens,
policyIntent: "basic_compaction_projection",
estimateMessageTokens: options.estimateMessageTokens,
});
// This final projection owns the hard output budget. Unlike the earlier
// basic candidate passes, it can drop completed tool pairs after the latest
// typed prompt while preserving coherent tool closures.
if (budgeted.status === "failed") {
options.logger?.debug("Basic compaction returned best-effort projection", {
budgetWarnings: budgeted.warnings.map((warning) => warning.code),
projectedTokens: budgeted.estimatedTokens,
targetTokens: totalTargetTokens,
maxInputTokens: options.context.maxInputTokens,
});
}
const resultMessages = budgeted.messages;
if (!haveMessagesChanged(options.context.messages, resultMessages)) {
return undefined;
}
const beforeTokens = beforeCompactableTokens + protectedTailTokens;
const afterTokens = totalTokens + protectedTailTokens;
const afterTokens = getTotalTokens(
resultMessages,
options.estimateMessageTokens,
);
const budgetActionCount = budgeted.actions.filter(
(action) =>
action.reason === "over_budget" ||
action.reason === "tool_pair_boundary",
).length;
options.logger?.debug("Performed basic compaction", {
messagesBefore: options.context.messages.length,
messagesAfter: nextMessages.length,
messagesRemoved: options.context.messages.length - nextMessages.length,
messagesAfter: resultMessages.length,
messagesRemoved: options.context.messages.length - resultMessages.length,
tokensBefore: beforeTokens,
tokensAfter: afterTokens,
budgetStatus: budgeted.status,
budgetActions: budgetActionCount,
budgetWarnings: budgeted.warnings.map((warning) => warning.code),
targetTokens: totalTargetTokens,
maxInputTokens: options.context.maxInputTokens,
});
return { messages: nextMessages };
return {
messages: resultMessages,
budget: {
policyIntent: "basic_compaction_projection",
actionCount: budgetActionCount,
warningCount: budgeted.warnings.length,
liveTailHandling: budgeted.liveTailHandling,
},
};
}
@@ -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,
@@ -329,10 +331,10 @@ describe("createContextCompactionPrepareTurn", () => {
expectNoOrphanedToolPairs(compacted);
expect(pairs.get("tool-a")).toBeUndefined();
expect(pairs.get("tool-b")).toEqual({ hasResult: true, hasUse: true });
expect(JSON.stringify(compacted)).toContain("Read the latest file");
});
it("preserves the latest tool pair under aggressive basic compaction", () => {
it("may drop the latest completed tool pair under aggressive basic compaction", () => {
const messages: LlmsProviders.Message[] = [
{ role: "user", content: "Read the files" },
assistantToolUseMessage("tool-a"),
@@ -347,7 +349,8 @@ describe("createContextCompactionPrepareTurn", () => {
expectNoOrphanedToolPairs(compacted);
expect(pairs.get("tool-a")).toBeUndefined();
expect(pairs.get("tool-b")).toEqual({ hasResult: true, hasUse: true });
expect(pairs.get("tool-b")).toBeUndefined();
expect(JSON.stringify(compacted)).toContain("Read the latest file");
});
it("treats multi-tool assistant turns as one atomic group in basic compaction", () => {
@@ -386,7 +389,7 @@ describe("createContextCompactionPrepareTurn", () => {
expect(collectToolPairPresence(compacted).get("tool-a")).toBeUndefined();
});
it("preserves the latest typed user turn with its tool work during basic compaction", () => {
it("preserves the latest typed user prompt without requiring completed tool work", () => {
const messages: LlmsProviders.Message[] = [
{ role: "user", content: "Old request" },
{ role: "assistant", content: "Old answer that can be compacted" },
@@ -400,9 +403,24 @@ describe("createContextCompactionPrepareTurn", () => {
expect(compacted).toEqual([
{ role: "user", content: "Old request" },
{ role: "user", content: "Read the latest file" },
assistantToolUseMessage("tool-a"),
toolResultMessage("tool-a", "latest result"),
]);
expectNoOrphanedToolPairs(compacted);
});
it("budgets the complete basic compaction output including the latest turn", () => {
const messages: LlmsProviders.Message[] = [
{ role: "user", content: "original task" },
{ role: "assistant", content: "old assistant " + "x".repeat(10_000) },
{ role: "user", content: "latest typed prompt" },
assistantToolUseMessage("tool-live"),
toolResultMessage("tool-live", "live result " + "y".repeat(10_000)),
];
const compacted = runForcedBasicCompaction(messages, 700);
expect(totalJsonTokens(compacted)).toBeLessThanOrEqual(700);
expect(JSON.stringify(compacted)).toContain("latest typed prompt");
expectNoOrphanedToolPairs(compacted);
});
it("does not compact a single typed user message", () => {
@@ -520,6 +538,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 +807,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 +1018,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({
@@ -1798,7 +1943,7 @@ describe("createContextCompactionPrepareTurn", () => {
expect(result?.messages.length).toBeLessThan(4);
});
it("preserves user image blocks during basic compaction sanitization", () => {
it("drops old user image blocks during basic compaction sanitization", () => {
const messages: LlmsProviders.Message[] = [
{
role: "user",
@@ -1834,7 +1979,6 @@ describe("createContextCompactionPrepareTurn", () => {
expect(result?.messages).toBeDefined();
expect(result?.messages[0]?.content).toEqual([
{ type: "text", text: "Older user turn" },
{ type: "image", data: "abc", mediaType: "image/png" },
]);
expect(result?.messages.at(-1)).toEqual({
role: "user",
@@ -2065,6 +2209,83 @@ describe("createContextCompactionPrepareTurn", () => {
);
});
it("accounts executed compaction telemetry against compaction messages", async () => {
const captureCalls: Array<{
event: string;
properties?: Record<string, unknown>;
}> = [];
const telemetry = {
capture: (call: {
event: string;
properties?: Record<string, unknown>;
}) => captureCalls.push(call),
captureRequired: () => {},
setDistinctId: () => {},
updateCommonProperties: () => {},
identify: () => {},
} as unknown as Parameters<
typeof createContextCompactionPrepareTurn
>[0]["telemetry"];
const compact = vi.fn(async () => ({
messages: [{ role: "user" as const, content: "trimmed" }],
}));
const prepareTurn = createContextCompactionPrepareTurn({
providerId: "anthropic",
modelId: "mock-model",
providerConfig: {
providerId: "anthropic",
modelId: "mock-model",
} as LlmsProviders.ProviderConfig,
compaction: {
enabled: true,
strategy: "basic",
reserveTokens: 1,
compact,
},
telemetry,
});
const messages: LlmsProviders.Message[] = [
{ role: "user", content: "Original task" },
{ role: "assistant", content: "short answer" },
{ role: "user", content: "Latest" },
];
const apiMessages: LlmsProviders.Message[] = [
...messages,
{ role: "assistant", content: "provider-only payload ".repeat(1_000) },
];
await prepareTurn?.({
agentId: "agent-1",
conversationId: "conv-1",
parentAgentId: null,
iteration: 3,
abortSignal: new AbortController().signal,
systemPrompt: "",
tools: [],
messages,
apiMessages,
model: {
id: "mock-model",
provider: "anthropic",
info: { id: "mock-model", maxInputTokens: 100 },
},
});
expect(compact).toHaveBeenCalledTimes(1);
const executed = captureCalls.find(
(call) => call.event === "task.compaction_executed",
);
const props = executed?.properties as Record<string, unknown>;
expect(props.tokensBefore as number).toBeLessThan(
props.triggerTokens as number,
);
expect(props.tokensSaved).toBe(
(props.tokensBefore as number) - (props.tokensAfter as number),
);
expect(props.tokensSaved as number).toBeGreaterThanOrEqual(0);
});
it("emits task.compaction_skipped when the strategy returns undefined", async () => {
const captureCalls: Array<{
event: string;
@@ -1,4 +1,5 @@
import {
captureCompactionBudgetEmergency,
captureCompactionExecuted,
captureCompactionSkipped,
type TelemetryCompactionStrategy,
@@ -430,6 +431,10 @@ export function createContextCompactionPrepareTurn(
);
const beforeMessageCount = context.messages.length;
const compactionInputTokens = context.messages.reduce(
(total: number, message) => total + estimateMessageTokens(message),
0,
);
const startedAt = Date.now();
const result = userCompaction?.compact
@@ -467,10 +472,11 @@ export function createContextCompactionPrepareTurn(
severity: "info",
strategy: strategy,
maxInputTokens,
inputTokens,
inputTokens: compactionInputTokens,
apiInputTokens: inputTokens,
afterTokens,
tokensSaved: inputTokens - afterTokens,
utilizationBefore: `${((inputTokens / maxInputTokens) * 100).toFixed(1)}%`,
tokensSaved: compactionInputTokens - afterTokens,
utilizationBefore: `${((compactionInputTokens / maxInputTokens) * 100).toFixed(1)}%`,
utilizationAfter: `${((afterTokens / maxInputTokens) * 100).toFixed(1)}%`,
thresholdTrigger: `${(targetState.thresholdRatio * 100).toFixed(1)}%`,
messagesBefore: beforeMessageCount,
@@ -484,9 +490,9 @@ export function createContextCompactionPrepareTurn(
messagesBefore: beforeMessageCount,
messagesAfter: result.messages.length,
messagesRemoved: beforeMessageCount - result.messages.length,
tokensBefore: inputTokens,
tokensBefore: compactionInputTokens,
tokensAfter: afterTokens,
tokensSaved: inputTokens - afterTokens,
tokensSaved: compactionInputTokens - afterTokens,
triggerTokens: targetState.triggerTokens,
maxInputTokens,
thresholdRatio: targetState.thresholdRatio,
@@ -497,6 +503,31 @@ export function createContextCompactionPrepareTurn(
modelId: config.modelId,
...telemetryIdentity,
});
if (
result.budget &&
(result.budget.actionCount > 0 || result.budget.warningCount > 0)
) {
captureCompactionBudgetEmergency(config.telemetry, {
ulid: telemetryUlid,
strategy: telemetryStrategy,
mode,
policyIntent: result.budget.policyIntent,
actionCount: result.budget.actionCount,
warningCount: result.budget.warningCount,
liveTailHandling: result.budget.liveTailHandling,
provider: config.providerId,
modelId: config.modelId,
...telemetryIdentity,
});
context.emitStatusNotice?.("compaction-budget-adjusted", {
kind: "compaction_budget_emergency",
reason: "compaction_budget_emergency",
iteration: context.iteration,
policyIntent: result.budget.policyIntent,
actionCount: result.budget.actionCount,
warningCount: result.budget.warningCount,
});
}
} else {
captureCompactionSkipped(config.telemetry, {
ulid: telemetryUlid,
@@ -128,6 +128,55 @@ describe("RuntimeEventAdapter — suppressed events", () => {
});
});
describe("RuntimeEventAdapter — status notices", () => {
let adapter: RuntimeEventAdapter;
beforeEach(() => {
adapter = new RuntimeEventAdapter();
});
it("preserves bounded compaction reasons", () => {
for (const reason of [
"auto_compaction",
"manual_compaction",
"compaction_budget_emergency",
] as const) {
const out = adapter.translate({
type: "status-notice",
snapshot: makeSnapshot(),
message: "compaction status",
metadata: { reason },
});
expect(out).toEqual([
{
type: "notice",
noticeType: "status",
displayRole: "status",
message: "compaction status",
reason,
metadata: { reason },
},
]);
}
});
it("does not promote arbitrary status reasons", () => {
const out = adapter.translate({
type: "status-notice",
snapshot: makeSnapshot(),
message: "custom",
metadata: { reason: "surprise" },
});
expect(out[0]).toMatchObject({
type: "notice",
noticeType: "status",
message: "custom",
reason: undefined,
});
});
});
// ---------------------------------------------------------------------------
// Iteration lifecycle
// ---------------------------------------------------------------------------
@@ -66,6 +66,21 @@ import type {
// Helpers
// =============================================================================
type StatusNoticeReason = Extract<AgentEvent, { type: "notice" }>["reason"];
function resolveStatusNoticeReason(
reason: unknown,
): StatusNoticeReason | undefined {
switch (reason) {
case "auto_compaction":
case "manual_compaction":
case "compaction_budget_emergency":
return reason;
default:
return undefined;
}
}
function extractTextPart(message: AgentMessage): string | undefined {
const parts = message.content.filter(
(part): part is AgentTextPart => part.type === "text",
@@ -225,10 +240,7 @@ export class RuntimeEventAdapter {
noticeType: "status",
displayRole: "status",
message: event.message,
reason:
event.metadata?.reason === "auto_compaction"
? "auto_compaction"
: undefined,
reason: resolveStatusNoticeReason(event.metadata?.reason),
metadata: event.metadata,
},
];
@@ -5,6 +5,7 @@ import {
import { describe, expect, test, vi } from "vitest";
import {
CORE_TELEMETRY_EVENTS,
captureCompactionBudgetEmergency,
captureCompactionExecuted,
captureCompactionSkipped,
captureExtensionActivated,
@@ -452,6 +453,38 @@ describe("captureRunCommandsTimeout", () => {
});
});
describe("captureCompactionBudgetEmergency", () => {
test("emits task.compaction_budget_emergency with action metadata", () => {
const stub = createTelemetryStub();
captureCompactionBudgetEmergency(stub.telemetry, {
ulid: "ulid-1",
strategy: "basic",
mode: "auto",
policyIntent: "basic_compaction_projection",
actionCount: 2,
warningCount: 1,
liveTailHandling: "included_degraded",
provider: "anthropic",
modelId: "claude-sonnet-4",
});
const { event, properties } = captureCallAt(stub, 0);
expect(event).toBe("task.compaction_budget_emergency");
expect(properties).toMatchObject({
ulid: "ulid-1",
strategy: "basic",
mode: "auto",
policyIntent: "basic_compaction_projection",
actionCount: 2,
warningCount: 1,
liveTailHandling: "included_degraded",
});
expect(typeof (properties as Record<string, unknown>).timestamp).toBe(
"string",
);
});
});
/**
* Telemetry-policy regression coverage.
*
@@ -614,6 +647,24 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
expect(emitRequired).not.toHaveBeenCalled();
});
test("captureCompactionBudgetEmergency never invokes captureRequired", () => {
const { adapter, emitRequired } = createDisabledAdapter();
const service = new TelemetryService({
distinctId: "test-distinct-id",
adapters: [adapter],
});
captureCompactionBudgetEmergency(service, {
ulid: "ulid-1",
strategy: "basic",
mode: "auto",
policyIntent: "basic_compaction_projection",
actionCount: 1,
warningCount: 0,
liveTailHandling: "included_degraded",
});
expect(emitRequired).not.toHaveBeenCalled();
});
test("a correctly-policed adapter drops these events when disabled", () => {
// This test layers on top of the previous four to assert the *full*
// end-to-end policy: when the adapter is disabled, a real adapter
@@ -692,6 +743,15 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
command_count: 2,
duration_ms: 1502,
});
captureCompactionBudgetEmergency(service, {
ulid: "ulid-1",
strategy: "basic",
mode: "auto",
policyIntent: "basic_compaction_projection",
actionCount: 1,
warningCount: 0,
liveTailHandling: "included_degraded",
});
expect(observed).toEqual([]);
expect(dropped).toEqual([
"user.extension_activated",
@@ -702,6 +762,7 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
"task.compaction_executed",
"task.compaction_skipped",
"sdk.tool_timeout",
"task.compaction_budget_emergency",
]);
});
});
@@ -6,6 +6,10 @@ import {
SDK_ERROR_TELEMETRY_EVENT,
type TelemetryProperties,
} from "@cline/shared";
import type {
CoreCompactionBudgetPolicyIntent,
CoreCompactionLiveTailHandling,
} from "../../types/config";
const MAX_ERROR_MESSAGE_LENGTH = 500;
@@ -67,6 +71,7 @@ export const CORE_TELEMETRY_EVENTS = {
SUBAGENT_COMPLETED: "task.subagent_completed",
COMPACTION_EXECUTED: "task.compaction_executed",
COMPACTION_SKIPPED: "task.compaction_skipped",
COMPACTION_BUDGET_EMERGENCY: "task.compaction_budget_emergency",
},
HOOKS: {
DISCOVERY_COMPLETED: "hooks.discovery_completed",
@@ -692,3 +697,26 @@ export function captureCompactionSkipped(
timestamp: new Date().toISOString(),
});
}
export interface CaptureCompactionBudgetEmergencyProperties {
ulid: string;
strategy: TelemetryCompactionStrategy;
mode: TelemetryCompactionMode;
policyIntent: CoreCompactionBudgetPolicyIntent;
actionCount: number;
warningCount: number;
liveTailHandling: CoreCompactionLiveTailHandling;
provider?: string;
modelId?: string;
}
export function captureCompactionBudgetEmergency(
telemetry: ITelemetryService | undefined,
properties: CaptureCompactionBudgetEmergencyProperties &
Partial<TelemetryAgentIdentityProperties>,
): void {
emit(telemetry, CORE_TELEMETRY_EVENTS.TASK.COMPACTION_BUDGET_EMERGENCY, {
...properties,
timestamp: new Date().toISOString(),
});
}
+31
View File
@@ -69,8 +69,32 @@ export interface CoreCompactionContext {
utilizationRatio: number;
}
// Mirrors BudgetPolicyIntent in extensions/context/budget-projection/types.ts.
// Keep this public API type decoupled from the internal projection module.
export type CoreCompactionBudgetPolicyIntent =
| "agentic_summary"
| "basic_compaction_projection"
| "normal_provider_request";
// Mirrors LiveTailHandling in extensions/context/budget-projection/types.ts.
// Keep this public API type decoupled from the internal projection module.
export type CoreCompactionLiveTailHandling =
| "included_verbatim"
| "included_degraded"
| "summarized_as_context"
| "omitted_with_warning"
| "preserved_out_of_band";
export interface CoreCompactionBudgetMetadata {
policyIntent: CoreCompactionBudgetPolicyIntent;
actionCount: number;
warningCount: number;
liveTailHandling: CoreCompactionLiveTailHandling;
}
export interface CoreCompactionResult {
messages: MessageWithMetadata[];
budget?: CoreCompactionBudgetMetadata;
}
export interface CoreCompactionSummarizerConfig {
@@ -79,6 +103,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;
+3 -1
View File
@@ -162,7 +162,9 @@ export interface AgentNoticeEvent extends AgentEventMetadata {
| "completion_without_submit"
| "tool_execution_failed"
| "mistake_limit"
| "auto_compaction";
| "auto_compaction"
| "manual_compaction"
| "compaction_budget_emergency";
metadata?: Record<string, unknown>;
}