diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index 994cf1e92e..284000fc2d 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,5 +1,32 @@ # AI Source Changes +## 2026-08-14 - Distinguish automatic prompt-cache lifetimes (DeepSeek) + +### What changed and why + +- New browser-safe `PromptCacheLifetime` semantic and `resolvePromptCacheLifetime()`: + `fixed(ttlSeconds) | automatic | disabled | unknown`. `resolvePromptCacheTtlSeconds()` is now a + backwards-compatible wrapper over it, returning the TTL only for `fixed` lifetimes. +- Direct DeepSeek (`provider: "deepseek"` or a `deepseek.com` base URL on the `openai-completions` + lane) classifies as `automatic`: DeepSeek's context cache is enabled by default, best-effort, and + exposes no client-visible deterministic TTL (api-docs.deepseek.com/guides/kv_cache). It no longer + reports a fabricated 300s TTL, and `PI_CACHE_RETENTION=long` no longer fabricates one either. +- Every other lane keeps its exact previous classification (Anthropic 300/3600, Bedrock 300/3600, + OpenRouter cache-control 300/3600, conservative 300 for the remaining `openai-completions` lanes, + Responses lanes 300, unknown lanes `undefined`). + +### Why this cannot be expressed externally + +- The classification lives in the browser-safe TTL resolver that cache-aware tool waits and Goal + monitor scheduling consume. Extensions observe only higher-level requests and cannot rewrite the + provider-agnostic cache-lifetime contract consumed by runtime scheduling. + +### Expected merge conflict zones + +- MEDIUM: `utils/prompt-cache-ttl.ts` around `resolvePromptCacheTtlSeconds()` / the new + `resolvePromptCacheLifetime()`. +- LOW: `index.ts` root export block. + ## 2026-08-13 - Preserve explicit request compatibility fields ### What changed and why diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 69e162346d..2c7f276a26 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -70,10 +70,12 @@ export * from "./utils/event-stream.ts"; export * from "./utils/json-parse.ts"; export { extractOpenAiCodexAccountId } from "./utils/openai-codex-auth.ts"; export * from "./utils/overflow.ts"; +export type { PromptCacheLifetime } from "./utils/prompt-cache-ttl.ts"; export { isAnthropicApiBaseUrl, PROMPT_CACHE_TTL_LONG_SECONDS, PROMPT_CACHE_TTL_SHORT_SECONDS, + resolvePromptCacheLifetime, resolvePromptCacheTtlSeconds, } from "./utils/prompt-cache-ttl.ts"; export * from "./utils/retry.ts"; diff --git a/packages/ai/src/utils/prompt-cache-ttl.ts b/packages/ai/src/utils/prompt-cache-ttl.ts index 9052d8b203..81205ddcb9 100644 --- a/packages/ai/src/utils/prompt-cache-ttl.ts +++ b/packages/ai/src/utils/prompt-cache-ttl.ts @@ -332,48 +332,76 @@ function resolveOpenAIResponsesCacheRetention(cacheRetention?: CacheRetention, e return "short"; } -export function resolvePromptCacheTtlSeconds(model: Model, env?: ProviderEnv): number | undefined { +export type PromptCacheLifetime = + | { readonly kind: "fixed"; readonly ttlSeconds: number } + | { readonly kind: "automatic" } + | { readonly kind: "disabled" } + | { readonly kind: "unknown" }; + +function isDeepSeekOpenAICompletionsModel(model: Model<"openai-completions">): boolean { + return model.provider === "deepseek" || model.baseUrl.includes("deepseek.com"); +} + +/** + * Classify a model's prompt-cache lifetime semantics. + * + * `fixed` carries the deterministic client-visible TTL used by cache-aware + * scheduling; `automatic` means the provider caches best-effort without a + * client-visible TTL contract (direct DeepSeek); `disabled` means caching is + * turned off; `unknown` means the lane has no known cache contract. + */ +export function resolvePromptCacheLifetime(model: Model, env?: ProviderEnv): PromptCacheLifetime { switch (model.api) { case "claude-sdk-oauth": // The Claude SDK owns prompt caching for this lane and uses Anthropic's default 5m TTL. - return PROMPT_CACHE_TTL_SHORT_SECONDS; + return { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS }; case "anthropic-messages": { const anthropicModel = model as Model<"anthropic-messages">; const retention = resolveAnthropicCacheRetention(anthropicModel.cacheRetention, env, "short"); - if (retention === "none") return undefined; + if (retention === "none") return { kind: "disabled" }; return retention === "long" && isAnthropicApiBaseUrl(anthropicModel.baseUrl) && getAnthropicCompat(anthropicModel).supportsLongCacheRetention - ? PROMPT_CACHE_TTL_LONG_SECONDS - : PROMPT_CACHE_TTL_SHORT_SECONDS; + ? { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_LONG_SECONDS } + : { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS }; } case "bedrock-converse-stream": { const bedrockModel = model as Model<"bedrock-converse-stream">; const retention = resolveBedrockCacheRetention(bedrockModel.cacheRetention, env); - if (retention === "none" || !supportsPromptCaching(bedrockModel, env)) return undefined; + if (retention === "none" || !supportsPromptCaching(bedrockModel, env)) return { kind: "disabled" }; return retention === "long" && supportsOneHourCacheTtl(bedrockModel) - ? PROMPT_CACHE_TTL_LONG_SECONDS - : PROMPT_CACHE_TTL_SHORT_SECONDS; + ? { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_LONG_SECONDS } + : { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS }; } case "openai-completions": { const completionsModel = model as Model<"openai-completions">; const retention = resolveOpenAICompletionsCacheRetention(completionsModel.cacheRetention, env); - if (retention === "none") return undefined; + if (retention === "none") return { kind: "disabled" }; + // Direct DeepSeek caches automatically and best-effort with no client-visible TTL + // (api-docs.deepseek.com/guides/kv_cache), so it must not get a fabricated 5m TTL. + if (isDeepSeekOpenAICompletionsModel(completionsModel)) return { kind: "automatic" }; const compat = getOpenAICompletionsCompat(completionsModel); if (compat.cacheControlFormat === "anthropic") { return retention === "long" && compat.supportsLongCacheRetention - ? PROMPT_CACHE_TTL_LONG_SECONDS - : PROMPT_CACHE_TTL_SHORT_SECONDS; + ? { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_LONG_SECONDS } + : { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS }; } - return PROMPT_CACHE_TTL_SHORT_SECONDS; + return { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS }; } case "openai-responses": case "openai-codex-responses": case "azure-openai-responses": { const retention = resolveOpenAIResponsesCacheRetention(model.cacheRetention, env); - return retention === "none" ? undefined : PROMPT_CACHE_TTL_SHORT_SECONDS; + return retention === "none" + ? { kind: "disabled" } + : { kind: "fixed", ttlSeconds: PROMPT_CACHE_TTL_SHORT_SECONDS }; } default: - return undefined; + return { kind: "unknown" }; } } + +export function resolvePromptCacheTtlSeconds(model: Model, env?: ProviderEnv): number | undefined { + const lifetime = resolvePromptCacheLifetime(model, env); + return lifetime.kind === "fixed" ? lifetime.ttlSeconds : undefined; +} diff --git a/packages/ai/test/prompt-cache-lifetime.test.ts b/packages/ai/test/prompt-cache-lifetime.test.ts new file mode 100644 index 0000000000..e183809e47 --- /dev/null +++ b/packages/ai/test/prompt-cache-lifetime.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { type Api, type Model, resolvePromptCacheLifetime, resolvePromptCacheTtlSeconds } from "../src/index.ts"; + +function createModel(api: TApi, overrides: Partial> = {}): Model { + return { + id: "test-model", + name: "Test Model", + api, + provider: "test-provider", + baseUrl: "https://example.com/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + ...overrides, + } as Model; +} + +const anthropicModel = createModel("anthropic-messages", { + provider: "anthropic", + baseUrl: "https://api.anthropic.com/v1", +}); + +const anthropicCompletionsModel = createModel("openai-completions", { + provider: "custom-proxy", + compat: { + cacheControlFormat: "anthropic", + supportsLongCacheRetention: true, + }, +}); + +const cacheableBedrockModel = createModel("bedrock-converse-stream", { + id: "anthropic.claude-3-7-sonnet-20250219-v1:0", + provider: "amazon-bedrock", +}); + +const openAIResponsesModel = createModel("openai-responses", { + provider: "openai", + baseUrl: "https://api.openai.com/v1", +}); + +const deepseekModel = createModel("openai-completions", { + provider: "deepseek", + baseUrl: "https://api.deepseek.com", +}); + +const originalCacheRetention = process.env.PI_CACHE_RETENTION; + +beforeEach(() => { + delete process.env.PI_CACHE_RETENTION; +}); + +afterEach(() => { + if (originalCacheRetention === undefined) { + delete process.env.PI_CACHE_RETENTION; + } else { + process.env.PI_CACHE_RETENTION = originalCacheRetention; + } +}); + +describe("DeepSeek automatic cache lifetime (issue #831)", () => { + it("classifies direct DeepSeek as automatic, not a fixed 5m TTL", () => { + expect(resolvePromptCacheLifetime(deepseekModel)).toEqual({ kind: "automatic" }); + }); + + it("detects DeepSeek through a deepseek.com base URL", () => { + const urlModel = createModel("openai-completions", { + provider: "custom-proxy", + baseUrl: "https://api.deepseek.com/v1", + }); + expect(resolvePromptCacheLifetime(urlModel)).toEqual({ kind: "automatic" }); + }); + + it("reports no fixed TTL for direct DeepSeek via the legacy wrapper", () => { + expect(resolvePromptCacheTtlSeconds(deepseekModel)).toBeUndefined(); + }); + + it("keeps long retention from fabricating a fixed TTL for DeepSeek", () => { + expect(resolvePromptCacheLifetime(deepseekModel, { PI_CACHE_RETENTION: "long" })).toEqual({ + kind: "automatic", + }); + expect(resolvePromptCacheTtlSeconds(deepseekModel, { PI_CACHE_RETENTION: "long" })).toBeUndefined(); + }); +}); + +describe("prompt-cache lifetime classification", () => { + it("maps every other lane exactly as the legacy TTL resolver did", () => { + expect(resolvePromptCacheLifetime(anthropicModel)).toEqual({ kind: "fixed", ttlSeconds: 300 }); + expect(resolvePromptCacheLifetime({ ...anthropicModel, cacheRetention: "long" })).toEqual({ + kind: "fixed", + ttlSeconds: 3600, + }); + expect(resolvePromptCacheLifetime({ ...anthropicModel, cacheRetention: "none" })).toEqual({ kind: "disabled" }); + expect(resolvePromptCacheLifetime(anthropicCompletionsModel)).toEqual({ kind: "fixed", ttlSeconds: 300 }); + expect(resolvePromptCacheLifetime(cacheableBedrockModel)).toEqual({ kind: "fixed", ttlSeconds: 300 }); + expect(resolvePromptCacheLifetime(openAIResponsesModel)).toEqual({ kind: "fixed", ttlSeconds: 300 }); + expect(resolvePromptCacheLifetime(createModel("openai-completions", { cacheRetention: "none" }))).toEqual({ + kind: "disabled", + }); + expect(resolvePromptCacheLifetime(createModel("google-generative-ai"))).toEqual({ kind: "unknown" }); + }); + + it("keeps the legacy wrapper identical to the fixed lifetime", () => { + const lifetime = resolvePromptCacheLifetime(anthropicModel); + expect(resolvePromptCacheTtlSeconds(anthropicModel)).toBe( + lifetime.kind === "fixed" ? lifetime.ttlSeconds : undefined, + ); + }); +}); diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index aeff201a78..a05fe08cb9 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -98,7 +98,7 @@ If your Claude Pro/Max subscription usage through `claude-sdk-oauth` feels unexp | Anthropic-compatible providers (kimi-coding, fireworks, gateways) | 5 minutes | The 1h TTL is gated on the native `api.anthropic.com` base URL, so these lanes stay short. | `cacheRetention` | Override precedence: `cacheRetention` in `models.json` / the model catalog wins over everything. `PI_CACHE_RETENTION=long` selects long; any other set value forces short; unset falls back to the lane default above. -5. **Goal-monitor timing.** The goal monitor's continuation backstop is derived from the model's cache-safe wait (TTL minus `promptCache.safetyBufferSeconds`, default 30), capped by `promptCache.goalBackstopMaxSeconds` (default 3570), instead of a fixed 4 minutes. The default 5m lanes wake every ~4m30s; a supported lane explicitly configured for 1h retention can wait up to 59m30s. Cache-warm notices show which warm iteration you are on. +5. **Goal-monitor timing.** The goal monitor's continuation backstop is derived from the model's cache-safe wait (TTL minus `promptCache.safetyBufferSeconds`, default 30), capped by `promptCache.goalBackstopMaxSeconds` (default 3570), instead of a fixed 4 minutes. The default 5m lanes wake every ~4m30s; a supported lane explicitly configured for 1h retention can wait up to 59m30s. Automatic-cache lanes (e.g. direct DeepSeek) have no client-visible TTL, so they skip the TTL-derived wake entirely and schedule their liveness wake at `promptCache.goalBackstopMaxSeconds` instead. Cache-warm notices show which warm iteration you are on. 6. **Wake sources that hold the goal backstop.** Anything that can wake a parked session publishes a `wake_source_state` event (`{source, activeCount}`): terminal monitors (`terminal-monitors`), background bash sessions including auto-detached and killed ones (`terminal-background-sessions`), detached `eval` cells (`senpi-codemode`), and omo-senpi background task children plus owned team members (`senpi-task`). The goal extension sums every source, so a goal waits inside the prompt-cache TTL while ANY of them is on duty instead of continuing immediately. The legacy `terminal_monitor_state` event is still emitted for external consumers and is folded onto the same `terminal-monitors` count. 7. **Directive-block deduplication.** As of v2026.8.4, the flatten serialization collapses repeated `` directive blocks to a single copy, preventing the issue-#494 scenario where duplicated ~17KB directive blocks consumed up to 73% of the re-sent prompt. The continuity observation reports how many were collapsed and the payload size. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 04835b8a63..af4e5efd8e 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -329,12 +329,14 @@ When unset, senpi leaves provider payloads unchanged. This setting currently app Sizes how long foreground tools may block on the active model's prompt-cache lifetime, so a long `bash` call never straddles cache expiry and forces a full re-read. When the model's cache TTL is -unknown (e.g. Google models) or caching is off, no budget applies and timeout behavior is unchanged. +unknown (e.g. Google models), caching is off, or the provider caches automatically without a +client-visible TTL (e.g. direct DeepSeek), no budget applies and timeout behavior is unchanged. | Setting | Type | Default | Description | |---------|------|---------|-------------| | `promptCache.cacheAwareTimeouts` | boolean | `true` | Cap foreground tool waits at the model's prompt-cache TTL minus the safety buffer; `false` restores the fixed legacy ceilings | | `promptCache.safetyBufferSeconds` | number | `30` | Headroom subtracted from the cache TTL (a 5m TTL yields a 270s ceiling). If it consumes the whole TTL, no budget applies | +| `promptCache.goalBackstopMaxSeconds` | number | `3570` | Maximum Goal monitor continuation backstop. Automatic-cache lanes (e.g. direct DeepSeek) schedule their liveness wake at this value instead of a TTL-derived delay | A foreground `bash` command still running at the budget is handed to a live background session instead of being killed; its explicit `timeout` remains the kill deadline. See diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index c24fcbd150..0795d170c4 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,24 @@ # changes +## Automatic prompt-cache lifetimes yield no cache-derived budget (2026-08-14) + +### What changed + +- `core/prompt-cache-budget.ts` now derives no budget for automatic-cache providers (direct + DeepSeek): `resolvePromptCacheSafeWaitSeconds()` returns `undefined` because pi-ai's TTL wrapper + reports no fixed TTL for `automatic` lifetimes. Cache-aware foreground tool waits fall back to + their legacy ceilings, and the advisory `PI_PROMPT_CACHE_SAFE_WAIT_SECONDS` env mirror is cleared. + +### Why + +- Providers like DeepSeek cache automatically and best-effort with no client-visible TTL, so a + TTL-derived wait budget would be fabricated. The resolver contract stays "fixed-TTL waits only" + (issue #831). + +### Expected merge conflict zones + +- LOW: `core/prompt-cache-budget.ts` docs/behavior note and its budget test suite. + ## Admit provider-owned compaction lanes (2026-08-14) ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm-renderer.ts b/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm-renderer.ts index 62f1277506..363186fe5d 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm-renderer.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm-renderer.ts @@ -41,6 +41,9 @@ function whyLine(data: GoalCacheWarmupEntryData): string { switch (data.phase) { case "scheduled": { const expected = `Continuation expected ${formatExpectedWake(data.dueAtMs, data.delayMs)}`; + if (data.cache?.cacheLifetime === "automatic") { + return `${expected} - provider caching is automatic; the timed wake only keeps the goal alive.`; + } if (data.cache?.ttlSeconds === undefined) { return `${expected} - the monitor wakes the goal the moment decisive output lands.`; } @@ -69,6 +72,9 @@ function warmLine(data: GoalCacheWarmupEntryData): string | undefined { const cache = data.cache; if (cache === undefined || cache.cachedTokens <= 0) return undefined; const tokens = `~${formatWarmTokenCount(cache.cachedTokens)} tokens`; + if (cache.cacheLifetime === "automatic") { + return `${tokens} cached after the prior turn`; + } const ttlMayHaveElapsed = cache.ttlSeconds !== undefined && (data.waitedMs ?? data.delayMs) >= cache.ttlSeconds * 1000; if (ttlMayHaveElapsed) { diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts b/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts index 3bf13a9fe1..575d8041fd 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts @@ -1,5 +1,5 @@ import type { Api, Model, ProviderEnv } from "@earendil-works/pi-ai"; -import { resolvePromptCacheTtlSeconds } from "@earendil-works/pi-ai"; +import { type PromptCacheLifetime, resolvePromptCacheLifetime } from "@earendil-works/pi-ai"; import type { TokenUsageSnapshot } from "./types.ts"; /** Custom session-entry type carrying the cache-warm continuation story. */ @@ -9,10 +9,28 @@ export const GOAL_MONITOR_CONTINUATION_FALLBACK_DELAY_MS = 240_000; const GOAL_MONITOR_CONTINUATION_MIN_DELAY_MS = 1_000; const GOAL_MONITOR_CONTINUATION_HARD_CEILING_MS = 3_600_000; +/** Default liveness backstop for providers whose caching needs no client TTL wake. */ +export const GOAL_MONITOR_LIVENESS_BACKSTOP_DEFAULT_SECONDS = 3570; + +export function resolveGoalMonitorLivenessBackstopMs(goalBackstopMaxSeconds?: number): number { + const backstopSeconds = + typeof goalBackstopMaxSeconds === "number" && + Number.isFinite(goalBackstopMaxSeconds) && + goalBackstopMaxSeconds > 0 + ? goalBackstopMaxSeconds + : GOAL_MONITOR_LIVENESS_BACKSTOP_DEFAULT_SECONDS; + return Math.max( + GOAL_MONITOR_CONTINUATION_MIN_DELAY_MS, + Math.min(backstopSeconds * 1000, GOAL_MONITOR_CONTINUATION_HARD_CEILING_MS), + ); +} + export function resolveGoalMonitorContinuationDelayMs( cacheSafeWaitSeconds: number | undefined, goalBackstopMaxSeconds?: number, + lifetime?: PromptCacheLifetime, ): number { + if (lifetime?.kind === "automatic") return resolveGoalMonitorLivenessBackstopMs(goalBackstopMaxSeconds); if ( typeof cacheSafeWaitSeconds !== "number" || !Number.isFinite(cacheSafeWaitSeconds) || @@ -33,6 +51,8 @@ export function resolveGoalMonitorContinuationDelayMs( export interface GoalCacheWarmMetrics { /** Prompt-cache TTL of the active model in seconds, when known. */ readonly ttlSeconds?: number; + /** Lifetime classification of the active model's prompt cache, when known. */ + readonly cacheLifetime?: "fixed" | "automatic"; /** Tokens sitting warm in the provider prompt cache after the last turn. */ readonly cachedTokens: number; /** Estimated USD saved by re-reading those tokens from cache instead of paying a cold input read. */ @@ -97,17 +117,30 @@ export function estimateCacheWarmMetrics( lastTurnUsage: Pick | undefined, ): GoalCacheWarmMetrics | undefined { const cachedTokens = clampTokens(lastTurnUsage?.cacheRead) + clampTokens(lastTurnUsage?.cacheWrite); - const ttlSeconds = model === undefined ? undefined : resolvePromptCacheTtlSeconds(model, toProviderEnv(env)); - if (ttlSeconds === undefined && cachedTokens === 0) return undefined; + if (model === undefined) return cachedTokens === 0 ? undefined : { cachedTokens }; + const lifetime = resolvePromptCacheLifetime(model, toProviderEnv(env)); const estimatedSavedUsd = - model !== undefined && cachedTokens > 0 + cachedTokens > 0 ? (Math.max(0, model.cost.input - model.cost.cacheRead) * cachedTokens) / TOKENS_PER_PRICE_UNIT : undefined; - return { - cachedTokens, - ...(ttlSeconds !== undefined ? { ttlSeconds } : {}), - ...(estimatedSavedUsd !== undefined ? { estimatedSavedUsd } : {}), - }; + switch (lifetime.kind) { + case "fixed": + return { + cachedTokens, + cacheLifetime: "fixed", + ttlSeconds: lifetime.ttlSeconds, + ...(estimatedSavedUsd !== undefined ? { estimatedSavedUsd } : {}), + }; + case "automatic": + // Automatic best-effort caching has no client-visible TTL and no + // timer-preservation rationale, so neither is reported. + return { cachedTokens, cacheLifetime: "automatic" }; + case "disabled": + case "unknown": + return cachedTokens === 0 + ? undefined + : { cachedTokens, ...(estimatedSavedUsd !== undefined ? { estimatedSavedUsd } : {}) }; + } } export function formatWarmTokenCount(tokens: number): string { @@ -147,7 +180,7 @@ function clampTokens(value: number | undefined): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; } -function toProviderEnv(env: NodeJS.ProcessEnv): ProviderEnv { +export function toProviderEnv(env: NodeJS.ProcessEnv): ProviderEnv { const resolved: Record = {}; for (const [key, value] of Object.entries(env)) { if (value !== undefined) resolved[key] = value; diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/changes.md b/packages/coding-agent/src/core/extensions/builtin/goal/changes.md index da3bc2629b..37674c9b95 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/goal/changes.md @@ -1,5 +1,37 @@ # goal Extension Changes +## Automatic-cache providers use the Goal liveness backstop (2026-08-14) + +### What changed + +- Monitor continuation scheduling is now lifetime-aware: when the active model's prompt cache is + `automatic` (direct DeepSeek), the monitor wait uses the configured Goal liveness backstop + (`promptCache.goalBackstopMaxSeconds`, default 3570s, hard ceiling 1h) instead of the 270s + cache-safe-wait or the 240s unknown fallback. Fixed-TTL lanes keep their existing safe-wait + scheduling, and unknown/disabled lanes keep the 240s fallback. +- Cache-warm metrics for automatic lifetimes carry `cacheLifetime: "automatic"` and omit + `ttlSeconds` and `estimatedSavedUsd`; the renderer explains the wait as a liveness backstop + ("provider caching is automatic; the timed wake only keeps the goal alive") and reports tokens + neutrally ("~X tokens cached after the prior turn") without "kept warm" or savings claims. + Fixed-TTL rendering is unchanged. + +### Why + +- DeepSeek's official context cache is automatic, best-effort, and exposes no 5-minute TTL; + waking every 4m30s to "preserve" it was scheduling under a fabricated contract (issue #831). + +### Why this is not extension-only + +- The continuation scheduler and cache-warm entry/renderer are private builtin goal surfaces, and + the lifetime classification comes from pi-ai's browser-safe resolver. + +### Merge-conflict zones + +- MEDIUM in `monitor-continuation.ts` around `#schedule` delay resolution. +- MEDIUM in `cache-warm.ts` around `resolveGoalMonitorContinuationDelayMs()` and + `estimateCacheWarmMetrics()`. +- LOW in `cache-warm-renderer.ts` automatic copy branch. + ## Explicit resume revives completed goals (2026-08-11) ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation.ts b/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation.ts index a48e530167..47433b844d 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation.ts @@ -1,4 +1,5 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { resolvePromptCacheLifetime } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "../../types.ts"; import { isTerminalMonitorStateEvent, @@ -15,6 +16,7 @@ import { type GoalCacheWarmupEntryData, type LiveGoalCacheWarmupEntryData, resolveGoalMonitorContinuationDelayMs, + toProviderEnv, } from "./cache-warm.ts"; export { GOAL_MONITOR_CONTINUATION_FALLBACK_DELAY_MS } from "./cache-warm.ts"; @@ -269,11 +271,15 @@ export class MonitorAwareGoalContinuation { #schedule(goal: Goal, kind: DelayedContinuationKind): void { if (this.#scheduledContinuationKind !== undefined) return; + const ctx = this.#ctx; + const lifetime = + ctx?.model === undefined ? undefined : resolvePromptCacheLifetime(ctx.model, toProviderEnv(process.env)); const delayMs = kind === "monitor" ? resolveGoalMonitorContinuationDelayMs( - this.#ctx?.getPromptCacheSafeWaitSeconds?.(), - this.#ctx?.getPromptCacheGoalBackstopMaxSeconds?.(), + ctx?.getPromptCacheSafeWaitSeconds?.(), + ctx?.getPromptCacheGoalBackstopMaxSeconds?.(), + lifetime, ) : GOAL_USER_GRACE_DELAY_MS; this.#scheduledDelayMs = delayMs; diff --git a/packages/coding-agent/test/suite/prompt-cache-budget.test.ts b/packages/coding-agent/test/suite/prompt-cache-budget.test.ts index 055c2d5493..310b067aa2 100644 --- a/packages/coding-agent/test/suite/prompt-cache-budget.test.ts +++ b/packages/coding-agent/test/suite/prompt-cache-budget.test.ts @@ -21,6 +21,21 @@ function anthropicModel(overrides: Partial> = {}): M } as Model<"anthropic-messages">; } +function deepseekModel(): Model<"openai-completions"> { + return { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + api: "openai-completions", + provider: "deepseek", + baseUrl: "https://api.deepseek.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + } as Model<"openai-completions">; +} + function googleModel(): Model<"google-generative-ai"> { return { id: "gemini-3-pro", @@ -59,6 +74,10 @@ describe("resolvePromptCacheSafeWaitSeconds", () => { expect(resolvePromptCacheSafeWaitSeconds(googleModel() as Model, undefined, {})).toBeUndefined(); }); + it("returns undefined for automatic-cache providers like direct DeepSeek", () => { + expect(resolvePromptCacheSafeWaitSeconds(deepseekModel(), undefined, {})).toBeUndefined(); + }); + it("returns undefined when no model is active", () => { expect(resolvePromptCacheSafeWaitSeconds(undefined, undefined, {})).toBeUndefined(); }); diff --git a/packages/coding-agent/test/suite/regressions/issue-831-deepseek-cache-lifetime.test.ts b/packages/coding-agent/test/suite/regressions/issue-831-deepseek-cache-lifetime.test.ts new file mode 100644 index 0000000000..0bddb02c23 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/issue-831-deepseek-cache-lifetime.test.ts @@ -0,0 +1,172 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { + GoalCacheWarmMetrics, + GoalCacheWarmupEntryData, +} from "../../../src/core/extensions/builtin/goal/cache-warm.ts"; +import { renderGoalCacheWarmupEntry } from "../../../src/core/extensions/builtin/goal/cache-warm-renderer.ts"; +import type { CustomEntry } from "../../../src/core/session-manager.ts"; +import { initTheme, theme } from "../../../src/modes/interactive/theme/theme.ts"; +import { + cleanAssistantStop, + cleanupGoalMonitorTempDirs, + createGoalHarness, + type GoalHarness, + makeGoalContext, + runGoalHandlers, + waitForSentCount, +} from "../goal-monitor-test-harness.ts"; + +const ANSI_PATTERN = /\u001b\[[0-9;]*m/g; +const LIVENESS_BACKSTOP_MS = 3_570_000; + +function deepseekModel(): Model { + return { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + api: "openai-completions", + provider: "deepseek", + baseUrl: "https://api.deepseek.com", + reasoning: false, + input: ["text"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 128_000, + maxTokens: 8192, + } as Model; +} + +type ScheduledContinuationEvent = { + readonly goalId: string; + readonly delayMs: number; + readonly iteration: number; + readonly activeMonitorCount: number; + readonly wakeSources: Readonly>; + readonly cache?: GoalCacheWarmMetrics; +}; + +function scheduledEvent(harness: GoalHarness): ScheduledContinuationEvent | undefined { + const event = harness.events.emitted.find((entry) => entry.channel === "goal_continuation_scheduled"); + return event?.data as ScheduledContinuationEvent | undefined; +} + +function renderEntry(data: GoalCacheWarmupEntryData): string { + const entry: CustomEntry = { + type: "custom", + id: "entry-issue-831", + parentId: null, + timestamp: "2026-08-12T00:00:00.000Z", + customType: "goal-cache-warmup", + data, + }; + const component = renderGoalCacheWarmupEntry(entry, { expanded: false }, theme); + return (component?.render(100) ?? []).join("\n").replace(ANSI_PATTERN, ""); +} + +async function setupDeepSeekHarness( + threadId: string, + state: { readonly goalBackstopMaxSeconds?: number } = {}, +): Promise<{ harness: GoalHarness; notices: string[]; ctx: Awaited> }> { + const notices: string[] = []; + const harness = createGoalHarness(); + const ctx = await makeGoalContext(notices, threadId, { + pendingMessages: false, + model: deepseekModel(), + goalBackstopMaxSeconds: state.goalBackstopMaxSeconds, + }); + await harness.tools.get("create_goal")?.execute("create", { objective: "Keep watching" }, undefined, undefined, ctx); + await runGoalHandlers(harness.handlers, "session_start", { type: "session_start", reason: "reload" }, ctx); + harness.events.emit("terminal_monitor_state", { activeCount: 1 }); + await harness.events.flush(); + await runGoalHandlers(harness.handlers, "agent_start", { type: "agent_start" }, ctx); + await runGoalHandlers( + harness.handlers, + "agent_end", + { type: "agent_end", messages: [cleanAssistantStop({ cacheRead: 100_000, cacheWrite: 20_000 })] }, + ctx, + ); + return { harness, notices, ctx }; +} + +describe("issue #831: direct DeepSeek must not wake the goal every 4m30 for a fabricated 5m TTL", () => { + beforeAll(() => { + initTheme("dark"); + }); + + afterEach(async () => { + vi.useRealTimers(); + await cleanupGoalMonitorTempDirs(); + }); + + it("schedules the monitor continuation at the liveness backstop, not a cache-preservation delay", async () => { + vi.useFakeTimers(); + const { harness } = await setupDeepSeekHarness("issue-831-deepseek-cache-lifetime"); + + const scheduled = scheduledEvent(harness); + expect(scheduled).toBeDefined(); + expect(scheduled?.delayMs).toBe(LIVENESS_BACKSTOP_MS); + expect(scheduled?.cache?.ttlSeconds).toBeUndefined(); + expect(scheduled?.cache?.estimatedSavedUsd).toBeUndefined(); + expect(scheduled?.cache).toEqual(expect.objectContaining({ cachedTokens: 120_000, cacheLifetime: "automatic" })); + + await vi.advanceTimersByTimeAsync(LIVENESS_BACKSTOP_MS - 1); + expect(harness.sent).toHaveLength(0); + const delivered = waitForSentCount(harness, 1); + await vi.advanceTimersByTimeAsync(1); + await delivered; + expect(harness.sent).toHaveLength(1); + }); + + it("honors the configured goal backstop as the automatic-cache liveness ceiling", async () => { + vi.useFakeTimers(); + const { harness } = await setupDeepSeekHarness("issue-831-deepseek-cache-backstop", { + goalBackstopMaxSeconds: 900, + }); + + expect(scheduledEvent(harness)?.delayMs).toBe(900_000); + await vi.advanceTimersByTimeAsync(899_999); + expect(harness.sent).toHaveLength(0); + const delivered = waitForSentCount(harness, 1); + await vi.advanceTimersByTimeAsync(1); + await delivered; + expect(harness.sent).toHaveLength(1); + }); + + it("renders the automatic-cache wait without TTL, warmth, or savings claims", () => { + const text = renderEntry({ + phase: "scheduled", + goalId: "goal-issue-831", + delayMs: LIVENESS_BACKSTOP_MS, + dueAtMs: 1_786_492_800_000 + LIVENESS_BACKSTOP_MS, + iteration: 1, + activeMonitorCount: 1, + cache: { cachedTokens: 120_000, cacheLifetime: "automatic" }, + }); + + expect(text).toContain("Continuation expected ready 2026-08-12 00:59 UTC (59m 30s)"); + expect(text).toContain("provider caching is automatic"); + expect(text).toContain("timed wake only keeps the goal alive"); + expect(text).toContain("~120K tokens cached after the prior turn"); + expect(text).not.toContain("prompt-cache TTL"); + expect(text).not.toContain("kept warm"); + expect(text).not.toContain("saved"); + }); + + it("renders the resumed automatic-cache wake neutrally", () => { + const text = renderEntry({ + phase: "resumed", + goalId: "goal-issue-831", + delayMs: LIVENESS_BACKSTOP_MS, + waitedMs: LIVENESS_BACKSTOP_MS, + dueAtMs: 1_786_492_800_000 + LIVENESS_BACKSTOP_MS, + iteration: 2, + activeMonitorCount: 1, + cache: { cachedTokens: 120_000, cacheLifetime: "automatic" }, + }); + + expect(text).toContain("Cache-warm wake ยท iteration 2"); + expect(text).toContain("~120K tokens cached after the prior turn"); + expect(text).not.toContain("stayed warm"); + expect(text).not.toContain("saved"); + expect(text).not.toContain("prompt-cache TTL"); + }); +});