UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

138 lines • 6.68 kB
/** * writeSnapshot — write-side stage for Causal memory. * * Captures the current run's `(query, finalContent)` pair from * `scope.newMessages` (populated by the Agent's PrepareFinal stage), * embeds the query for retrieval, and persists a `SnapshotEntry` to * the store. Future turns can match new questions against past * queries via cosine similarity to replay decision evidence. * * Reads from scope: `identity`, `turnNumber`, `newMessages` * Writes to store: one `SnapshotEntry` per call, id = `snap-{turn}` * * Why per-turn (not per-iteration)? * Causal memory captures TURN outcomes — "user asked X, agent said Y." * Mid-iteration state isn't useful for cross-run replay. * * Turn derivation — collisions are impossible by construction: * The effective turn is `max(scope.turnNumber, maxStoredTurn + 1)` where * `maxStoredTurn` is the highest `snap-{n}` already live in THIS * conversation's namespace (`identityNamespace(identity)` — the durable * conversation anchor across `run()` calls, Agent instances, and * processes). Rationale: * - Hosts that track `turnNumber` correctly keep their numbering * (`turnNumber: 5` → `snap-5`, gaps preserved). * - Hosts with a stale counter still get a fresh, ordered id — turn 2 * of the same conversation lands `snap-2` instead of silently * replacing `snap-1`. * Causal snapshots are decision evidence (audit/replay data): when * "stale counter" and "deliberate same-turn rewrite" are * indistinguishable, never destroying a prior turn's evidence wins. * TTL-expired snapshots are ignored by the scan (same as every read). * * Since 9.6.0 this is SELF-DEFENSE, not the primary mechanism. The Agent * resolves the turn once per run against the same stores (`seed` → * `resolveTurnNumber`), so under an Agent `scope.turnNumber` already * satisfies this rule and the scan changes nothing — it re-answers what * it is given. It stays for hand-composed pipelines mounted through * `mountMemoryWrite` in a host that numbers its own turns, which is * exactly the case the Agent's resolution cannot reach. * * The scan is deliberately narrowed to `snap-{n}` ids rather than to * every turn-stamped entry: `writeMessages` may already have written * turn N into a SHARED store by the time this stage runs, and a scan that * counted those would answer N+1 and file this turn's evidence under a * turn its own messages do not have. * * Empty-newMessages handling: * When `newMessages` is empty (no final answer produced — e.g. * pause-resume mid-flight), the stage no-ops. Re-runs after resume * capture the snapshot then. * * @see ./types.ts for the SnapshotEntry shape this writes * @see ./loadSnapshot.ts for the read-side counterpart */ import { maxStoredTurn, normalizeHostTurn } from '../turn/resolveTurnNumber.js'; /** Ids written by this stage: `snap-{turn}`. Used to find the highest * turn already persisted for a conversation (other entry kinds sharing * the store — `msg-{turn}-{idx}`, beats, facts — never match). */ const SNAPSHOT_ID_PATTERN = /^snap-(\d+)$/; /** * Highest turn number among the LIVE snapshots already stored for this * conversation. 0 when none. One paged `list()` scan per turn-write — * snapshot writes happen once per turn, and the namespace is a single * conversation, so the scan stays small. * * The paging (and its bound) is `maxStoredTurn`'s, shared with the Agent's * per-run resolution; only WHICH entries count is this stage's own. */ async function maxStoredSnapshotTurn(store, identity) { return maxStoredTurn(store, identity, { turnFor: (entry) => { const match = SNAPSHOT_ID_PATTERN.exec(entry.id); return match ? Number(match[1]) : 0; }, }); } export function writeSnapshot(config) { const { store, embedder } = config; const embedderId = config.embedderId ?? 'unknown-embedder'; return async (scope) => { const identity = scope.identity; const newMessages = (scope.newMessages ?? []); if (newMessages.length === 0) return; // Extract the (query, finalContent) pair from newMessages — // populated by Agent's PrepareFinal stage as [user, assistant]. const userMsg = newMessages.find((m) => m.role === 'user'); const assistantMsg = newMessages.find((m) => m.role === 'assistant'); const query = userMsg?.content ?? ''; const finalContent = assistantMsg?.content ?? ''; if (query.length === 0) return; // No query → no useful snapshot. // Effective turn — anchored on the store, not the host's counter // (see header: "Turn derivation"). Guarantees distinct, ordered ids for // consecutive turns of one conversation even when the host's own counter // is stale — which the Agent's is not since 9.6.0, and a hand-composed // host's may still be. const hostTurn = normalizeHostTurn(scope.turnNumber); const storedMax = await maxStoredSnapshotTurn(store, identity); const turn = Math.max(hostTurn, storedMax + 1); const signal = scope.$getEnv?.()?.signal; const queryVec = (await embedder.embed({ text: query, ...(signal ? { signal } : {}), })); const now = Date.now(); const ttl = config.ttlMs ? now + config.ttlMs : undefined; // Evidence bridge (#5): the wire layer delivers run evidence harvested by // `causalEvidenceRecorder` via the write mount's `evidenceSource`. Absent // (non-agent hosts, no recorder attached) → zeros, as before. const evidence = scope.runEvidence; const snapshot = { query, finalContent, iterations: evidence?.iterations ?? 0, decisions: evidence?.decisions ?? [], toolCalls: evidence?.toolCalls ?? [], durationMs: evidence?.durationMs ?? 0, tokenUsage: evidence?.tokenUsage ?? { input: 0, output: 0 }, }; const entry = { id: `snap-${turn}`, value: snapshot, version: 1, createdAt: now, updatedAt: now, lastAccessedAt: now, accessCount: 0, embedding: [...queryVec], embeddingModel: embedderId, ...(ttl !== undefined && { ttl }), ...(config.tier && { tier: config.tier }), source: { turn, identity }, }; await store.put(identity, entry); }; } //# sourceMappingURL=writeSnapshot.js.map