agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
249 lines • 12.7 kB
JavaScript
;
/**
* defineRAG — retrieval-augmented generation over a document corpus.
*
* Embed the user's question, retrieve the most similar chunks from a
* vector store, inject them into the system-prompt slot of the next LLM
* call. It is the same machinery as `defineMemory({ type: SEMANTIC,
* strategy: TOP_K })` — with three differences that are not cosmetic:
*
* defineMemory ─┬─► EPISODIC (raw conversation)
* ├─► SEMANTIC (extracted facts / RAG chunks)
* ├─► NARRATIVE (beats / summaries)
* └─► CAUSAL (footprintjs decision snapshots)
*
* defineRAG ─► SEMANTIC + TOP_K, READ-ONLY, over a NAMED CORPUS,
* rendered as citable <source> blocks.
*
* ─── What changed in 8.8.0, and why ────────────────────────────────────
*
* **1. A corpus is read-only.** Until 8.8.0 `defineRAG` also mounted the
* semantic pipeline's WRITE half, which embedded every conversation turn
* and stored it in the same namespace as the documents. The consequence
* was measurable and bad: the user's own question, re-embedded, is the
* single best-scoring "document" in the corpus (cosine 1.0 against
* itself), so it came back as retrieval hit #1 and consumed a slot in
* the top-K budget that a real passage should have had. A corpus is not
* a conversation log. Nothing this retriever reads is written back.
*
* If you want conversation memory ALONGSIDE a corpus, that is what
* `defineMemory` is for, and the two are registered separately, each
* with its own store:
*
* ```ts
* const agent = Agent.create({ provider })
* .rag(defineRAG({ id: 'product-docs', store: corpusStore, embedder }))
* .memory(defineMemory({
* id: 'chat',
* type: MEMORY_TYPES.EPISODIC,
* strategy: { kind: MEMORY_STRATEGIES.WINDOW, size: 10 },
* store: conversationStore,
* }))
* .build();
* ```
*
* **2. The corpus has its own namespace.** A memory reads under the
* identity passed to `agent.run()`. For conversation recall that is
* right. For a shared corpus it was the reason the documented example
* retrieved NOTHING: `indexDocuments` writes under
* `{ conversationId: '_global' }` by default, while a run with no
* explicit identity gets `{ conversationId: 'run-<timestamp>-<n>' }` —
* so the write side and the read side never met, silently. `corpus`
* defaults to the same `'_global'` the indexer uses, so the documented
* example works with no extra arguments; pass it explicitly when you
* index per tenant.
*
* **3. Chunks are citable.** Retrieved passages render as
* `<source id="refunds.md#3" doc="refunds.md" score="0.81">` under a
* corpus header, instead of the conversation-recall shape that printed
* `role="unknown" turn="0"` on a page of a PDF and gave the model
* nothing to cite.
*
* Pattern: Composition over duplication — defineRAG returns a
* MemoryDefinition produced by defineMemory. No new engine
* code, no new slot subflow, no new stage.
*
* Role: Layer-3 context-engineering primitive. Lives next to
* defineSkill / defineSteering / defineInstruction / defineFact
* but resolves to a memory subflow rather than an Injection
* (RAG content is computed at runtime via async retrieval —
* can't fit the synchronous Injection.inject shape).
*
* Emits: `agentfootprint.memory.retrieved` (once per retrieval, with
* every candidate and its score — including the rejected ones),
* `agentfootprint.memory.attached` (once per chunk that reached
* the prompt), and `agentfootprint.context.injected` with
* `source: 'rag'` (once per chunk).
*
* @see ./indexDocuments.ts for the seeding helper
* @see ../../memory/define.ts for the underlying factory
* @see ../../memory/retrieval/ for the retrieval seam
* @see ../../memory/asRoleRefusal.ts for why `asRole` is refused, not honoured
*
* @example Basic usage — works with no identity argument anywhere
* ```ts
* import { Agent, defineRAG, indexDocuments } from 'agentfootprint';
* import { InMemoryStore, mockEmbedder } from 'agentfootprint/memory';
* import { mock } from 'agentfootprint/providers';
*
* const embedder = mockEmbedder();
* const store = new InMemoryStore();
*
* // Seed the corpus once at startup (defaults to the '_global' namespace).
* await indexDocuments(store, embedder, [
* { id: 'refunds.md#0', content: 'Refunds are processed within 3 business days.',
* metadata: { source: 'refunds.md' } },
* { id: 'pricing.md#0', content: 'Pro plan costs $20/month.',
* metadata: { source: 'pricing.md' } },
* ]);
*
* const docs = defineRAG({
* id: 'product-docs',
* description: 'Retrieve product documentation chunks',
* store,
* embedder,
* topK: 3,
* threshold: 0.6,
* });
*
* const agent = Agent.create({ provider: mock({ reply: 'ok' }), model: 'mock' })
* .rag(docs)
* .build();
*
* agent.on('agentfootprint.memory.retrieved', (e) => {
* // every candidate, admitted or not, with its score and its reason
* console.log(e.payload.candidates);
* });
*
* await agent.run({ message: 'How long do refunds take?' });
* ```
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.defineRAG = exports.DEFAULT_CORPUS_IDENTITY = void 0;
const capability_js_1 = require("../../memory/store/capability.js");
const define_types_js_1 = require("../../memory/define.types.js");
const define_js_1 = require("../../memory/define.js");
const asRoleRefusal_js_1 = require("../../memory/asRoleRefusal.js");
const redactRefusal_js_1 = require("../../memory/redactRefusal.js");
/**
* The namespace a corpus lives in unless told otherwise — the same one
* `indexDocuments` writes to by default. The two defaults are one value
* on purpose: index with no options, retrieve with no options, and the
* documents are found.
*/
exports.DEFAULT_CORPUS_IDENTITY = { conversationId: '_global' };
// LENS · system-text · request-ephemeral
// reads: the top-K passages ← the store's search under this corpus, rendered as citable <source> blocks
// top-K is an ATTENTION omission and stays visible: the block never implies the corpus holds nothing else.
// law: may omit, never deny; every clause anchored to the call it was composed on.
/**
* Build a RAG context-engineering definition. The returned
* `MemoryDefinition` is registered on the Agent via `.rag(definition)`
* (or, equivalently, `.memory(definition)` — same plumbing).
*
* @throws when `store` does not implement `search()`. RAG requires a
* store that can retrieve.
* @throws when `embedder` is missing and the store does not rank text
* server-side — somebody has to turn the question into a vector.
* @throws when `embedder`/`embedderId` is passed to a store that DOES rank
* text server-side — the option would be read by nothing.
* @throws when `retrieval` is combined with `topK` or `threshold`.
*/
function defineRAG(opts) {
if (!opts.id || opts.id.trim() === '') {
throw new Error('defineRAG: `id` is required and must be non-empty.');
}
// `asRole` no longer type-checks; this catches JavaScript callers and
// casts. Refused here rather than only inside `defineMemory` so the
// message names the factory the caller actually wrote.
(0, asRoleRefusal_js_1.refuseAsRole)(opts, `defineRAG('${opts.id}')`);
// A memory `redact` never existed here and is refused the same way, so a
// JavaScript caller who copied it from `defineMemory` is told, not ignored.
(0, redactRefusal_js_1.refuseMemoryRedact)(opts, `defineRAG('${opts.id}')`);
if (!opts.store) {
throw new Error(`defineRAG[${opts.id}]: \`store\` is required.`);
}
// Which query form this store takes decides whether an embedder belongs
// here at all. A contradiction between the store's two declarations is
// refused inside the resolver, naming the store.
const ranksBy = (0, capability_js_1.resolveRankingMode)(opts.store, `defineRAG[${opts.id}]`);
if (ranksBy === 'server-text') {
const ignored = [];
if (opts.embedder !== undefined)
ignored.push('`embedder`');
if (opts.embedderId !== undefined)
ignored.push('`embedderId`');
if (ignored.length > 0) {
throw new Error(`defineRAG[${opts.id}]: ${ignored.join(' and ')} cannot be used with a store that ` +
`declares \`ranksBy: 'server-text'\`.\n` +
` That store takes the question as WORDS and ranks it on the backend's side. An ` +
`embedder here would turn every question into a vector that is discarded on ` +
`arrival — paid for, per turn, and read by nothing.\n` +
` Fix: drop ${ignored.join('/')} — \`defineRAG({ id, store })\` is the whole ` +
`wiring for a server-text corpus.\n` +
` This refuses rather than ignoring the option, because an ignored embedder reads ` +
`from the wiring exactly like a working one: the same line, the same id in the ` +
`recording, and no way to tell that nothing was embedded.`);
}
}
else if (!opts.embedder) {
throw new Error(`defineRAG[${opts.id}]: \`embedder\` is required — somebody has to turn the question ` +
`into a vector before this store can rank it.\n` +
` Omit it only for a store that declares \`ranksBy: 'server-text'\` (it embeds and ` +
`ranks the question on its own side, so there is nothing to embed here).`);
}
if (!opts.store.search) {
throw new Error(`defineRAG[${opts.id}]: store must implement search(). ` +
'Pass a vector-capable adapter (InMemoryStore, pgvector, Pinecone, ...).');
}
if (opts.maxChars !== undefined && (!Number.isFinite(opts.maxChars) || opts.maxChars < 1)) {
throw new Error(`defineRAG[${opts.id}]: \`maxChars\` must be a positive number of characters — received ` +
`${String(opts.maxChars)}. A budget of zero admits no passage at all, which is what ` +
'leaving `maxChars` unset already means (no size bound); say what you want spent.');
}
if (opts.retrieval !== undefined) {
const shorthand = [];
if (opts.topK !== undefined)
shorthand.push('`topK`');
if (opts.threshold !== undefined)
shorthand.push('`threshold`');
if (shorthand.length > 0) {
throw new Error(`defineRAG[${opts.id}]: ${shorthand.join(' and ')} cannot be combined with \`retrieval\` ` +
'— they are two spellings of the same rule and could disagree. Keep ' +
`\`retrieval: ${opts.retrieval.name}({ ... })\` and drop ${shorthand.join('/')}.`);
}
}
return (0, define_js_1.defineMemory)({
id: opts.id,
...(opts.description !== undefined && { description: opts.description }),
type: define_types_js_1.MEMORY_TYPES.SEMANTIC,
strategy: opts.retrieval !== undefined
? {
kind: define_types_js_1.MEMORY_STRATEGIES.TOP_K,
// Spread, not assigned: a server-text corpus has no embedder, and
// an explicit `embedder: undefined` is a different fact from an
// absent one to every `!== undefined` check downstream.
...(opts.embedder !== undefined && { embedder: opts.embedder }),
retrieval: opts.retrieval,
...(opts.embedderId !== undefined && { embedderId: opts.embedderId }),
// Rides BOTH arms: the rule chooses the passages, this bounds
// how much text the chosen ones are allowed to be.
...(opts.maxChars !== undefined && { maxChars: opts.maxChars }),
}
: {
kind: define_types_js_1.MEMORY_STRATEGIES.TOP_K,
topK: opts.topK ?? 3,
threshold: opts.threshold ?? 0.7,
...(opts.embedder !== undefined && { embedder: opts.embedder }),
...(opts.embedderId !== undefined && { embedderId: opts.embedderId }),
...(opts.maxChars !== undefined && { maxChars: opts.maxChars }),
},
store: opts.store,
// A corpus is not a conversation log — see the header.
readOnly: true,
corpus: opts.corpus ?? exports.DEFAULT_CORPUS_IDENTITY,
flavor: 'rag',
});
}
exports.defineRAG = defineRAG;
//# sourceMappingURL=defineRAG.js.map