agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
167 lines (166 loc) • 8.56 kB
TypeScript
/**
* window/turns — where a turn boundary is, and which turns may leave.
*
* Pattern: Pure functions over the window (no scope, no I/O, no clock).
* Role: core/ layer. THE refusal engine. Every window strategy — the three
* that ship, and any a consumer writes — decides what leaves the
* window by calling these functions and nothing else, because the
* whole safety argument of the family lives here:
*
* a removal that splits an assistant's `tool_use` from its
* `tool_result` produces a request the vendor rejects, and a
* removal that swallows an unanswered question destroys the
* referent of the answer that has not arrived yet.
*
* Strategies never import this module. `WindowStrategyInput` hands
* them `planRemoval` already bound to this iteration's turns and
* guards, so a strategy CANNOT skip the refusal rules — that is a
* property of the seam, not of the documentation.
* Emits: N/A.
*
* Testable on its own — see `test/core/window-turns.test.ts`.
*/
import type { LLMMessage } from '../../../provider-contract/types.js';
import type { ToolResultPin } from './lastToolResult.js';
import type { LedgerFactPin } from './ledgerFactPins.js';
import type { WindowObservations, WindowRefusal, WindowRefusalReason } from './types.js';
/**
* One turn: a `user` / `assistant` / `system` message plus every `tool`
* message that answers it. Tool results belong to the assistant turn that
* requested them — that pairing is the thing a removal must never break.
*/
export interface Turn {
/** Index of this turn in the segmentation. */
readonly index: number;
/** Index of the turn's FIRST message in the window. */
readonly start: number;
/** Number of messages in the turn. */
readonly length: number;
readonly messages: readonly LLMMessage[];
}
/**
* Segment a window into turns.
*
* A new turn starts at any non-`tool` message; `tool` messages join the turn
* in progress. A leading `tool` message (only reachable from a hand-built
* history) starts its own turn rather than being silently dropped.
*/
export declare function segmentTurns(history: readonly LLMMessage[]): readonly Turn[];
/**
* Context a removal decision needs beyond the turn itself.
*
* Internal: the stage builds it from scope and binds it into the
* `planRemoval` a strategy is handed, so no strategy has to know it exists.
*/
export interface RemovalGuards {
/** Every `toolCallId` answered anywhere in the window. */
readonly answeredCallIds: ReadonlySet<string>;
/**
* Index in the window of the CURRENT REQUEST — the message this run is
* executing (9.55.0). The turn holding it refuses with `'current-request'`.
*
* Resolved by `currentRequestIndexOf`, which the stage calls with the run's
* own `scope.userMessage`. Absent (or `-1`) when the window holds no
* identifiable request, and then this rule simply does not apply — which is
* how a window that never had one behaves exactly as it did before the rule
* existed.
*/
readonly currentRequestIndex?: number;
/** The tool call this run is paused on, when it is paused. */
readonly pausedToolCallId?: string;
/** True when the pause is a check-in (human consent) rather than askHuman. */
readonly pausedCheckIn?: boolean;
/**
* Candidate LAST-TOOL-RESULT pins (9.57.0), newest first, from
* `toolResultPinsOf`. An INPUT to `planRemoval`, not to `refusalFor`: the
* ceiling can only be spent once the keep window is known, so `planRemoval`
* admits some of these and hands `refusalFor` the answer.
*/
readonly toolResultPins?: readonly ToolResultPin[];
/** The ceiling on admitted pins (`keepLastToolResults`). 0 = the pin is off. */
readonly keepLastToolResults?: number;
/**
* The ADMITTED pins, by turn index — what `refusalFor` actually reads.
* Derived by `planRemoval`; a caller building guards by hand may set it
* directly to ask `refusalFor` about one turn.
*/
readonly pinnedTurnIndexes?: ReadonlySet<number>;
/**
* Candidate LEDGER-FACT pins (9.102.0), newest first, from
* `ledgerFactPinsOf` — the turns whose results the MODEL declared facts on
* its findings ledger. The same contract as `toolResultPins`: an input to
* `planRemoval`, which spends the ceiling and hands `refusalFor` the
* answer. Present only on an agent with `.findings()` — the stage never
* resolves them otherwise, so an unarmed agent's guards are the object
* they always were.
*/
readonly ledgerFactPins?: readonly LedgerFactPin[];
/** The ceiling on admitted fact pins (`keepLedgerFacts`). 0 = the hold is off. */
readonly keepLedgerFacts?: number;
/**
* The ADMITTED fact pins, by turn index — the `'ledger-fact'` twin of
* `pinnedTurnIndexes`, kept apart because the two pins have different
* ceilings and a reader adding up what each one cost needs them apart.
*/
readonly factPinnedTurnIndexes?: ReadonlySet<number>;
}
/** Every tool_call id that has a matching `role: 'tool'` message. */
export declare function answeredCallIds(history: readonly LLMMessage[]): ReadonlySet<string>;
/**
* Why this turn may NOT leave the window, or `undefined` when it may.
*
* Order matters only for which reason gets reported first; every check is
* independent. `paused-tool` / `pending-check-in` are separated from
* `unresolved-tool-call` on purpose: they are the same shape but a different
* fact about the world, and "we are waiting on a human" is what the person
* reading the trace needs to see.
*/
export declare function refusalFor(turn: Turn, guards: RemovalGuards): WindowRefusalReason | undefined;
/** The span a removal will take, plus every refusal it had to name to get there. */
export interface RemovalPlan {
/** First turn index in the span; -1 when nothing may be removed. */
readonly from: number;
/** Last turn index in the span (inclusive); -1 when nothing may be removed. */
readonly to: number;
readonly refusals: readonly WindowRefusal[];
/**
* What the last-tool-result pin did on this plan (9.57.0) — which turns it
* held and what the ceiling turned away. Absent when it held nothing, so a
* window with no pinnable result plans exactly as it did before.
*/
readonly observations?: WindowObservations;
/**
* What the ledger-fact pin did on this plan (9.102.0) — the same shape,
* with `limit` = `keepLedgerFacts`. Absent when it held nothing, which on
* an agent without `.findings()` is always.
*/
readonly ledgerFacts?: WindowObservations;
}
/**
* Choose the removal span: the LONGEST CONTIGUOUS run of removable candidate
* turns, starting at the oldest removable one.
*
* Contiguity is not fussiness — it is what keeps the conversation in order.
* A fold replaces its span with ONE summary message; if the span skipped over
* an unremovable turn, that turn would end up after a summary of things that
* happened before it. So an unremovable turn at the front is stepped over (the
* span "takes the next oldest instead") and an unremovable turn in the middle
* ends the span. Everything not removed keeps its position.
*
* The drop strategies take the same span for a second reason: it is what makes
* a refusal reason mean the same thing under every strategy. A turn that ends
* the span this iteration is retried the next one — by which time the tool
* result it was waiting on has usually arrived.
*
* @param turns the window's turn segmentation
* @param keepRecent how many trailing turns are off-limits
* @param guards what must not leave (unanswered calls, the pause)
* @param isExistingSummary optional: true for a turn that is a summary a prior
* fold wrote. When the whole span is one such turn, the plan refuses with
* `only-existing-summary` — re-summarizing a summary spends an LLM call to
* lose detail. The drop strategies omit it: a drop spends nothing, so there
* is nothing to protect against.
*/
export declare function planRemoval(turns: readonly Turn[], keepRecent: number, guards: RemovalGuards, isExistingSummary?: (turn: Turn) => boolean): RemovalPlan;
/** Total characters of message content in a window. Exact; not tokens. */
export declare function windowChars(history: readonly LLMMessage[]): number;