UNPKG

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
"use strict"; /** * 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