agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
206 lines (205 loc) • 9.88 kB
TypeScript
/**
* innerRunRecords — the record a tool keeps of its OWN run.
*
* Pattern: bounded LRU store + a structural join key (a registry symbol).
* Role: the one place the outer trace and an inner trace are allowed to
* know about each other. Nothing here executes, renders, or
* formats — it holds records and hands them back by id.
*
* THE GAP
* ───────
* `inspect_tool_call` resolves a tool call end to end and then stops at a
* wall: *"⚠ boundary: what happened INSIDE the tool is not traced."* That
* marker is honest for a tool that calls someone else's system. It is
* needlessly honest for a tool that IS a footprintjs flowchart — that tool
* recorded every stage it ran, and then threw the recording away, because
* nobody was holding it.
*
* So: hold it. `flowchartAsTool({ keepRecord: true })` keeps each
* invocation's inner record here, keyed by the `toolCallId` the outer run
* already uses to name that call. One id, two levels — which is what makes
* the descent a lookup rather than a correlation puzzle.
*
* WHY BOUNDED, AND WHY LRU
* ────────────────────────
* A retained snapshot is retained memory. An agent that runs a chart tool
* on every turn for a week would pin every one of those runs, and the
* failure would look like a leak rather than like a feature. So the store
* has a cap (default {@link DEFAULT_INNER_RUN_LIMIT}), drops the
* least-recently-USED record when it overflows, and COUNTS the drops —
* because "we kept the last 20 of 340" is an answer a debugging session can
* act on, and a silently missing row is not.
*
* Reading refreshes recency on purpose: a record under active investigation
* (three drills into one inner run) must not be the next one evicted by a
* turn happening beside it.
*
* THE JOIN KEY
* ────────────
* The store rides the `Tool` object under a registry symbol rather than a
* named property: a `Tool` is a consumer-authored shape, and a plain name
* like `innerRuns` would be a name a consumer's own tool could collide
* with. `Symbol.for` (registry, not unique-per-module) because this package
* ships CJS *and* ESM — two module instances of this file must agree on the
* key or a tool built in one half would be invisible to the other.
*/
import type { ControlDepLookup } from 'foottrace';
/**
* How many invocations one tool keeps by default.
*
* Twenty is a debugging window, not an archive: enough that "the call three
* turns ago" is still there, small enough that a long-lived server holding
* one store per chart tool has a ceiling it can multiply out. Raise it with
* `keepRecordLimit` when the sessions are long and the charts are small.
*/
export declare const DEFAULT_INNER_RUN_LIMIT = 20;
/** How the inner run finished, from the wrapping tool's point of view. */
export type InnerRunOutcome = 'ok' | 'error' | 'paused';
/**
* One retained inner run — the evidence of a single tool invocation.
*
* `recording` is deliberately the `{ snapshot, structure }` shape
* `recordRun(...).toRecording()` produces, so the same `openRecording`
* adapter opens it. It is held BY REFERENCE (no clone): the inner executor
* is discarded after the call, so nothing else can mutate it, and cloning a
* snapshot per invocation would be the retention cost twice over.
*/
export interface InnerRunRecord {
/** The outer run's id for this call — the one `inspect_tool_call` takes. */
readonly toolCallId: string;
/** The wrapping tool's name, for messages that name what was descended into. */
readonly toolName: string;
/** How the invocation ended. A failed inner run is still a complete record. */
readonly outcome: InnerRunOutcome;
/** Committed steps in the inner run — the size hint the descent line prints. */
readonly steps: number;
/**
* The OUTER run that made the call (`ctx.runId`) — absent when the tool ran
* outside an agent run, or for a record a third-party producer filed. The
* store keys a record by run AND call id, so two runs that reuse a call id
* (another session's model, a replayed script) never displace each other;
* and a self-explaining agent serves only the records of the asking
* conversation's own runs ({@link innerRunsOfConversation}).
*/
readonly runId?: string;
/**
* `{ snapshot, structure }` — absent only when capture itself failed, in
* which case {@link problem} says why.
*/
readonly recording?: {
readonly snapshot: unknown;
readonly structure?: unknown;
};
/**
* The inner run's control-dependence lookup.
*
* A serialized recording can never carry one (a function does not
* serialize — see `openRecording`). This record is LIVE in process, so it
* can: the wrapping tool attaches a fresh `controlDepRecorder()` per
* invocation, and inner slices therefore show the `[control: rule]` edges
* that a reopened JSON recording has to say ⚠ about.
*/
readonly controlDeps?: ControlDepLookup;
/** The inner run's narrative lines, when the chart had a narrative attached. */
readonly narrative?: readonly string[];
/**
* Why there is no `recording`.
*
* A record that could not be captured is filed anyway, carrying the
* reason. The alternative — no row at all — is indistinguishable from a
* call that was never made, which is the one thing a trace must never be
* ambiguous about.
*/
readonly problem?: string;
}
/** A retained record, named without opening it. */
export interface InnerRunSummary {
readonly toolCallId: string;
readonly toolName: string;
readonly outcome: InnerRunOutcome;
readonly steps: number;
/** See {@link InnerRunRecord.runId}. */
readonly runId?: string;
}
/** Read side — what the trace tools are given. */
export interface InnerRunLookup {
/** The record for one tool call, or undefined (never kept, or evicted). */
get(toolCallId: string): InnerRunRecord | undefined;
/**
* Every record still held, least-recently-used first. Used for the
* unknown-id correction, so the model is told which calls it CAN descend
* into rather than only which one it cannot.
*/
list(): readonly InnerRunSummary[];
/** Records dropped to stay under the cap. Non-zero means "ask sooner". */
readonly dropped: number;
/** The cap in force (the smallest, when several stores are merged). */
readonly limit: number;
/**
* The most recent record for `toolCallId` that `accept` admits — optional,
* so a lookup built before it existed still type-checks; a view falls back
* to {@link get} and checks the one record it returns.
*/
getWhere?(toolCallId: string, accept: (record: InnerRunRecord) => boolean): InnerRunRecord | undefined;
/** Drops of records whose `runId` `accept` admits (optional; see {@link getWhere}). */
droppedWhere?(accept: (runId: string | undefined) => boolean): number;
}
/** Write side — what the wrapping tool holds. */
export interface InnerRunStore extends InnerRunLookup {
/** File one invocation's record, evicting the least-recently-used if full. */
keep(record: InnerRunRecord): void;
}
/**
* The key an inner-record store rides on a `Tool`.
*
* Registry symbol, not a unique one: this package ships CJS and ESM, and a
* tool built through one entry point must be readable through the other.
*/
export declare const INNER_RUN_RECORDS: unique symbol;
/** A `Tool` (or anything) that carries retained inner runs. */
export interface KeepsInnerRuns {
readonly [INNER_RUN_RECORDS]: InnerRunLookup;
}
/**
* Read the inner-run store off a candidate, or undefined when it carries
* none. Structural and total — a plain tool, a string, `null` all answer
* "no records" rather than throwing.
*/
export declare function innerRunsOf(candidate: unknown): InnerRunLookup | undefined;
/**
* A bounded LRU store of inner runs.
*
* @param limit maximum retained records (clamped to at least 1 — a store
* that keeps nothing is `keepRecord: false`, and saying so with
* a zero would be config that lies).
*/
export declare function innerRunStore(limit?: number): InnerRunStore;
/**
* One lookup over several stores — an agent may mount more than one chart
* tool, and the model asking "descend into call c3" should not have to know
* which tool produced c3.
*
* Returns undefined for an empty list so callers can spread the field
* conditionally rather than passing an empty lookup that answers "nothing
* was kept" when the truth is "nothing keeps anything".
*/
export declare function mergeInnerRuns(lookups: readonly InnerRunLookup[]): InnerRunLookup | undefined;
/**
* The records ONE conversation's runs filed — the view a self-explaining agent
* serves (`trace-toolpack/selfExplain.ts · SelfExplainBinding`), so a why-question asked in
* one conversation can neither descend into, list, nor count a call another
* conversation's run made.
*
* `belongs(runId)` answers for a record's outer run (`InnerRunRecord.runId`);
* a record that names no run is asked with `undefined`. A record the view does
* not admit answers exactly like a record never kept — the same not-found, so
* the view says nothing about whether anyone else called the tool — and its
* drops are not in `dropped`. `limit` is the store's.
*
* @example
* ```ts
* const mine = innerRunsOfConversation(lookup, (runId) => myRuns.has(runId ?? ''));
* mine.get('call-from-another-run'); // undefined — not found, not forbidden
* ```
*/
export declare function innerRunsOfConversation(lookup: InnerRunLookup, belongs: (runId: string | undefined) => boolean): InnerRunLookup;