UNPKG

agentfootprint

Version:

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

270 lines • 13.7 kB
import { identityNamespace } from '../identity/index.js'; import { resolveRankingMode } from '../store/capability.js'; import { fnv1a } from '../../lib/fnv1a.js'; import { chunkProvenance, chunkText } from '../retrieval/provenance.js'; import { topK } from '../retrieval/topK.js'; import { emitEmbedding } from './emitEmbedding.js'; import { emitServed } from '../../redaction/runRedaction.js'; /** * Default query extractor — last user message. * * Inside the memory-read subflow (mounted by `mountMemoryRead`), the * current turn's messages are piped in as `scope.messages` via the * mount's inputMapper. Falls back to `newMessages` for custom pipelines * that wire differently. */ function defaultQueryFrom(scope) { const scopeAny = scope; const incoming = scopeAny.messages ?? []; const source = incoming.length > 0 ? incoming : (scope.newMessages ?? []); for (let i = source.length - 1; i >= 0; i--) { const m = source[i]; if (m.role !== 'user') continue; if (m.content) return m.content; } return ''; } /** Emit through the scope's emit channel when there is one (there always is under an * Agent) — served by the run's redaction like every typed event * (`src/redaction/runRedaction.ts` · `emitServed`). */ function emit(scope, type, payload) { const emitter = scope.$emit; if (typeof emitter === 'function') emitServed(scope, type, payload); } export function loadRelevant(config) { const { store, embedder } = config; if (!store.search) { throw new Error('loadRelevant: the configured store does not implement search(). ' + 'Use a store that can retrieve (InMemoryStore, sqliteVectorStore, pgVectorStore, ' + 's3VectorsStore, ...).'); } // Fail-loud at STAGE BUILD time, as this stage has always done for a store // with no `search()`: a retriever with no embedder over a store that ranks // vectors is a config bug, not a runtime condition, and its symptom would be // an empty answer. if (!embedder && resolveRankingMode(store, 'loadRelevant') !== 'server-text') { throw new Error('loadRelevant: no `embedder` was configured, and this store does not declare ' + "`ranksBy: 'server-text'`. Something has to turn the query into a vector before a " + 'vector-ranking store can answer it.'); } const queryFrom = config.queryFrom ?? defaultQueryFrom; // The shorthand and the strategy are two spellings of one rule; the // strategy wins when both are present, and `defineMemory` refuses the // contradiction before it ever gets here. const strategy = config.retrieval ?? topK({ k: config.k ?? 20, threshold: config.minScore === undefined ? null : config.minScore, // With no explicit strategy there is no explicit reject window // either; 10 near-misses is enough to diagnose a threshold without // making every retrieval read a large page. }); const poolSize = strategy.k + strategy.rejectWindow; return async (scope) => { const identity = scope.identity; const namespace = identityNamespace(identity); const text = queryFrom(scope).trim(); const baseEvidence = { // Named here, in the base, so the EMPTY-QUERY record carries it too: a // retrieval that never ran still ran under a rule, and "which retriever // was this?" is exactly the question an empty record raises. strategy: strategy.name, queryHash: fnv1a(text), k: strategy.k, ...(strategy.threshold !== undefined && { threshold: strategy.threshold }), ...(config.embedderId !== undefined && { embedderId: config.embedderId }), selectionOrder: 'recency', namespace, }; if (text.length === 0) { scope.loaded = []; const emptyQuery = { ...baseEvidence, consideredCount: 0, admittedCount: 0, rejectedCount: 0, candidates: [], candidatesComplete: true, // The corpus was never asked, so it cannot be reported empty. corpusEmpty: false, }; scope.retrieved = emptyQuery; return; } const signal = scope.$getEnv?.()?.signal; // No embedder means a store that ranks the TEXT server-side: there is no // vector to make, so none is made, none is billed, and no embedding event // is emitted. `[]` is what the port's `query` argument honestly is here, // and `options.text` below is the query such a store actually reads. let queryVec = []; if (embedder) { const embedStartedAt = Date.now(); queryVec = (await embedder.embed({ text, ...(signal ? { signal } : {}), })); // The QUERY-time half of the two-phase cost model (8.9.0): one embedding // per retrieval, scaling with traffic rather than with corpus size. Paired // with the `'document'` side in `embedMessages`, this is what makes the // cost of a corpus a number you can read rather than one you estimate. emitEmbedding(scope, { model: config.embedderId ?? embedder.id ?? 'unknown', provider: 'custom', inputKind: 'query', dimension: queryVec.length, count: 1, durationMs: Date.now() - embedStartedAt, }); } // store.search optional on MemoryStore but required when an embedder // is configured (validated upstream by defineMemory). // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const results = (await store.search(identity, queryVec, { k: poolSize, // `minScore` is deliberately NOT forwarded — see the header proof. ...(config.tiers && { tiers: config.tiers }), ...(config.embedderId !== undefined && { embedderId: config.embedderId }), text, })); // A store that ranks server-side may return entries without a usable // score. We report that rather than printing a number we did not get. const scoresUsable = results.every((r) => typeof r.score === 'number' && Number.isFinite(r.score)); const verdicts = scoresUsable ? strategy.select(results.map((r) => ({ entry: r.entry, score: r.score }))) : // No comparable scores to rule on. The store already ranked, so we // honour its order and take the first `k` — and say in the record // that we could not see the candidates ourselves. results.map((_, i) => ({ admitted: i < strategy.k })); // ── The SIZE bound, spent in rank order (8.19.0) ────────────────── // `k` bounds how MANY passages reach the prompt and says nothing about // how much text that is. Ten ordinary headings is eleven thousand // characters, which over-runs the system-prompt slot's 4000-char // default with nothing but defaults on either side. `maxChars` is the // bound on the other axis. // // Rank order, and the TAIL goes: the first passage that does not fit // ends the spend, and it and everything after it are refused with // `over-char-budget`. Not "skip it and try the next smaller one" — // that would silently reorder relevance by length, and a reader of the // record could not tell a budget drop from a bad score. A budget // smaller than the best-scoring passage therefore admits NOTHING, and // says so per candidate rather than half-injecting. const maxChars = config.maxChars; const droppedForChars = new Set(); let charsUsed = 0; if (maxChars !== undefined) { let budgetSpent = false; verdicts.forEach((verdict, i) => { if (!verdict.admitted) return; if (budgetSpent) { droppedForChars.add(i); return; } // `verdicts` is built from `results`, one per element. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const cost = chunkText(results[i].entry.value).length; if (charsUsed + cost > maxChars) { budgetSpent = true; droppedForChars.add(i); return; } charsUsed += cost; }); } const admitted = []; const candidates = []; results.forEach((result, i) => { // `verdicts` is built from `results` above, one per element. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const verdict = verdicts[i]; const overChars = droppedForChars.has(i); const isAdmitted = verdict.admitted && !overChars; if (isAdmitted) admitted.push(result); const reason = overChars ? 'over-char-budget' : verdict.reason; candidates.push({ id: result.entry.id, score: result.score, rank: i + 1, admitted: isAdmitted, ...(reason !== undefined && { reason }), ...chunkProvenance(result.entry.value), }); }); const evidence = { ...baseEvidence, ...(queryVec.length > 0 && { dimensions: queryVec.length }), ...(maxChars !== undefined && { maxChars, charsUsed }), consideredCount: results.length, admittedCount: admitted.length, rejectedCount: results.length - admitted.length, ...(scoresUsable ? { candidates } : { candidatesOmittedReason: 'the store ranked this query server-side and returned no comparable scores', }), // The pool came back full, so there may be further below-threshold // entries we never saw. This never weakens the ADMITTED set (see // the header proof) — only the rejected list is a sample. candidatesComplete: results.length < poolSize, corpusEmpty: results.length === 0, }; scope.loaded = admitted.map((r) => r.entry); scope.retrieved = evidence; // Telemetry rides the emit channel, never a commit payload. The // memory bridge (`memoryRecorder`) is attached on every Agent run, so // this reaches `agent.on('agentfootprint.memory.retrieved')` with no // extra wiring. Which retriever fired is `meta.runtimeStageId` // (`sf-memory-read-<id>/load-relevant#N`) — the house correlation model. emit(scope, 'agentfootprint.memory.retrieved', { strategy: evidence.strategy, queryHash: evidence.queryHash, k: evidence.k, ...(evidence.threshold !== undefined && { threshold: evidence.threshold }), ...(evidence.maxChars !== undefined && { maxChars: evidence.maxChars, charsUsed: evidence.charsUsed, }), ...(evidence.embedderId !== undefined && { embedderId: evidence.embedderId }), ...(evidence.dimensions !== undefined && { dimensions: evidence.dimensions }), consideredCount: evidence.consideredCount, admittedCount: evidence.admittedCount, rejectedCount: evidence.rejectedCount, ...(evidence.candidates !== undefined && { candidates: evidence.candidates }), ...(evidence.candidatesOmittedReason !== undefined && { candidatesOmittedReason: evidence.candidatesOmittedReason, }), candidatesComplete: evidence.candidatesComplete, corpusEmpty: evidence.corpusEmpty, namespace: evidence.namespace, }); // An empty namespace is almost never "the corpus has nothing to say". // It is almost always "the corpus was indexed under a different // identity than the one being queried" — the failure that used to be // completely silent. Say it once per process, naming both sides. if (evidence.corpusEmpty) warnEmptyCorpus(namespace); }; } const warnedNamespaces = new Set(); function warnEmptyCorpus(namespace) { if (warnedNamespaces.has(namespace)) return; warnedNamespaces.add(namespace); console.warn(`[agentfootprint] Semantic retrieval found NO entries at all in namespace '${namespace}'.\n` + ` Nothing was injected, and the answer will come from the model alone.\n` + ` The usual cause is that the corpus was indexed under a different identity than the one\n` + ` being queried: indexDocuments() defaults to { conversationId: '_global' }, and a memory\n` + ` reads under the identity passed to agent.run() unless the retriever declares its own\n` + ` \`corpus\`. defineRAG() defaults \`corpus\` to { conversationId: '_global' } so the two\n` + ` sides meet; pass \`corpus\` explicitly when you index per tenant.`); } /** Test seam — the once-per-process warning is per PROCESS, which tests must be able to reset. */ export function __resetEmptyCorpusWarnings() { warnedNamespaces.clear(); } //# sourceMappingURL=loadRelevant.js.map