feat: add cache-ttl pruning mode

This commit is contained in:
Peter Steinberger
2026-01-21 19:44:20 +00:00
parent c415ccaed5
commit 9f59ff325b
18 changed files with 164 additions and 123 deletions

View File

@@ -0,0 +1,52 @@
type CustomEntryLike = { type?: unknown; customType?: unknown; data?: unknown };
export const CACHE_TTL_CUSTOM_TYPE = "clawdbot.cache-ttl";
export type CacheTtlEntryData = {
timestamp: number;
provider?: string;
modelId?: string;
};
export function isCacheTtlEligibleProvider(provider: string, modelId: string): boolean {
const normalizedProvider = provider.toLowerCase();
const normalizedModelId = modelId.toLowerCase();
if (normalizedProvider === "anthropic") return true;
if (normalizedProvider === "openrouter" && normalizedModelId.startsWith("anthropic/"))
return true;
return false;
}
export function readLastCacheTtlTimestamp(sessionManager: unknown): number | null {
const sm = sessionManager as { getEntries?: () => CustomEntryLike[] };
if (!sm?.getEntries) return null;
try {
const entries = sm.getEntries();
let last: number | null = null;
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry?.type !== "custom" || entry?.customType !== CACHE_TTL_CUSTOM_TYPE) continue;
const data = entry?.data as Partial<CacheTtlEntryData> | undefined;
const ts = typeof data?.timestamp === "number" ? data.timestamp : null;
if (ts && Number.isFinite(ts)) {
last = ts;
break;
}
}
return last;
} catch {
return null;
}
}
export function appendCacheTtlTimestamp(sessionManager: unknown, data: CacheTtlEntryData): void {
const sm = sessionManager as {
appendCustomEntry?: (customType: string, data: unknown) => void;
};
if (!sm?.appendCustomEntry) return;
try {
sm.appendCustomEntry(CACHE_TTL_CUSTOM_TYPE, data);
} catch {
// ignore persistence failures
}
}

View File

@@ -11,6 +11,7 @@ import { setContextPruningRuntime } from "../pi-extensions/context-pruning/runti
import { computeEffectiveSettings } from "../pi-extensions/context-pruning/settings.js";
import { makeToolPrunablePredicate } from "../pi-extensions/context-pruning/tools.js";
import { ensurePiCompactionReserveTokens } from "../pi-settings.js";
import { isCacheTtlEligibleProvider, readLastCacheTtlTimestamp } from "./cache-ttl.js";
function resolvePiExtensionPath(id: string): string {
const self = fileURLToPath(import.meta.url);
@@ -43,7 +44,8 @@ function buildContextPruningExtension(params: {
model: Model<Api> | undefined;
}): { additionalExtensionPaths?: string[] } {
const raw = params.cfg?.agents?.defaults?.contextPruning;
if (raw?.mode !== "adaptive" && raw?.mode !== "aggressive") return {};
if (raw?.mode !== "cache-ttl") return {};
if (!isCacheTtlEligibleProvider(params.provider, params.modelId)) return {};
const settings = computeEffectiveSettings(raw);
if (!settings) return {};
@@ -52,6 +54,7 @@ function buildContextPruningExtension(params: {
settings,
contextWindowTokens: resolveContextWindowTokens(params),
isToolPrunable: makeToolPrunablePredicate(settings.tools),
lastCacheTouchAt: readLastCacheTtlTimestamp(params.sessionManager),
});
return {

View File

@@ -21,7 +21,7 @@ export function resolveExtraParams(params: {
return modelConfig?.params ? { ...modelConfig.params } : undefined;
}
type CacheControlTtl = "5m" | "1h";
type CacheControlTtl = "5m";
function resolveCacheControlTtl(
extraParams: Record<string, unknown> | undefined,
@@ -29,7 +29,7 @@ function resolveCacheControlTtl(
modelId: string,
): CacheControlTtl | undefined {
const raw = extraParams?.cacheControlTtl;
if (raw !== "5m" && raw !== "1h") return undefined;
if (raw !== "5m") return undefined;
if (provider === "anthropic") return raw;
if (provider === "openrouter" && modelId.startsWith("anthropic/")) return raw;
return undefined;

View File

@@ -36,8 +36,10 @@ describe("injectHistoryImagesIntoMessages", () => {
const didMutate = injectHistoryImagesIntoMessages(messages, new Map([[0, [image]]]));
expect(didMutate).toBe(false);
const content = messages[0]?.content as unknown[] | undefined;
expect(content).toBeDefined();
const content = messages[0]?.content;
if (!Array.isArray(content)) {
throw new Error("expected array content");
}
expect(content).toHaveLength(2);
});

View File

@@ -49,6 +49,7 @@ import { resolveDefaultModelForAgent } from "../../model-selection.js";
import { isAbortError } from "../abort.js";
import { buildEmbeddedExtensionPaths } from "../extensions.js";
import { applyExtraParamsToAgent } from "../extra-params.js";
import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-ttl.js";
import {
logToolSchemasForGoogle,
sanitizeSessionHistory,
@@ -685,6 +686,17 @@ export async function runEmbeddedAttempt(
note: `images: prompt=${imageResult.images.length} history=${imageResult.historyImagesByIndex.size}`,
});
const shouldTrackCacheTtl =
params.config?.agents?.defaults?.contextPruning?.mode === "cache-ttl" &&
isCacheTtlEligibleProvider(params.provider, params.modelId);
if (shouldTrackCacheTtl) {
appendCacheTtlTimestamp(sessionManager, {
timestamp: Date.now(),
provider: params.provider,
modelId: params.modelId,
});
}
// Only pass images option if there are actually images to pass
// This avoids potential issues with models that don't expect the images parameter
if (imageResult.images.length > 0) {