diff --git a/open-sse/services/compression/cacheAwareConfig.ts b/open-sse/services/compression/cacheAwareConfig.ts index 5f0c01fb71..e6d1f2d447 100644 --- a/open-sse/services/compression/cacheAwareConfig.ts +++ b/open-sse/services/compression/cacheAwareConfig.ts @@ -1,23 +1,36 @@ import type { CompressionConfig } from "./types.ts"; import type { CachingDetectionContext } from "./cachingAware.ts"; import { detectCachingContext, getCacheAwareStrategy } from "./cachingAware.ts"; +import { + normalizePreserveSystemPromptMode, + resolvePreserveSystemPrompt, +} from "./preserveSystemPromptMode.ts"; /** - * #3890: honor the cache-aware `skipSystemPrompt` decision that - * `getCacheAwareStrategy` already computes but `selectCompressionStrategy` - * cannot return. In a caching context the system prompt is part of the - * cacheable prefix, so compressing it breaks the upstream prompt cache. + * #3890/#3955 + T05/C5: materialize the engine-facing `preserveSystemPrompt` + * boolean from the authoritative `preserveSystemPromptMode` intent, using the + * cache-aware `skipSystemPrompt` signal that `getCacheAwareStrategy` already + * computes (a caching provider — or `cache_control` — means the system prompt is + * part of the cacheable prefix, so compressing it breaks the upstream cache). + * + * This generalizes the previous hard-coded "force `true` when a cache is present + * and the operator disabled preservation" into the three modes: + * - `always` → always `true`. + * - `whenNoCache` → `true` only when a cache is present (the legacy `false` + * behaviour — preserved exactly for back-compat). + * - `never` → always `false`, even when it breaks a prompt cache. */ export function resolveCacheAwareConfig( config: CompressionConfig, body?: Record, context?: CachingDetectionContext ): CompressionConfig { - if (!body) return config; - const ctx = detectCachingContext(body, context); - const cacheAware = getCacheAwareStrategy(config.defaultMode, ctx); - if (cacheAware.skipSystemPrompt && config.preserveSystemPrompt === false) { - return { ...config, preserveSystemPrompt: true }; - } - return config; + const mode = normalizePreserveSystemPromptMode(config); + // No request body → no cacheable prefix to detect; honor the mode at its no-cache baseline. + const hasCache = body + ? getCacheAwareStrategy(config.defaultMode, detectCachingContext(body, context)).skipSystemPrompt + : false; + const effective = resolvePreserveSystemPrompt(mode, { hasCache }); + if (effective === config.preserveSystemPrompt) return config; + return { ...config, preserveSystemPrompt: effective }; } diff --git a/open-sse/services/compression/preserveSystemPromptMode.ts b/open-sse/services/compression/preserveSystemPromptMode.ts new file mode 100644 index 0000000000..39528d442a --- /dev/null +++ b/open-sse/services/compression/preserveSystemPromptMode.ts @@ -0,0 +1,72 @@ +import type { CompressionConfig, PreserveSystemPromptMode } from "./types.ts"; + +/** + * T05/C5 — system-prompt preservation mode. + * + * The engine-facing field is the boolean `CompressionConfig.preserveSystemPrompt` + * (truthy = skip/preserve the system prompt). Its authoritative *intent* is the + * `preserveSystemPromptMode` enum, resolved to that boolean at the cache-aware layer + * (`resolveCacheAwareConfig`), which already runs upstream in `chatCore` with the + * caching context. This module is the single source of the enum<->boolean mapping so + * the legacy boolean and the new enum can never drift. + * + * Mode semantics: + * - `always` → preserve the system prompt unconditionally. + * - `whenNoCache` → preserve it only when there is a cache to protect (provider caches + * the prefix or `cache_control` is present); compress it otherwise. + * This is exactly what the legacy `preserveSystemPrompt: false` already + * did via the #3890/#3955 cache guard. + * - `never` → compress the system prompt even when it breaks a prompt cache. + */ +export const PRESERVE_SYSTEM_PROMPT_MODES: readonly PreserveSystemPromptMode[] = [ + "always", + "whenNoCache", + "never", +]; + +export function isPreserveSystemPromptMode(value: unknown): value is PreserveSystemPromptMode { + return ( + typeof value === "string" && + (PRESERVE_SYSTEM_PROMPT_MODES as readonly string[]).includes(value) + ); +} + +/** + * Back-compat shim: derive the authoritative mode from a config. An explicit + * `preserveSystemPromptMode` always wins; otherwise the legacy boolean is mapped + * 1:1 to the behaviour it already had (`false → whenNoCache`, anything else → `always`). + */ +export function normalizePreserveSystemPromptMode( + config: Pick +): PreserveSystemPromptMode { + if (isPreserveSystemPromptMode(config.preserveSystemPromptMode)) { + return config.preserveSystemPromptMode; + } + return config.preserveSystemPrompt === false ? "whenNoCache" : "always"; +} + +/** + * Resolve a mode to the effective engine-facing boolean given whether a cacheable + * prefix is present. `true` = preserve (skip), `false` = compress the system prompt. + */ +export function resolvePreserveSystemPrompt( + mode: PreserveSystemPromptMode, + { hasCache }: { hasCache: boolean } +): boolean { + switch (mode) { + case "always": + return true; + case "never": + return false; + case "whenNoCache": + return hasCache; + } +} + +/** + * The no-cache projection of a mode — the stored/effective boolean used as a sane + * default for any reader that runs before the cache-aware layer materializes it. + */ +export function modeToBaselineBoolean(mode: PreserveSystemPromptMode): boolean { + return resolvePreserveSystemPrompt(mode, { hasCache: false }); +} diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index 755d9256f1..8e2e9b2d7f 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -145,13 +145,34 @@ export interface EngineToggle { level?: string; } +/** T05/C5 — system-prompt preservation intent (see `CompressionConfig.preserveSystemPromptMode`). */ +export type PreserveSystemPromptMode = "always" | "whenNoCache" | "never"; + export interface CompressionConfig { enabled: boolean; defaultMode: CompressionMode; autoTriggerMode?: CompressionMode; autoTriggerTokens: number; cacheMinutes: number; + /** + * Effective, engine-facing boolean: when truthy the system prompt is skipped + * (preserved, not compressed). Kept as the materialized value all engines read. + * Its authoritative *intent* is `preserveSystemPromptMode` (T05/C5); this boolean + * is the no-cache projection of that mode, refined up to `true` by + * `resolveCacheAwareConfig` when a cacheable prefix is detected. + */ preserveSystemPrompt: boolean; + /** + * T05/C5 — authoritative system-prompt preservation intent: + * - `always`: never compress the system prompt. + * - `whenNoCache`: compress it only when there is no cache to protect + * (preserve when the provider caches or `cache_control` is present). This is the + * behaviour the legacy `preserveSystemPrompt: false` already had via the cache guard. + * - `never`: always compress the system prompt, even when it breaks a prompt cache. + * Optional/back-compat: absent → derived from the legacy boolean + * (`false → whenNoCache`, otherwise `always`). + */ + preserveSystemPromptMode?: PreserveSystemPromptMode; mcpDescriptionCompressionEnabled?: boolean; comboOverrides: Record; compressionComboId?: string | null; @@ -289,6 +310,7 @@ export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = { autoTriggerTokens: 0, cacheMinutes: 5, preserveSystemPrompt: true, + preserveSystemPromptMode: "always", mcpDescriptionCompressionEnabled: true, comboOverrides: {}, compressionComboId: null, diff --git a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx index 192ba9f39b..da6a856b06 100644 --- a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx +++ b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx @@ -51,6 +51,7 @@ interface CompressionConfig { enabled: boolean; autoTriggerTokens: number; preserveSystemPrompt: boolean; + preserveSystemPromptMode?: "always" | "whenNoCache" | "never"; engines: Record; activeComboId: string | null; cavemanOutputMode?: CavemanOutputModeConfig; @@ -458,15 +459,25 @@ export default function CompressionPanel() { diff --git a/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx index a68426f21d..da4b993bd7 100644 --- a/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx @@ -65,6 +65,7 @@ interface CompressionConfig extends CompressionTokenSaverConfig { autoTriggerTokens: number; cacheMinutes: number; preserveSystemPrompt: boolean; + preserveSystemPromptMode?: "always" | "whenNoCache" | "never"; mcpDescriptionCompressionEnabled?: boolean; comboOverrides: Record; cavemanConfig?: CavemanConfig; @@ -372,18 +373,26 @@ export default function CompressionSettingsTab() {