agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
157 lines (156 loc) • 8.8 kB
TypeScript
/**
* window/ledgerFactPins — the results the model declared FACTS, and why the
* window keeps them.
*
* Pattern: One pure function over the window (no scope, no I/O, no clock),
* plus the two helpers that rank a standing and give a TURN one.
* Role: core/ layer. Feeds the refusal engine, which is what actually
* keeps these turns in the window (see turns.ts, `'ledger-fact'`).
* Twin of lastToolResult.ts, and kept apart from it for the same
* reason that file is kept apart from currentRequest.ts: "what has
* the agent just seen" and "what does the agent say it stands on"
* are different questions, answered by different evidence. That pin
* is CONTENT-BLIND — it keeps each tool's LATEST result whatever it
* says. This one is content-aware by the MODEL'S claim only: it
* keeps the turns whose results the model itself filed as `fact` in
* `AgentState.findingsLedger`, and reads nothing else.
* Emits: N/A.
*
* ## Why this exists
*
* Measured, on `bench/findings-context.mjs`. Under `slidingWindow` (keep 6,
* twenty calls, six planted facts) the ledger PIECE carried all six facts to
* the answer turn, but the wire carried two of them verbatim — the rest had
* left oldest-first with the noise around them, while noise turns younger
* than they were stayed. The window knew nothing about standing: a result
* the model had declared a fact and a result it had declared noise were the
* same bytes to the refusal engine, so recency decided, every time.
*
* The ledger already says which is which, by the model's own hand. This pin
* reads that: **a turn the model declared a fact stays, up to a ceiling,
* until the model re-files it or the person asks something new.** 'Noise
* first' then follows without a second mechanism — noise, ruled-out, open
* and undeclared turns are unpinned and leave oldest-first exactly as today,
* and since step 3b collapsed a judged noise turn to a ticket on the wire, a
* noise turn that outlives a fact costs bytes the wire no longer pays.
*
* ## What is pinned, exactly, and why it cannot grow
*
* One candidate per TURN whose standing is `fact`, by the LAST row the
* ledger holds for each result (the fold takes the last, so a fact the model
* re-files as noise is released the moment it says so). A Turn is the
* removal unit — an assistant's call and its results leave together — so
* the standing of a turn is its most valuable member's ({@link
* turnStandingOf}): a parallel batch that answered one fact beside two noise
* results is ONE turn, one slot, and the noise stays with it. Three further
* bounds compose on top, the same three as the sibling pin:
*
* • **deduped by turn** — one pin per turn however many facts it holds;
* • **capped** — `keepLedgerFacts` is the ceiling, applied in
* `planRemoval` where the keep window is known, so a fact turn already
* inside `keepRecentTurns` costs nothing at all;
* • **anchored** — nothing at or before the CURRENT REQUEST is pinnable,
* so a new user turn releases the whole previous loop for free.
*
* And a fourth the ceiling alone cannot give: a hold that has provably been
* blocking progress stands down, on the record (`stages/window.ts ·
* pinIsBlocking`). A fact hold NEVER exists without its ceiling and its
* stand-down — a window full of declared facts degrades to today's recency
* behaviour after the stand-down, and the record says why.
*
* ## What it deliberately gets wrong
*
* The pin trusts the model. A result the model called a fact and never
* revisits is held to the ceiling whether or not it was one; a result the
* model never judged is `undeclared`, ranks above noise so a batch with one
* unjudged member is never filed as noise, and is NOT held. The library does
* not read the result's text to second-guess either — a guess about content
* is what this library refuses to make everywhere else, and the model's
* standing is the one content-aware signal that is a claim on the record
* rather than an inference. A heuristic that PINS bills its error every
* iteration, which is why the cap is small, the stand-down exists, and the
* record names what it kept.
*/
import type { LLMMessage } from '../../../provider-contract/types.js';
import type { Standing } from '../findings/types.js';
import type { Turn } from './turns.js';
/** One turn the pin holds, and what holding it costs. */
export interface LedgerFactPin {
/**
* Every tool result the turn holds, in wire order — not only the facts.
* The turn is what the pin actually holds: a noise result answered in the
* same batch as a fact stays with it, and each result's own standing is on
* the ledger by this id. A message the batch settlement wrote (9.113.0) is
* no result and is never listed (`findings/offer.ts` · `isResultMessage`).
*/
readonly toolCallIds: readonly string[];
/**
* The tool of the newest nameable result in the turn — the name the record
* files under `WindowObservations.pinned`. A turn is usually one tool; a
* batch is named for its newest member, the same way the sibling pin names
* a turn for the latest result it holds.
*/
readonly toolName: string;
/** Index of the turn in this iteration's segmentation. */
readonly turnIndex: number;
/** Index of the turn's first message in the window. */
readonly messageIndex: number;
/**
* Content characters of the WHOLE turn — the assistant's call and its
* results leave together, so the turn is what the pin actually holds.
*/
readonly chars: number;
}
/**
* How much a standing is worth to the window, as a number ONLY so two can be
* compared: `fact > open > undeclared > ruled-out > noise`. Never recorded.
*
* `undefined` is UNDECLARED — the model said nothing about the result — and
* it ranks above `ruled-out` and `noise` on purpose: an absent standing is
* never defaulted to a verdict, so a batch with one unjudged member is not
* a noise batch. It ranks below `open` because `open` is a declaration.
*/
export declare function rankStanding(standing: Standing | undefined): number;
/**
* The standing of a TURN: its most valuable tool result's, by
* {@link rankStanding}. A Turn is the removal unit, so this is the one
* question the refusal engine can ask about it.
*
* A turn with no tool result has no standing — `undefined`, the same answer
* as a turn whose results the model never judged. Both are "the ledger says
* nothing that holds this turn", which is all a caller may conclude.
*
* A message the batch settlement wrote (9.113.0, `LLMMessage.notDispatched`)
* is served and is no RESULT (`findings/offer.ts` · `isResultMessage`), so it
* takes no part in the ranking: counted, it would rank as an UNDECLARED
* result above the noise and ruled-out results the model really judged, and
* a batch it sits in would lose the standing its results earned — and a
* standing a model filed on its id anyway (`unknownId: true`) would hold the
* turn for a sentence about a call that never ran.
*
* @param turn the turn to judge
* @param standingOfCall the LAST standing the ledger holds for a result, by
* its `toolCallId` (`foldLedger(rows).standingOf.get(id)?.standing`), or
* `undefined` when it holds none
*/
export declare function turnStandingOf(turn: Turn, standingOfCall: (toolCallId: string) => Standing | undefined): Standing | undefined;
/**
* The turns whose standing is `fact`, NEWEST FIRST.
*
* Newest first because that is the order the ceiling is spent in: when the
* model has declared more facts than there are slots, the ones it declared
* most recently keep theirs.
*
* @param turns the window's turn segmentation
* @param history the window itself, for recovering a name from the
* assistant call when a result does not carry one
* @param standingOf a turn's standing, as the stage bound it
* (`WindowStrategyInput.standingOf`; built with {@link turnStandingOf}).
* Asked once per turn, newest first, and it is the ONLY thing read about
* the ledger here — the pin is content-aware by the model's claim alone
* @param after index of the CURRENT REQUEST, or `-1` when the window
* holds none. Nothing at or before it is pinnable: facts gathered for an
* earlier request are ordinary history, and this is what makes a new user
* turn release the whole previous loop without a line of code
*/
export declare function ledgerFactPinsOf(turns: readonly Turn[], history: readonly LLMMessage[], standingOf: (turn: Turn) => Standing | undefined, after?: number): readonly LedgerFactPin[];