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