UNPKG

agentfootprint

Version:

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

173 lines • 9.78 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.formatDefault = void 0; const provenance_js_1 = require("../retrieval/provenance.js"); const runRedaction_js_1 = require("../../redaction/runRedaction.js"); const DEFAULT_HEADER = 'Relevant context from prior conversations. Use when it helps answer the current turn.'; const RAG_HEADER = 'Relevant passages retrieved from the document corpus. Cite the source id when you use one.'; /** * Escape any closing tag in user-controlled content so it can't close the * surrounding citation block prematurely. Without this guard, content * containing the literal close tag could trick the LLM into treating * subsequent text as "outside memory" — a small but real prompt-injection * vector. We insert a zero-width-joiner after the first character of the * tag name so the sequence survives tokenization but does NOT parse as a * closing tag. */ function escapeCloseTag(text, tag) { const head = tag[0] ?? ''; const rest = tag.slice(1); // U+200D ZERO WIDTH JOINER — invisible, survives tokenization, and // breaks the literal tag match. Same byte the memory formatter has // used since 7.x; keeping it identical keeps that output byte-stable. // Whitespace inside the tag (`</memory >`, `</ memory\n>`) closes it just as // well for a reader, so it is matched too; the replacement is the same bytes. return text.replace(new RegExp(`<\\/\\s*${tag}\\s*>`, 'gi'), `</${head}‍${rest}>`); } /** Escape a value for an XML-ish attribute. Corpus metadata is not trusted input. */ function attr(value) { return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); } function defaultRenderEntry(entry) { const msg = entry.value; const turnAttr = entry.source?.turn !== undefined ? ` turn="${entry.source.turn}"` : ''; const updatedAttr = entry.updatedAt !== undefined ? ` updated="${new Date(entry.updatedAt).toISOString()}"` : ''; const text = msg.content ?? ''; const role = msg.role ?? 'unknown'; return `<memory role="${role}"${turnAttr}${updatedAttr}>\n${escapeCloseTag(text, 'memory')}\n</memory>`; } /** * Corpus rendering — the chunk's own coordinates, and the score that put * it here. Every attribute is omitted when it is not known, because a * fabricated page number is worse than a missing one. */ function ragRenderEntry(entry, candidate) { const prov = (0, provenance_js_1.chunkProvenance)(entry.value); const docUri = candidate?.docUri ?? prov.docUri; const page = candidate?.page ?? prov.page; const heading = candidate?.heading ?? prov.heading; const parts = [`id="${attr(entry.id)}"`]; if (docUri !== undefined) parts.push(`doc="${attr(docUri)}"`); if (page !== undefined) parts.push(`page="${page}"`); if (heading !== undefined) parts.push(`heading="${attr(heading)}"`); if (candidate?.score !== undefined) parts.push(`score="${candidate.score.toFixed(2)}"`); // `chunkText` reads `.content` OR `.text` (8.19.0): a document from // `indexDocuments` and a chat message keep their text on `content`, a // `Chunk` from `indexCorpus` keeps it on `text`. Reading only the first // rendered this whole block around an empty body for every corpus the // `rag` door built. const text = (0, provenance_js_1.chunkText)(entry.value); return `<source ${parts.join(' ')}>\n${escapeCloseTag(text, 'source')}\n</source>`; } /** Emit through the scope's emit channel when there is one — 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 summarize(text, n = 80) { return text.length <= n ? text : text.slice(0, n - 1) + '…'; } // LENS · system-text · request-ephemeral // reads: selected ← pickByBudget; retrieved ← the retrieval fold, each chunk keeping its promptFragment // it writes scope.formatted: this file, not its neighbours, owns the message role and the wording. // law: may omit, never deny; every clause anchored to the call it was composed on. function formatDefault(config = {}) { const flavor = config.flavor ?? 'memory'; const header = config.header ?? (flavor === 'rag' ? RAG_HEADER : DEFAULT_HEADER); const footer = config.footer ?? ''; const renderEntry = config.renderEntry ?? (flavor === 'rag' ? ragRenderEntry : defaultRenderEntry); const emitWhenEmpty = config.emitWhenEmpty ?? false; return async (scope) => { const selected = scope.selected ?? []; if (selected.length === 0 && !emitWhenEmpty) { scope.formatted = []; return; } const evidence = scope.retrieved; const byId = new Map((evidence?.candidates ?? []).map((c) => [c.id, c])); const blocks = selected.map((entry) => renderEntry(entry, byId.get(entry.id))); const content = (header ? `${header}\n\n` : '') + blocks.join('\n\n') + (footer ? `\n\n${footer}` : ''); scope.formatted = [{ role: 'system', content }]; // ── Per-chunk prompt bytes ────────────────────────────────────── // The header rides the FIRST fragment and the footer the LAST, so // that `fragments.join('\n\n')` is byte-identical to `content` above. // That identity is what lets one retrieval become one InjectionRecord // per chunk without changing a single byte the model sees — and it is // pinned by test, because it is the whole blast radius of the change. // // `promptPosition` rides along because the record is stored in SCORE // order while the prompt is assembled in the PICKER's order: without // it, rejoining the fragments reproduces the right bytes in the wrong // sequence, which is a prompt the model never saw. if (evidence) { const fragments = blocks.map((block, i) => { const withHeader = i === 0 && header ? `${header}\n\n${block}` : block; return i === blocks.length - 1 && footer ? `${withHeader}\n\n${footer}` : withHeader; }); const placed = new Map(selected.map((entry, i) => [entry.id, { fragment: fragments[i] ?? '', position: i }])); scope.retrieved = { ...evidence, ...(evidence.candidates && { candidates: evidence.candidates.map((c) => { const slot = placed.get(c.id); return slot === undefined ? c : { ...c, promptFragment: slot.fragment, promptPosition: slot.position }; }), }), }; } // ── Per-chunk attachment telemetry ────────────────────────────── // `agentfootprint.memory.attached` has been declared since 2.x and was // never emitted by anything. It is emitted here, once per chunk that // actually reached the prompt, because that is the event a consumer // needs to answer "which passages did this turn read". The memory // bridge is attached on every Agent run, so no extra wiring exists. for (const entry of selected) { const candidate = byId.get(entry.id); emit(scope, 'agentfootprint.memory.attached', { memoryId: entry.id, contentSummary: summarize((0, provenance_js_1.chunkText)(entry.value)), ...(candidate?.score !== undefined && { score: candidate.score }), ...(candidate?.rank !== undefined && { rank: candidate.rank }), source: 'store', }); } // Context-engineering emit: memory formatted N entries into one // system-role message. // // LAW: this event must name the slot the content actually lands in. // It says `'system-prompt'` because that is where it goes, and it is // checkable end to end — `memoryRecallInjections` routes system-role // recall to `inject.systemPrompt`, `buildSystemPromptSlot` records it // as a `slot: 'system-prompt'` injection, and the request carries it // in `systemPrompt`. Two events describing one piece of content must // not disagree: `context.memory.injected` and `context.injected` name // the same slot for the same bytes. // // Until 7.20.0 this said `'messages'`, which was never true of this // stage — `scope.formatted` above is unconditionally role `'system'`. // A consumer branching on the old value was branching on a lie. emit(scope, 'agentfootprint.context.memory.injected', { slot: 'system-prompt', // Memory injects ONE system-role message containing every selected // entry as a citation block (see the headers + renderEntry). // The downstream delta is therefore +1 system fragment, regardless // of how many memory entries it carries. role: 'system', deltaCount: { system: 1 }, count: selected.length, // Tiers present in the injection — lets the UI show "working / long-term" // differentiation later without a schema change. tiers: Array.from(new Set(selected.map((e) => e.tier).filter(Boolean))), }); }; } exports.formatDefault = formatDefault; //# sourceMappingURL=formatDefault.js.map