agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
162 lines (161 loc) • 9.44 kB
TypeScript
import type { CommitBundle, UntrackedSource, ValueBasis } from 'foottrace';
import type { EvidenceInput } from '../influence-core/index.js';
import { type ContextBugArtifacts, type HonestyFlag } from './types.js';
/** One source the loop's `call-llm` read, traced back to its live writer for THAT loop. */
export interface ContextSource {
/** The state key call-llm#k read (e.g. 'systemPromptInjections'). */
readonly key: string;
/** runtimeStageId of the live writer (findLastWriter); undefined when never committed before. */
readonly writerId: string | undefined;
/** The writer's commitLog ARRAY position — NOT the optional CommitBundle.idx. */
readonly writerArrayIdx: number | undefined;
/** Materialized live value (commitValueAt); undefined under the pre-run-initial blind spot. */
readonly value: unknown;
/**
* Why `writerId` / `value` are not exact, when they are not — the writer's and the value's
* footprintjs basis codes, writer's first (`findLastWriterWithBasis` then
* `commitValueAtWithBasis`; sentences in `HONESTY_CODES` on `foottrace`). E.g.
* `['nested-rows']` when the writer reached the key only through paths inside it (a subflow
* seed or merge-back). ABSENT when both answers are exact.
*/
readonly basis?: readonly ValueBasis[];
/** The bridge handed to scorers: { id, text, ancestorTexts }. */
readonly evidence: EvidenceInput;
}
/** One ReAct iteration — bounded by the loop HEAD, pointing at the call-llm inside it. */
export interface LoopFrame {
/** Anchor ordinal 0,1,2… DERIVED from the commit log (NOT TraversalContext.loopIteration). */
readonly loopIndex: number;
/** The call-llm#k runtimeStageId — the LLM-step pointer WITHIN the frame. */
readonly llmCallId: string | undefined;
/** call-llm#k's commitLog array index — the EXCLUSIVE beforeIdx for findLastWriter. */
readonly llmCallArrayIdx: number | undefined;
/** The loop-HEAD commit's array index — the body's lower bound. */
readonly headArrayIdx: number;
/** Every runtimeStageId in [head[k], head[k+1]) — the full multi-stage body of round k, in commit order. */
readonly bodyIds: readonly string[];
/** stepOutputText over the call-llm commit (assistant content + tool-call intents). */
readonly intermediateText: string | undefined;
/** One per key call-llm#k read. */
readonly contextSources: readonly ContextSource[];
/**
* WALK-ONLY (proposal 008): the proximate tool result for this loop — the most recent
* `lastToolResult` committed before this loop's call-llm, with the PRODUCING loop's tool-calls
* stage as its `writerId` (the cross-loop provenance edge L4's `walkToRoot` descends along). NOT
* in `contextSources` — L3's narrow never scores it. Absent on loop 0 / grouped frames.
*/
readonly proximateToolSource?: ProximateToolSource;
/**
* GROUPED chart only: the `sf-llm-call` mount runtimeStageId this frame was projected from.
* When set, ALL array indices on this frame (`headArrayIdx`, `llmCallArrayIdx`,
* `bodyIds`, each `contextSource.writerArrayIdx`) are relative to that subflow's OWN inner
* commit log (`subflowResults[subflowScope].treeContext.history`), NOT the run commit log.
* Absent for FLAT-chart frames (indices are run-commit-log relative).
*/
readonly subflowScope?: string;
/** Pass-through of the call-llm bundle's untrackedSources ('args'|'env'|'silent'). */
readonly incompleteSources?: ReadonlyArray<UntrackedSource>;
/** True when incompleteSources is non-empty — "this step read untracked; slice may be
* incomplete here". NOT a model-internalized claim (that is undetectable — see Trajectory). */
readonly untrackedReadsPresent: boolean;
}
/** The proximate tool result a loop's decision was conditioned on — L4's cross-loop hop edge (proposal 008). */
export interface ProximateToolSource {
/** The committed `{ toolName, result }` (redaction-scrubbed, via commitValueAt). */
readonly value: unknown;
/** The producing loop's tool-calls stage runtimeStageId — resolves to an EARLIER frame. */
readonly writerId: string | undefined;
/** Why the writer / value are not exact, when they are not — as {@link ContextSource.basis}. */
readonly basis?: readonly ValueBasis[];
/**
* The state key this value was materialized from (`'lastToolResult'`). Every
* OTHER source the walk can hop along carries its key on `ContextSource.key`;
* this one is not a contextSource, so the key is stamped here rather than
* hard-coded at the consumer (variable-recall joins on it). WALK-ONLY, like
* the rest of this record.
*/
readonly stateKey: string;
/** Honesty: the call-llm read `history`, NOT this key — an INFERRED proximate, not a direct read. */
readonly proximate: true;
}
/** The run input, re-injected as a synthetic node — a PROXY (args is untracked), never a recorded edge. */
export interface SyntheticQuestionNode {
readonly text: string;
readonly incompleteSources: readonly ['args'];
readonly injected: true;
}
export interface Trajectory {
readonly frames: readonly LoopFrame[];
/** Commits BEFORE the first head (seed, memory-read) — run setup, not a loop body. */
readonly prelude: readonly string[];
/** Only populated when the contrastive path is wired (proposal 005 L2 note). */
readonly question?: SyntheticQuestionNode;
/** Degrade-never-throw. STANDING caveat on EVERY trajectory: contextSources show only
* sources re-committed to tracked state; context the model retained internally (carried
* in its own reasoning, never re-committed) leaves no read→write edge and is NOT here. */
readonly honestyFlags: readonly HonestyFlag[];
/** Set only when maxFrames cut the run. */
readonly truncated?: {
readonly byFrames: boolean;
};
}
/** One frame's raw partition: the head's array index + the runtimeStageIds in its half-open range. */
export interface AnchorBucket {
readonly headArrayIdx: number;
readonly bodyIds: readonly string[];
}
/**
* Partition a commit log by a list of HEAD runtimeStageIds (taken as data — no agent
* knowledge). Each frame is the half-open range `[head[k], head[k+1])`; commits before
* the first head are the `prelude`. TOTAL: every commit lands in exactly one frame OR
* the prelude. Heads not found in the log are ignored; ordering follows the log, not the
* input list (an out-of-order or duplicate head list cannot reorder/duplicate commits).
*
* A head is anchored at the FIRST commit bearing its runtimeStageId — a single stage
* execution can flush MORE THAN ONE commit bundle under one runtimeStageId (parallel
* fork merges, multi-flush stages), and those repeats stay INSIDE the frame they open
* rather than each spawning a spurious one-commit frame.
*/
export declare function bucketByAnchors(commitLog: readonly CommitBundle[], headRuntimeStageIds: readonly string[]): {
frames: AnchorBucket[];
prelude: string[];
};
/**
* The flat-chart loop heads: the FIRST commit of each injection-engine ENTRY (one per
* ReAct iteration, since the loop is branch-sourced back to the injection engine). A head
* is a commit that is in the injection engine while the previous commit was not — so a
* multi-commit injection-engine body yields exactly one head per loop.
*
* Returns the runtimeStageIds to feed `bucketByAnchors`. Empty when the run never enters
* the injection engine (e.g. the grouped chart, where the loop lives in sf-llm-call —
* the caller degrades with an honesty flag).
*/
export declare function findLoopHeads(commitLog: readonly CommitBundle[]): string[];
export interface AssembleTrajectoryOptions {
/** Chars of each source value / intermediate text embedded. Default 2000. */
readonly maxTextChars?: number;
/** Keep only the first N frames (honesty-flagged via `truncated`). */
readonly maxFrames?: number;
}
/**
* Slice a recorded agent run into a {@link Trajectory} — one {@link LoopFrame} per ReAct
* iteration, each carrying its `call-llm` pointer, the call's output text, and the live
* {@link ContextSource}s that fed it (traced via `findLastWriter` + `commitValueAt` from the
* SAME commit log — zero new capture).
*
* Takes the SAME `ContextBugArtifacts` bag the localizer takes — adopter call is just
* `assembleTrajectory(artifacts)`.
*
* Handles BOTH chart shapes:
* - FLAT (`reactMode: 'dynamic'`, default): `call-llm` is a parent-level stage; frames are
* bucketed over the run commit log by the `sf-injection-engine` loop heads.
* - GROUPED (`reactMode: 'dynamic-grouped'`): the LLM turn runs in an `sf-llm-call` subflow;
* each loop is projected PER-SCOPE over its own inner commit log (retained per-iteration by
* footprintjs subflow-commit-visibility). Such frames carry `subflowScope` and their array
* indices are inner-log-relative.
*
* Standing caveat on every result: contextSources show only sources re-committed to tracked
* state; context the model retained internally (carried in its own reasoning, never
* re-committed) leaves no read→write edge and is NOT represented.
*/
export declare function assembleTrajectory(artifacts: ContextBugArtifacts, opts?: AssembleTrajectoryOptions): Trajectory;