agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
235 lines (234 loc) • 11.1 kB
TypeScript
/**
* retrieval/types — the record a retrieval leaves behind, and the seam
* that decides which candidates reach the prompt.
*
* Pattern: Strategy (the seam) + Value objects (the record).
* Role: memory/ layer. The law this folder exists to keep is stated
* once, here, because every stage downstream implements a piece
* of it: **a retrieval must be able to say what it considered,
* not only what it used.**
* Emits: N/A (types only). `loadRelevant` emits
* `agentfootprint.memory.retrieved` from the record below;
* `formatDefault` emits `agentfootprint.memory.attached` per
* admitted chunk.
*
* Before 8.8.0 a retrieval computed a cosine score for every candidate
* and then threw all of them away one line later (`results.map(r =>
* r.entry)`). The prompt carried the passages; nothing carried the
* reason. "Why did the agent read this passage" had no answer in the
* recording, and "why did it NOT read that one" had no answer anywhere —
* a below-threshold candidate was filtered inside the store and never
* came back. {@link RetrievalEvidence} is what that answer is made of.
*/
import type { MemoryEntry } from '../entry/index.js';
/**
* Why a candidate did not reach the prompt. Every rejected candidate
* names one of these — a rejection without a reason is the silence this
* whole record exists to remove.
*/
export type RetrievalRejectReason =
/** Scored below the retriever's `threshold`. The quality floor refused it. */
'below-threshold'
/** Cleared the threshold, but the context-token budget had no room left. */
| 'over-budget'
/** Cleared the threshold and the budget, but the picker's `maxEntries` cap was full. */
| 'over-max-entries'
/**
* Cleared the threshold and the count rule, but the retriever's `maxChars`
* budget was already spent by better-ranked passages (8.19.0).
*
* A COUNT bound is not a SIZE bound: `topK: 10` over ordinary headings can
* be eleven thousand characters, which over-runs the system-prompt slot's
* 4000-char default with nothing but defaults. `maxChars` is the size
* bound, and this reason is what makes spending it visible — the tail is
* dropped, and the record says which chunks and why.
*/
| 'over-char-budget';
/**
* One candidate the retrieval considered — admitted or not.
*
* `rank` is the candidate's position by SCORE (1-based, descending),
* which is not necessarily the order it appears in the prompt: the
* budget picker admits by recency (see {@link RetrievalEvidence.selectionOrder}).
* Recording both is the point — a reader can see that the best-scoring
* chunk was admitted third, and know that was the picker's doing.
*/
export interface RetrievedCandidate {
/** The store entry's id. For an indexed corpus this is the chunk id. */
readonly id: string;
/** Similarity as the store reported it. Cosine ([-1, 1]) for every shipped store. */
readonly score: number;
/** 1-based position by score, descending, across the whole candidate pool. */
readonly rank: number;
/** Did this candidate's text reach the prompt? */
readonly admitted: boolean;
/** Present exactly when `admitted` is false. */
readonly reason?: RetrievalRejectReason;
/** Source document, when the indexed value carried one in its metadata. */
readonly docUri?: string;
/** Page number, when the loader knew one (PDFs). */
readonly page?: number;
/** Section heading, when the splitter knew one. */
readonly heading?: string;
/**
* The exact prompt bytes this chunk contributed, set by the formatter
* for admitted candidates. Joining every admitted candidate's fragment
* **in {@link promptPosition} order** with `\n\n` reproduces the
* injected message exactly — which is what lets one retrieval become
* one `InjectionRecord` PER CHUNK without changing a single byte the
* model sees.
*/
readonly promptFragment?: string;
/**
* Where this chunk sat in the injected message, 0-based.
*
* NOT the same as {@link rank}, and the difference is the honest part:
* `rank` is how well the chunk scored, `promptPosition` is where the
* budget picker put it. Under the default recency ordering the
* best-scoring chunk can land last — which is exactly the kind of thing
* a lost-in-the-middle investigation needs to be able to see, and which
* a record that only kept one of the two orders could not show.
*/
readonly promptPosition?: number;
}
/**
* Everything one retrieval knows about itself. Written to the memory
* subflow's scope by `loadRelevant`, refined by `pickByBudget` and
* `formatDefault`, and lifted to the PARENT scope by the read mount so
* it lands in the root commit log where a slice can reach it.
*/
export interface RetrievalEvidence {
/** The retriever's id (`defineRAG({ id })`). Stamped by the read mount. */
readonly memoryId?: string;
/**
* Which RULE ruled — {@link RetrievalStrategy.name}, e.g. `'top-k'`.
*
* The seam promises this on its own `name` field ("appears in the
* recording"), and until 9.x the recording did not carry it: a reader
* could see `k`, `threshold` and a verdict per candidate, and could not
* tell whether a shipped `topK` or a consumer's own re-ranker produced
* them. Two strategies with the same `k` leave records that are
* otherwise identical, so the name is the only thing that distinguishes
* them — and it is the first thing you need when the admitted set looks
* wrong.
*
* Always present: a retrieval always ran under exactly one strategy, and
* the shorthand (`k` / `minScore`) is `topK` spelled differently, not the
* absence of a rule.
*/
readonly strategy: string;
/**
* A stable hash of the query text — NOT the text. The query is already
* in the recording once (as `userMessage`); copying it into a second
* key would widen the exposure surface for no new information, and any
* redaction policy the host configured for the first copy would not
* know about the second.
*/
readonly queryHash: string;
/** How many chunks the retriever was willing to admit. */
readonly k: number;
/** The quality floor. Absent when the retriever set none. */
readonly threshold?: number;
/**
* The character budget the admitted passages were spent against (8.19.0).
* Absent when the retriever set none — which is the default, and means
* `k` was the only bound on how much text reached the prompt.
*/
readonly maxChars?: number;
/**
* How many characters of PASSAGE the admitted set spends. Present exactly
* when {@link maxChars} is, and re-stated by the budget picker so it can
* never disagree with {@link admittedCount}.
*
* Passage characters, not prompt bytes: the `<source …>` wrapper and the
* block header are added later by the formatter and are not counted here.
* The exact bytes are on each candidate's `promptFragment` once the
* formatter has run.
*/
readonly charsUsed?: number;
/** The embedder id the query was produced with, when the caller declared one. */
readonly embedderId?: string;
/** Length of the query vector. Mixing two lengths in one store is a config bug. */
readonly dimensions?: number;
/** How the budget picker ordered the admitted set. See the note on `rank`. */
readonly selectionOrder: 'recency' | 'relevance';
/** How many candidates came back from the store. */
readonly consideredCount: number;
/** How many reached the prompt. */
readonly admittedCount: number;
/** `consideredCount - admittedCount`. */
readonly rejectedCount: number;
/**
* The candidates themselves, best-scoring first.
*
* `undefined` means this store could not tell us — see
* {@link candidatesOmittedReason}. It never means "there were none";
* that case is `[]` with `consideredCount: 0`.
*/
readonly candidates?: readonly RetrievedCandidate[];
/**
* Whether {@link candidates} is the complete set of candidates that
* existed, or only as far as the pool we asked for reached.
*
* `false` does NOT weaken the admitted set — see the proof in
* `loadRelevant`. It only means the REJECTED list is a sample: there
* may be further below-threshold entries we never saw.
*/
readonly candidatesComplete: boolean;
/** Present exactly when `candidates` is undefined. */
readonly candidatesOmittedReason?: string;
/**
* The store returned nothing at all for this namespace. Distinct from
* "everything scored below threshold" (`consideredCount > 0`), and the
* distinction is the whole diagnosis: an empty namespace almost always
* means the corpus was indexed somewhere else.
*/
readonly corpusEmpty: boolean;
/** The namespace that was searched, as a plain string, for the diagnosis above. */
readonly namespace?: string;
}
/**
* The retrieval seam: given the candidates a store returned, decide
* which of them the prompt may have — and say why about each one.
*
* A strategy NEVER talks to the store and never embeds anything. It is
* handed a scored, score-descending pool and returns a verdict per
* candidate. That narrowness is what makes it composable: a re-ranker or
* a diversity selector is the same shape with a different body.
*
* Shipped: {@link topK}. Deliberately NOT shipped in 8.8.0, and named
* here so the destination is on record rather than implied — a
* cross-encoder `rerank(...)` and a maximal-marginal-relevance `mmr(...)`
* are additional adapters behind this same interface. Neither needs an
* engine change, a new stage, or a new event; both were left out because
* a re-ranker without a shipped re-ranking model is a config with nothing
* to configure.
*/
export interface RetrievalStrategy {
/** Stable name — appears in the recording and in refusal messages. */
readonly name: string;
/** How many candidates this strategy is willing to admit. */
readonly k: number;
/** The quality floor, when the strategy has one. */
readonly threshold?: number;
/**
* How many EXTRA candidates to pull past `k` purely so that rejected
* ones can be shown. Never affects which candidates are admitted.
*/
readonly rejectWindow: number;
/**
* Rule on a score-descending pool. Return one verdict per input, in
* the same order. Implementations must not reorder.
*/
select(pool: readonly ScoredCandidate[]): readonly RetrievalVerdict[];
}
/** One store result, as a strategy sees it. */
export interface ScoredCandidate {
readonly entry: MemoryEntry<unknown>;
readonly score: number;
}
/** A strategy's ruling on one candidate. */
export interface RetrievalVerdict {
readonly admitted: boolean;
readonly reason?: RetrievalRejectReason;
}