agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
132 lines (131 loc) • 6.41 kB
TypeScript
/**
* llmEdgeWeigher — influence-scored `EdgeWeigher` for LLM-call slice edges
* (RFC-003 Part B, block D7).
*
* The gap this fills: footprintjs's causal slice treats every parent of an
* LLM call equally — a 12-parent hairball (system prompt, history, tool
* results, cache markers, counters …) gives a debugger 12 equally-plausible
* leads. D7 turns the hairball into a RANKED shortlist by weighing each
* parent edge with influence-core's composite (D6): the parent's WRITTEN
* content vs the LLM call's OUTPUT.
*
* Pattern: two-pass adapter over footprintjs's synchronous `EdgeWeigher`
* hook (A3). Embedding is async, the hook is sync — so the handle
* PRIMES first (walk an unweighted slice, embed every needed text
* in deduplicated batches, memoize composites), and `weigh` then
* answers synchronously from the primed map. `localizeContextBug`
* drives the two passes; standalone consumers call
* `prime(dag)` themselves and re-run `causalChain({ weigh })`.
* Role: `src/context-bisect/` leaf. The engine stays zero-dep — the
* weigher is consumer-injected exactly as A3 intended.
*
* ## Honest claim (§B2)
*
* Weights are CORRELATIONAL proxies: deterministic embedding geometry
* between texts the run already committed — never model internals, never
* causal attribution (ablation is the causal tier). A weight of 0.93 means
* "this parent's content is semantically close to what the LLM produced",
* not "the LLM used it".
*
* ## Determinism
*
* Same artifacts + same (deterministic) embedder → same weights and same
* ranking, run after run: texts are built from the commit log in commit
* order, embedded via influence-core's deduplicated batch, and ties in
* `rankedParents` keep first-seen (slice BFS) order. Wrap the embedder in
* `embeddingCache(...)` to also make repeat localizations embed nothing.
*
* ## What is weighed
*
* DATA edges whose CHILD is an LLM call (`llmCallIds`). Everything else —
* non-LLM children, control edges (a routing decision's influence is not a
* semantic-content question) — returns `undefined`, which footprintjs
* stamps as the default 1.0.
*
* ## Redaction posture
*
* Texts come exclusively from the COMMIT LOG, which footprintjs scrubs at
* commit time — a redacted key's committed value IS the placeholder, so
* the embedder never sees the raw secret.
*/
import type { CausalNode, CommitBundle, EdgeWeigher } from 'foottrace';
import { type Embedder, type InfluenceWeights } from '../influence-core/index.js';
export interface LlmEdgeWeigherOptions {
/**
* Injected embedder (D6 contract). Wrap in `embeddingCache(...)` so the
* weigher, the suspect refinement, and any margin/lint consumer share
* one embedding spend.
*/
readonly embedder: Embedder;
/**
* runtimeStageIds of LLM-call executions — the children whose parent
* edges get weighed. Provide explicitly, or extract from captured
* events with `llmCallIdsFromEvents` (the `stream.llm_start` ids).
*/
readonly llmCallIds: Iterable<string>;
/** The run's commit log — where edge texts are read from. */
readonly commitLog: readonly CommitBundle[];
/** Char cap per embedded text. Default 2000. */
readonly maxTextChars?: number;
/** Composite weights forwarded to influence-core. Default: paper priors. */
readonly weights?: InfluenceWeights;
/**
* Override the CHILD text (the LLM call's output). Default: the values
* the child committed, serialized `key=value` in trace order, capped.
*/
readonly childTextOf?: (runtimeStageId: string) => string | undefined;
/**
* Override the PARENT text for one edge. Default: the value the parent
* committed for the edge's key, serialized + capped.
*/
readonly parentTextOf?: (runtimeStageId: string, key: string) => string | undefined;
}
/** One ranked parent edge of an LLM call (descending weight). */
export interface RankedParentEdge {
readonly parentId: string;
readonly stageName: string;
readonly key: string;
/** Influence composite clamped to [0, 1] — a correlational proxy (§B2). */
readonly weight: number;
}
export interface LlmEdgeWeigherHandle {
/**
* Pass 1 — walk an (unweighted) causal DAG, embed every LLM-edge text in
* one deduplicated batch, and memoize composite weights. Idempotent;
* call again with a different DAG to extend the map.
*/
prime(root: CausalNode): Promise<void>;
/**
* Pass 2 — the synchronous footprintjs `EdgeWeigher`. Returns the primed
* weight for (LLM child, parent, data key); `undefined` (→ engine
* default 1.0) for control edges, non-LLM children, and unprimed pairs.
*/
readonly weigh: EdgeWeigher;
/**
* The D7 acceptance view: an LLM call's parents ranked by weight,
* descending; ties keep first-seen (slice BFS) order. Empty until
* `prime` ran over a DAG containing the call.
*/
rankedParents(llmCallId: string): readonly RankedParentEdge[];
/** Texts the embedder was given — exposed for security audits/tests. */
embeddedTexts(): readonly string[];
}
/**
* Default child text: everything the step committed, `key=value` in trace
* order. For an agent's LLM call this carries the assistant content +
* tool-call intents — the step's observable OUTPUT.
*
* WITH ONE EXCLUSION (9.88.0). The receipt an LLM call commits is a
* FINGERPRINT of the request, not something the step said: run-salted hex
* digests, which are by construction semantically empty. Left in, they are
* hundreds of characters of noise inside a `maxChars` budget that then has
* less room for the assistant's actual words — measured, that alone reordered
* the localizer's suspects and demoted a planted fact below a tool. A text
* corpus scored by an embedder must contain only text somebody wrote.
*/
export declare function stepOutputText(commitLog: readonly CommitBundle[], lastIdxOf: Map<string, number>, runtimeStageId: string, maxChars: number): string | undefined;
/**
* Build the D7 weigher. See module docs for the two-pass contract, the
* determinism guarantee, and the §B2 honest claim (weights = proxies).
*/
export declare function llmEdgeWeigher(options: LlmEdgeWeigherOptions): LlmEdgeWeigherHandle;