feat(coding-agent): add prompt cache miss tracking (#6427)
Detect prompt cache misses per turn by comparing each assistant message's cache reads against the previous request's prompt tokens (core/cache-stats.ts). Significant misses emit a warning-colored transcript notice at the turn they occur, noting idle gaps past the cache TTL and model switches when relevant. /session gained cache statistics: a compact token/cache breakdown with hit rate, a $-prefixed cost section with per-model cost breakdown, and the cumulative cost re-billed due to cache misses.
This commit is contained in:
@@ -3016,14 +3016,15 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session statistics.
|
||||
* Get session statistics. Aggregates over ALL session entries (including
|
||||
* history that was compacted away), so token/cost totals reflect what was
|
||||
* actually billed across the session.
|
||||
*/
|
||||
getSessionStats(): SessionStats {
|
||||
const state = this.state;
|
||||
const userMessages = state.messages.filter((m) => m.role === "user").length;
|
||||
const assistantMessages = state.messages.filter((m) => m.role === "assistant").length;
|
||||
const toolResults = state.messages.filter((m) => m.role === "toolResult").length;
|
||||
|
||||
let userMessages = 0;
|
||||
let assistantMessages = 0;
|
||||
let toolResults = 0;
|
||||
let totalMessages = 0;
|
||||
let toolCalls = 0;
|
||||
let totalInput = 0;
|
||||
let totalOutput = 0;
|
||||
@@ -3031,15 +3032,26 @@ export class AgentSession {
|
||||
let totalCacheWrite = 0;
|
||||
let totalCost = 0;
|
||||
|
||||
for (const message of state.messages) {
|
||||
if (message.role === "assistant") {
|
||||
for (const entry of this.sessionManager.getEntries()) {
|
||||
if (entry.type !== "message") continue;
|
||||
totalMessages++;
|
||||
const message = entry.message;
|
||||
if (message.role === "user") {
|
||||
userMessages++;
|
||||
} else if (message.role === "toolResult") {
|
||||
toolResults++;
|
||||
} else if (message.role === "assistant") {
|
||||
assistantMessages++;
|
||||
const assistantMsg = message as AssistantMessage;
|
||||
toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length;
|
||||
totalInput += assistantMsg.usage.input;
|
||||
totalOutput += assistantMsg.usage.output;
|
||||
totalCacheRead += assistantMsg.usage.cacheRead;
|
||||
totalCacheWrite += assistantMsg.usage.cacheWrite;
|
||||
totalCost += assistantMsg.usage.cost.total;
|
||||
if (Array.isArray(assistantMsg.content)) {
|
||||
toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length;
|
||||
}
|
||||
const usage = assistantMsg.usage;
|
||||
totalInput += usage.input;
|
||||
totalOutput += usage.output;
|
||||
totalCacheRead += usage.cacheRead;
|
||||
totalCacheWrite += usage.cacheWrite;
|
||||
totalCost += usage.cost.total;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3050,7 +3062,7 @@ export class AgentSession {
|
||||
assistantMessages,
|
||||
toolCalls,
|
||||
toolResults,
|
||||
totalMessages: state.messages.length,
|
||||
totalMessages,
|
||||
tokens: {
|
||||
input: totalInput,
|
||||
output: totalOutput,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { SessionEntry } from "./session-manager.ts";
|
||||
|
||||
/**
|
||||
* Prompt-cache TTL: idle gaps longer than this are worth mentioning as the
|
||||
* likely cause of a miss. Anthropic's default cache TTL is 5 minutes.
|
||||
*/
|
||||
export const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Per-turn misses at or below this are cache breakpoint granularity noise. */
|
||||
const NOISE_FLOOR_TOKENS = 1024;
|
||||
|
||||
/** A counted cache miss on a single assistant message. */
|
||||
export interface CacheMiss {
|
||||
/** Prompt tokens that were in the previous turn's prompt but not read from cache. */
|
||||
missedTokens: number;
|
||||
/** Extra dollars paid vs. a full cache hit; 0 when pricing is unknown. */
|
||||
missedCost: number;
|
||||
/** Milliseconds since the previous request (which last refreshed the cache). */
|
||||
idleMs: number;
|
||||
/** True when the model changed relative to the previous request. */
|
||||
modelChanged: boolean;
|
||||
}
|
||||
|
||||
export interface CacheWasteTotals {
|
||||
missedTokens: number;
|
||||
missedCost: number;
|
||||
/** Number of counted misses (turns above the noise floor). */
|
||||
missCount: number;
|
||||
}
|
||||
|
||||
/** Minimal pricing lookup, satisfied by ModelRegistry. Cost is $/million tokens. */
|
||||
export interface ModelPriceSource {
|
||||
find(provider: string, modelId: string): { cost: { cacheRead: number } } | undefined;
|
||||
}
|
||||
|
||||
/** The last request seen by the scan; everything in its prompt should be cached. */
|
||||
interface PreviousRequest {
|
||||
promptTokens: number;
|
||||
modelKey: string;
|
||||
timestamp: number;
|
||||
/**
|
||||
* Sticky: some earlier request in this scan segment reported cache activity.
|
||||
* Distinguishes a total miss on a cache-read-only provider (OpenAI-style,
|
||||
* writes unreported) from a provider that never reports caching at all.
|
||||
*/
|
||||
reportedCache: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the cache miss for one assistant message relative to the previous
|
||||
* request. Returns undefined when nothing is counted: first turn, after a
|
||||
* reset, no cache activity ever reported (provider without cache support), or
|
||||
* miss below the noise floor.
|
||||
*/
|
||||
function detectMiss(
|
||||
prev: PreviousRequest | undefined,
|
||||
message: AssistantMessage,
|
||||
models: ModelPriceSource,
|
||||
): CacheMiss | undefined {
|
||||
const usage = message.usage;
|
||||
const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
|
||||
// A zero-cache turn only counts when cache activity was reported before:
|
||||
// on cache-read-only providers that is a total miss, while on providers
|
||||
// that never report caching it means nothing.
|
||||
if (!prev || promptTokens <= 0 || (usage.cacheRead + usage.cacheWrite === 0 && !prev.reportedCache)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const missedTokens = Math.min(prev.promptTokens, promptTokens) - usage.cacheRead;
|
||||
if (missedTokens <= NOISE_FLOOR_TOKENS) return undefined;
|
||||
|
||||
// Extra cost = missed tokens billed at the actual paid rate (input/cacheWrite,
|
||||
// incl. write premium) instead of the cache-read rate. Missed tokens can only
|
||||
// land in the input or cacheWrite buckets, so the paid rate comes straight
|
||||
// from this message's own cost breakdown.
|
||||
const paidTokens = usage.input + usage.cacheWrite;
|
||||
const paidPerToken = paidTokens > 0 ? (usage.cost.input + usage.cost.cacheWrite) / paidTokens : 0;
|
||||
const readPerToken =
|
||||
usage.cacheRead > 0
|
||||
? usage.cost.cacheRead / usage.cacheRead
|
||||
: (models.find(message.provider, message.model)?.cost.cacheRead ?? 0) / 1_000_000;
|
||||
|
||||
return {
|
||||
missedTokens,
|
||||
missedCost: missedTokens * Math.max(0, paidPerToken - readPerToken),
|
||||
idleMs: Math.max(0, message.timestamp - prev.timestamp),
|
||||
modelChanged: `${message.provider}/${message.model}` !== prev.modelKey,
|
||||
};
|
||||
}
|
||||
|
||||
function asPreviousRequest(message: AssistantMessage, reportedCache: boolean): PreviousRequest | undefined {
|
||||
const usage = message.usage;
|
||||
const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
|
||||
if (promptTokens <= 0) return undefined;
|
||||
return {
|
||||
promptTokens,
|
||||
modelKey: `${message.provider}/${message.model}`,
|
||||
timestamp: message.timestamp,
|
||||
reportedCache: reportedCache || usage.cacheRead + usage.cacheWrite > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function scan(
|
||||
entries: SessionEntry[],
|
||||
models: ModelPriceSource,
|
||||
): { prev: PreviousRequest | undefined; totals: CacheWasteTotals; misses: Map<AssistantMessage, CacheMiss> } {
|
||||
let prev: PreviousRequest | undefined;
|
||||
const totals: CacheWasteTotals = { missedTokens: 0, missedCost: 0, missCount: 0 };
|
||||
const misses = new Map<AssistantMessage, CacheMiss>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
||||
// The context legitimately changed; the next turn's prompt is new content,
|
||||
// not re-billed content. Model switches are NOT exempt: they re-bill the
|
||||
// full prompt and should be counted.
|
||||
prev = undefined;
|
||||
continue;
|
||||
}
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const miss = detectMiss(prev, entry.message, models);
|
||||
if (miss) {
|
||||
totals.missedTokens += miss.missedTokens;
|
||||
totals.missedCost += miss.missedCost;
|
||||
totals.missCount += 1;
|
||||
misses.set(entry.message, miss);
|
||||
}
|
||||
prev = asPreviousRequest(entry.message, prev?.reportedCache ?? false) ?? prev;
|
||||
}
|
||||
}
|
||||
return { prev, totals, misses };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumulative cache waste across a session: prompt tokens that should have been
|
||||
* cache reads (they were in the previous turn's prompt) but were re-billed.
|
||||
*/
|
||||
export function computeCacheWaste(entries: SessionEntry[], models: ModelPriceSource): CacheWasteTotals {
|
||||
return scan(entries, models).totals;
|
||||
}
|
||||
|
||||
/**
|
||||
* All counted cache misses across a session, keyed by the assistant message
|
||||
* (by reference) that paid for them. Used to re-derive transcript notices when
|
||||
* rebuilding the chat from entries (resume, post-compaction rebuild).
|
||||
*/
|
||||
export function collectCacheMisses(
|
||||
entries: SessionEntry[],
|
||||
models: ModelPriceSource,
|
||||
): Map<AssistantMessage, CacheMiss> {
|
||||
return scan(entries, models).misses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a cache miss on a just-completed assistant message.
|
||||
* `entries` must not yet contain `message` (message_end fires before persistence).
|
||||
*/
|
||||
export function detectCacheMiss(
|
||||
entries: SessionEntry[],
|
||||
message: AssistantMessage,
|
||||
models: ModelPriceSource,
|
||||
): CacheMiss | undefined {
|
||||
return detectMiss(scan(entries, models).prev, message, models);
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export interface Settings {
|
||||
branchSummary?: BranchSummarySettings;
|
||||
retry?: RetrySettings;
|
||||
hideThinkingBlock?: boolean;
|
||||
showCacheMissNotices?: boolean; // default: false - show transcript notices for significant prompt-cache misses
|
||||
externalEditor?: string; // Command for Ctrl+G external editor; takes precedence over VISUAL/EDITOR
|
||||
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
|
||||
quietStartup?: boolean;
|
||||
@@ -845,6 +846,10 @@ export class SettingsManager {
|
||||
return this.settings.hideThinkingBlock ?? false;
|
||||
}
|
||||
|
||||
getShowCacheMissNotices(): boolean {
|
||||
return this.settings.showCacheMissNotices ?? false;
|
||||
}
|
||||
|
||||
getExternalEditorCommand(): string | undefined {
|
||||
const configuredEditor = this.settings.externalEditor;
|
||||
if (typeof configuredEditor === "string" && configuredEditor.trim() !== "") {
|
||||
@@ -863,6 +868,12 @@ export class SettingsManager {
|
||||
this.save();
|
||||
}
|
||||
|
||||
setShowCacheMissNotices(show: boolean): void {
|
||||
this.globalSettings.showCacheMissNotices = show;
|
||||
this.markModified("showCacheMissNotices");
|
||||
this.save();
|
||||
}
|
||||
|
||||
getShellPath(): string | undefined {
|
||||
return this.settings.shellPath;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user