agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
275 lines • 14.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.__resetEmptyCorpusWarnings = exports.loadRelevant = void 0;
const index_js_1 = require("../identity/index.js");
const capability_js_1 = require("../store/capability.js");
const fnv1a_js_1 = require("../../lib/fnv1a.js");
const provenance_js_1 = require("../retrieval/provenance.js");
const topK_js_1 = require("../retrieval/topK.js");
const emitEmbedding_js_1 = require("./emitEmbedding.js");
const runRedaction_js_1 = require("../../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')
(0, runRedaction_js_1.emitServed)(scope, type, payload);
}
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 && (0, capability_js_1.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 ??
(0, topK_js_1.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 = (0, index_js_1.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: (0, fnv1a_js_1.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.
(0, emitEmbedding_js_1.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 = (0, provenance_js_1.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 }),
...(0, provenance_js_1.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);
};
}
exports.loadRelevant = loadRelevant;
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. */
function __resetEmptyCorpusWarnings() {
warnedNamespaces.clear();
}
exports.__resetEmptyCorpusWarnings = __resetEmptyCorpusWarnings;
//# sourceMappingURL=loadRelevant.js.map