diff --git a/apps/cli/src/utils/events.test.ts b/apps/cli/src/utils/events.test.ts index 5bf75acce4..1a3eabaf7b 100644 --- a/apps/cli/src/utils/events.test.ts +++ b/apps/cli/src/utils/events.test.ts @@ -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 = ""; diff --git a/apps/cli/src/utils/events.ts b/apps/cli/src/utils/events.ts index b1164408ca..3032a315e3 100644 --- a/apps/cli/src/utils/events.ts +++ b/apps/cli/src/utils/events.ts @@ -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; } diff --git a/sdk/packages/core/src/extensions/context/agentic-compaction.ts b/sdk/packages/core/src/extensions/context/agentic-compaction.ts index d9f5c19a66..5cee89ce81 100644 --- a/sdk/packages/core/src/extensions/context/agentic-compaction.ts +++ b/sdk/packages/core/src/extensions/context/agentic-compaction.ts @@ -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, + }, + }; } diff --git a/sdk/packages/core/src/extensions/context/basic-compaction.ts b/sdk/packages/core/src/extensions/context/basic-compaction.ts index 144c72a898..49b6a981e2 100644 --- a/sdk/packages/core/src/extensions/context/basic-compaction.ts +++ b/sdk/packages/core/src/extensions/context/basic-compaction.ts @@ -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, + }, + }; } diff --git a/sdk/packages/core/src/extensions/context/budget-projection/index.ts b/sdk/packages/core/src/extensions/context/budget-projection/index.ts new file mode 100644 index 0000000000..fc6e3a114e --- /dev/null +++ b/sdk/packages/core/src/extensions/context/budget-projection/index.ts @@ -0,0 +1,17 @@ +export { + buildBudgetProjection, + findLatestTypedUserMessageIndex, +} from "./project"; +export type { + BlockBudgetClass, + BudgetAction, + BudgetActionKind, + BudgetActionReason, + BudgetPath, + BudgetPolicyIntent, + BudgetProjectionOptions, + BudgetProjectionResult, + BudgetProjectionWarning, + ContentBlockBudgetClassification, + LiveTailHandling, +} from "./types"; diff --git a/sdk/packages/core/src/extensions/context/budget-projection/project.test.ts b/sdk/packages/core/src/extensions/context/budget-projection/project.test.ts new file mode 100644 index 0000000000..0d22500674 --- /dev/null +++ b/sdk/packages/core/src/extensions/context/budget-projection/project.test.ts @@ -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", + }), + ]), + ); + }); +}); diff --git a/sdk/packages/core/src/extensions/context/budget-projection/project.ts b/sdk/packages/core/src/extensions/context/budget-projection/project.ts new file mode 100644 index 0000000000..82cecf0d06 --- /dev/null +++ b/sdk/packages/core/src/extensions/context/budget-projection/project.ts @@ -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 { + const ids = new Set(); + 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> { + const index = new Map>(); + 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(); + 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 { + const pairIndex = buildToolPairIndex(messages); + const removal = new Set(); + 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[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, +): { messages: MessageWithMetadata[]; originalIndexes: number[] } { + return { + messages: messages.filter((_, index) => !removal.has(index)), + originalIndexes: originalIndexes.filter((_, index) => !removal.has(index)), + }; +} + +function closureTouchesProtectedTail( + closure: Set, + protectedStartIndex: number, +): boolean { + if (protectedStartIndex < 0) { + return false; + } + for (const removalIndex of closure) { + if (removalIndex >= protectedStartIndex) { + return true; + } + } + return false; +} + +function closureTouchesPinnedMessage( + closure: Set, + 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, + }; +} diff --git a/sdk/packages/core/src/extensions/context/budget-projection/types.ts b/sdk/packages/core/src/extensions/context/budget-projection/types.ts new file mode 100644 index 0000000000..da6e35157f --- /dev/null +++ b/sdk/packages/core/src/extensions/context/budget-projection/types.ts @@ -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; + }) + | (BaseBudgetAction & { + kind: "dropped_block"; + path: Required; + reason: Exclude; + }) + | (BaseBudgetAction & { + kind: "dropped_message"; + reason: Exclude; + }); + +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; +} diff --git a/sdk/packages/core/src/extensions/context/compaction-shared.ts b/sdk/packages/core/src/extensions/context/compaction-shared.ts index d6a52d8f31..6db5e71d3d 100644 --- a/sdk/packages/core/src/extensions/context/compaction-shared.ts +++ b/sdk/packages/core/src/extensions/context/compaction-shared.ts @@ -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, diff --git a/sdk/packages/core/src/extensions/context/compaction.test.ts b/sdk/packages/core/src/extensions/context/compaction.test.ts index 818317cca2..4359d053b1 100644 --- a/sdk/packages/core/src/extensions/context/compaction.test.ts +++ b/sdk/packages/core/src/extensions/context/compaction.test.ts @@ -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; + }> = []; + const telemetry = { + capture: (call: { + event: string; + properties?: Record; + }) => 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; + 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; @@ -2108,6 +2329,19 @@ describe("createContextCompactionPrepareTurn", () => { { role: "assistant", content: "x".repeat(500) }, { role: "user", content: "Latest" }, ]; + const apiMessages: LlmsProviders.Message[] = [ + { role: "user", content: "api-shaped ".repeat(500) }, + ]; + const estimateMessageTokens = createTokenEstimator(); + const sessionInputTokens = messages.reduce( + (total, message) => total + estimateMessageTokens(message), + 0, + ); + const apiInputTokens = apiMessages.reduce( + (total, message) => total + estimateMessageTokens(message), + 0, + ); + expect(apiInputTokens).not.toBe(sessionInputTokens); const result = await prepareTurn?.({ agentId: "agent-1", @@ -2118,7 +2352,7 @@ describe("createContextCompactionPrepareTurn", () => { systemPrompt: "", tools: [], messages, - apiMessages: messages, + apiMessages, model: { id: "mock-model", provider: "anthropic", @@ -2136,6 +2370,7 @@ describe("createContextCompactionPrepareTurn", () => { expect(props.mode).toBe("auto"); expect(props.reason).toBe("no_result"); expect(props.ulid).toBe("ulid-test-skip"); + expect(props.tokensBefore).toBe(sessionInputTokens); expect(typeof props.durationMs).toBe("number"); expect( captureCalls.find((call) => call.event === "task.compaction_executed"), diff --git a/sdk/packages/core/src/extensions/context/compaction.ts b/sdk/packages/core/src/extensions/context/compaction.ts index 8e7a034092..14a492eff4 100644 --- a/sdk/packages/core/src/extensions/context/compaction.ts +++ b/sdk/packages/core/src/extensions/context/compaction.ts @@ -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,13 +503,38 @@ 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, strategy: telemetryStrategy, mode, reason: "no_result", - tokensBefore: inputTokens, + tokensBefore: compactionInputTokens, triggerTokens: targetState.triggerTokens, maxInputTokens, thresholdRatio: targetState.thresholdRatio, diff --git a/sdk/packages/core/src/runtime/orchestration/runtime-event-adapter.test.ts b/sdk/packages/core/src/runtime/orchestration/runtime-event-adapter.test.ts index 36b119bc19..858df01f06 100644 --- a/sdk/packages/core/src/runtime/orchestration/runtime-event-adapter.test.ts +++ b/sdk/packages/core/src/runtime/orchestration/runtime-event-adapter.test.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/sdk/packages/core/src/runtime/orchestration/runtime-event-adapter.ts b/sdk/packages/core/src/runtime/orchestration/runtime-event-adapter.ts index a4a6ad024c..1ca6063cb5 100644 --- a/sdk/packages/core/src/runtime/orchestration/runtime-event-adapter.ts +++ b/sdk/packages/core/src/runtime/orchestration/runtime-event-adapter.ts @@ -66,6 +66,21 @@ import type { // Helpers // ============================================================================= +type StatusNoticeReason = Extract["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, }, ]; diff --git a/sdk/packages/core/src/services/telemetry/core-events.test.ts b/sdk/packages/core/src/services/telemetry/core-events.test.ts index 8769c4faaa..3186e737d2 100644 --- a/sdk/packages/core/src/services/telemetry/core-events.test.ts +++ b/sdk/packages/core/src/services/telemetry/core-events.test.ts @@ -5,6 +5,7 @@ import { import { describe, expect, test, vi } from "vitest"; import { CORE_TELEMETRY_EVENTS, + captureCompactionBudgetEmergency, captureCompactionExecuted, captureCompactionSkipped, captureExtensionActivated, @@ -453,6 +454,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).timestamp).toBe( + "string", + ); + }); +}); + /** * Telemetry-policy regression coverage. * @@ -615,6 +648,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 @@ -693,6 +744,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", @@ -703,6 +763,7 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => { "task.compaction_executed", "task.compaction_skipped", "sdk.tool_timeout", + "task.compaction_budget_emergency", ]); }); }); diff --git a/sdk/packages/core/src/services/telemetry/core-events.ts b/sdk/packages/core/src/services/telemetry/core-events.ts index 25fa3f4454..8066e224c2 100644 --- a/sdk/packages/core/src/services/telemetry/core-events.ts +++ b/sdk/packages/core/src/services/telemetry/core-events.ts @@ -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", @@ -693,3 +698,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, +): void { + emit(telemetry, CORE_TELEMETRY_EVENTS.TASK.COMPACTION_BUDGET_EMERGENCY, { + ...properties, + timestamp: new Date().toISOString(), + }); +} diff --git a/sdk/packages/core/src/types/config.ts b/sdk/packages/core/src/types/config.ts index 9b9626a329..6d5ad24be1 100644 --- a/sdk/packages/core/src/types/config.ts +++ b/sdk/packages/core/src/types/config.ts @@ -73,8 +73,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 { @@ -83,6 +107,13 @@ export interface CoreCompactionSummarizerConfig { apiKey?: string; baseUrl?: string; headers?: Record; + /** + * 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; providerConfig?: ProviderConfig; maxOutputTokens?: number; diff --git a/sdk/packages/shared/src/agents/types.ts b/sdk/packages/shared/src/agents/types.ts index 0403bed86a..be862f09fb 100644 --- a/sdk/packages/shared/src/agents/types.ts +++ b/sdk/packages/shared/src/agents/types.ts @@ -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; }