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
JavaScript
;
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, '&').replace(/"/g, '"').replace(/</g, '<');
}
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