@sema-agent/core
Version:
Stateless, task-oriented AI agent core
86 lines (85 loc) • 3.82 kB
JavaScript
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 };
}
}