UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

86 lines (85 loc) • 3.82 kB
export function toolsToFingerprintInputs(tools) { return tools.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })); } const BREAK_REL = 0.05; const BREAK_ABS = 2000; const SERVER_GAP_MS = 5 * 60_000; function fnv1a(s) { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } function stableStringify(v) { return JSON.stringify(v, (_k, val) => val && typeof val === "object" && !Array.isArray(val) ? Object.fromEntries(Object.keys(val).sort().map((k) => [k, val[k]])) : val); } export class CacheBreakDetector { prev; notifyCompaction() { this.prev = undefined; } observe(input) { const snap = { systemHash: fnv1a(input.systemPrompt), perToolHash: new Map(input.tools.map((t) => [t.name, fnv1a(`${t.name}\0${t.description}\0${stableStringify(t.parameters)}`)])), toolSetHash: fnv1a([...input.tools.map((t) => t.name)].sort().join(",")), toolOrderHash: fnv1a(input.tools.map((t) => t.name).join(",")), modelKey: input.modelKey, cacheRead: input.cacheRead, at: Date.now(), }; const prev = this.prev; this.prev = snap; if (!prev) return undefined; const before = prev.cacheRead; const after = input.cacheRead; if (before <= 0) return undefined; const drop = before - after; if (!(drop > BREAK_ABS && drop / before > BREAK_REL)) return undefined; let cause; let detail; if (snap.modelKey !== prev.modelKey) { cause = "model-switch"; detail = `model switched ${prev.modelKey} → ${snap.modelKey} (different cache namespace)`; } else { const changed = [...snap.perToolHash].find(([name, h]) => { const p = prev.perToolHash.get(name); return p !== undefined && p !== h; }); if (changed) { cause = "tool-schema"; detail = `tool "${changed[0]}" description/schema changed (drifting tool defs are the most common prefix breaker)`; } else if (snap.toolSetHash !== prev.toolSetHash) { cause = "tool-set"; detail = "the tool set changed (a tool was added or removed)"; } else if (snap.toolOrderHash !== prev.toolOrderHash) { cause = "tool-set"; detail = "the tool ORDER changed (same tools, different positions) — a reordered tool list is a byte-different prefix from the first moved tool onward, so the provider re-bills the whole tail"; } else if (snap.systemHash !== prev.systemHash) { cause = "system-prefix"; detail = "the system prompt prefix changed (volatile content placed up front?)"; } else { const gapMs = snap.at - prev.at; cause = "server-or-ttl"; const checked = "nothing this detector fingerprints (system prefix, tool set + order, model)"; detail = gapMs < SERVER_GAP_MS ? `${checked} changed and the gap was ${Math.round(gapMs / 1000)}s — most likely a server-side miss; the message history is not fingerprinted, so volatile content early in the conversation would also land here` : `${checked} changed and the gap was ${Math.round(gapMs / 60_000)}min — most likely a normal TTL expiry (cold cache)`; } } return { turn: input.turn, cacheReadBefore: before, cacheReadAfter: after, cause, detail }; } }