UNPKG

agentfootprint

Version:

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

216 lines • 9.21 kB
"use strict"; /** * 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). */ Object.defineProperty(exports, "__esModule", { value: true }); exports.embeddingScorer = exports.keywordScorer = exports.rankEntries = void 0; const cosine_js_1 = require("../memory/embedding/cosine.js"); const softmax_js_1 = require("./softmax.js"); /** * 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. */ function rankEntries(scorerName, candidates, rawScores) { if (candidates.length === 0) return { scorer: scorerName, chosen: undefined, ranked: [] }; const safe = rawScores.map((s) => (Number.isFinite(s) ? s : Number.NEGATIVE_INFINITY)); const relevances = (0, softmax_js_1.softmax)(safe); const ranked = candidates.map((c, i) => ({ id: c.id, score: rawScores[i], // the scorer's RAW output (may be non-finite — honest) relevance: relevances[i], })); // argmax over the SANITIZED scores (declaration order breaks ties) — never over the // raw scores, so a NaN/Inf can't win. Order-preserving with `relevance`. let winner = 0; for (let i = 1; i < safe.length; i++) if (safe[i] > safe[winner]) winner = i; return { scorer: scorerName, chosen: ranked[winner].id, ranked }; } exports.rankEntries = rankEntries; /** * 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. */ function keywordScorer(options = {}) { const stop = new Set((options.stopWords ?? DEFAULT_STOP_WORDS).map((w) => w.toLowerCase())); function score(input, second) { if (Array.isArray(second)) { // Intent path — set-cosine of the message against intent + example tokens. const q = tokenize(input.message, stop); return second.map((c) => ({ id: c.id, score: setCosine(q, tokenize([c.intent, ...c.examples].join(' '), stop)), })); } const { userMessage, candidates } = input; const q = tokenize(userMessage, stop); const scores = candidates.map((c) => setCosine(q, tokenize(c.description, stop))); return rankEntries('keyword', candidates, scores); } return { name: 'keyword', floor: 0, score }; } exports.keywordScorer = keywordScorer; /** * 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. */ function embeddingScorer(embedder, options = {}) { async function embedAll(texts, signal) { // One embedBatch round-trip when the backend supports it (OpenAI/Voyage/…), // else N+1 CONCURRENT embed() calls — never the serial N+1 latency stack. const sig = signal ? { signal } : {}; return embedder.embedBatch ? [...(await embedder.embedBatch({ texts: [...texts], ...sig }))] : Promise.all(texts.map((text) => embedder.embed({ text, ...sig }))); } async function score(input, second, third) { if (Array.isArray(second)) { if (second.length === 0) return []; const texts = [ input.message, ...second.map((c) => [c.intent, ...c.examples].join('\n')), ]; const [qVec, ...cVecs] = await embedAll(texts, third); return second.map((c, i) => ({ id: c.id, score: (0, cosine_js_1.cosineSimilarity)(qVec, cVecs[i]) })); } const { userMessage, candidates } = input; if (candidates.length === 0) return { scorer: 'embedding', chosen: undefined, ranked: [] }; const texts = [userMessage, ...candidates.map((c) => c.description)]; const [qVec, ...dVecs] = await embedAll(texts, second); const scores = dVecs.map((dVec) => (0, cosine_js_1.cosineSimilarity)(qVec, dVec)); return rankEntries('embedding', candidates, scores); } return { name: 'embedding', ...(options.floor !== undefined && { floor: options.floor }), score, }; } exports.embeddingScorer = embeddingScorer; /** Small, conservative stop list — fillers a user would never route on. */ const DEFAULT_STOP_WORDS = [ 'a', 'an', 'the', 'and', 'or', 'but', 'if', 'to', 'of', 'for', 'in', 'on', 'at', 'by', 'with', 'is', 'are', 'was', 'were', 'be', 'been', 'it', 'this', 'that', 'these', 'those', 'i', 'you', 'we', 'my', 'our', 'me', 'please', 'can', 'could', 'would', 'should', 'do', 'does', 'how', 'what', 'need', 'want', ]; /** * Lowercase → split on non-alphanumerics → drop 1-char tokens + stop words → * light plural fold → set. The plural fold (drop a single trailing `s` on tokens * length ≥ 4) lets `refund` match `refunds` and `payment` match `payments` — the * common miss that makes a naive keyword router feel broken. Conservative: short * words (`is`, `as`, `bus`) are untouched. */ function tokenize(text, stop) { const out = new Set(); for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) { if (raw.length < 2 || stop.has(raw)) continue; // Fold a single trailing 's' (refunds→refund) but NOT '-ss' (address stays // address) — that asymmetry would defeat the matching it's for. ASCII only: // non-Latin/accented text is split away by the regex, so the keyword router is // effectively English/ASCII (use embeddingScorer for other languages). const fold = raw.length >= 4 && raw.endsWith('s') && !raw.endsWith('ss'); out.add(fold ? raw.slice(0, -1) : raw); } return out; } /** |A ∩ B| / sqrt(|A| · |B|) — set cosine, 0..1. Empty either side → 0. */ function setCosine(a, b) { if (a.size === 0 || b.size === 0) return 0; const [small, large] = a.size <= b.size ? [a, b] : [b, a]; let shared = 0; for (const t of small) if (large.has(t)) shared += 1; return shared / Math.sqrt(a.size * b.size); } //# sourceMappingURL=entryScorer.js.map