agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
112 lines (111 loc) • 5.85 kB
TypeScript
/**
* EntryScorer — the pluggable STRATEGY for ranking a skill graph's entry
* candidates by relevance to the user's message.
*
* `skillGraph().entryBy(scorer)` selects a strategy; the agent's PickEntry stage
* runs it ONCE per turn (off the hot loop) and starts the cursor at the winner.
* Two built-ins ship:
* • `keywordScorer()` — no dependency, no model call: word overlap between
* the message and each skill's `description`. The zero-config way to route.
* • `embeddingScorer(e)` — semantic: cosine similarity of embeddings (needs an
* Embedder). `.entryByRelevance(embedder)` is sugar for this.
* Bring your own by implementing `EntryScorer`.
*
* The scorer OWNS both the surfaced `relevance` % AND the `chosen` winner, so the
* explanation and the decision can never disagree (softmax is order-preserving, so
* argmax-score == argmax-relevance).
*/
import type { Embedder } from '../memory/embedding/types.js';
import type { IntentScorer } from './intentScorer.js';
/** One entry candidate's relevance to the user's message. */
export interface EntryScore {
/** The entry skill id. */
readonly id: string;
/** Raw, strategy-specific score — cosine for `embedding`, word-overlap for
* `keyword`. Higher = more relevant. Not normalized across strategies. */
readonly score: number;
/** Softmax share across candidates, 0..1 — the surfaced "Why this skill?" %. */
readonly relevance: number;
}
/** Result of scoring the entries — the picked entry, the full ranking, and which
* scorer produced it. */
export interface EntryScoring {
/** The scorer's `name` (e.g. `'keyword'`, `'embedding'`) — surfaced so a lens /
* the "Why this skill?" panel can say HOW the entry was chosen. */
readonly scorer: string;
/** Winning entry id (highest `score`), or undefined if no candidate. */
readonly chosen: string | undefined;
/** Every scored candidate, in declaration order. */
readonly ranked: readonly EntryScore[];
}
/** A candidate the scorer ranks — its id + the text it's matched on (the skill's
* `description`). */
export interface EntryCandidate {
readonly id: string;
readonly description: string;
}
/** What a scorer receives: the user's message + the candidates to rank. */
export interface EntryScorerInput {
readonly userMessage: string;
readonly candidates: readonly EntryCandidate[];
}
/**
* A strategy for ranking entry candidates. Pure given its inputs; may be async (an
* embedder makes network calls). Runs ONCE per turn off the hot loop, so cost here
* never touches the ReAct inner loop.
*/
export interface EntryScorer {
/** Short, stable name — shown in the lens / "Why this skill?" panel + any
* strategy picker. */
readonly name: string;
score(input: EntryScorerInput, signal?: AbortSignal): Promise<EntryScoring> | EntryScoring;
}
/**
* Shared finisher: raw scores → `EntryScoring`. Softmax turns the (sanitized) raw
* scores into a `relevance` share (sums to 1); the winner is the argmax with
* declaration order breaking ties. Because softmax is order-preserving over the
* sanitized scores, the WINNER is always the argmax `relevance` — the surfaced %
* and the pick can never disagree.
*
* `EntryScorer` is a public, consumer-implementable interface, so a third-party
* scorer might return `NaN` / `±Infinity`. We sanitize those to `-Infinity` for BOTH
* the softmax input AND the winner pick, so a non-finite score can never silently win
* (`NaN > x` is always false — a leading `NaN` would otherwise seed-and-keep a naive
* reduce) and the softmax stays well-defined. The raw (possibly non-finite) score is
* still surfaced on `EntryScore.score` for honest debugging.
*/
export declare function rankEntries(scorerName: string, candidates: readonly EntryCandidate[], rawScores: readonly number[]): EntryScoring;
/**
* keywordScorer — rank by word overlap between the message and each description.
* No embedder, no model call, deterministic. Scores the set-cosine of lowercased
* word tokens (length-normalized so a long description can't win on sheer size),
* minus a small stop-word list. The zero-config router: good enough when skill
* descriptions use the words a user would.
*
* ALSO an {@link IntentScorer} (SG-C): handed a candidates array as the second
* argument it scores the message's set-cosine against each intent's sentence +
* examples, and declares `floor: 0` — zero token overlap IS honestly "did not
* match at all", so this scorer can say *unmatched*. One factory, one name,
* two arities; existing `EntryScorer` callers are byte-unaffected.
*/
export declare function keywordScorer(options?: {
readonly stopWords?: readonly string[];
}): EntryScorer & IntentScorer;
/**
* embeddingScorer — rank by SEMANTIC similarity. Embeds the message + each
* description and cosine-scores them. Needs an `Embedder` (a model call per text);
* runs once per turn off the hot loop. `.entryByRelevance(embedder)` is sugar for
* `.entryBy(embeddingScorer(embedder))`.
*
* ALSO an {@link IntentScorer} (SG-C): handed a candidates array it embeds the
* message + one text per candidate (the intent sentence with its examples) and
* cosine-scores them. **No floor unless you declare one** (`{ floor }`): an
* absolute embedding threshold is a lie by default — measured in the field,
* real intent clusters separate by single-digit percentages of cosine, so a
* built-in cutoff would flip between "unmatched" and "decisive" on noise.
* Without a floor this scorer can say *stay*, *move* or *near-tie*, never
* *unmatched* — the verdict table documents that honestly.
*/
export declare function embeddingScorer(embedder: Embedder, options?: {
readonly floor?: number;
}): EntryScorer & IntentScorer;