agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
3,898 lines • 194 kB
TypeScript
/**
* Event payload types — the payload interfaces behind the registered events
* (never a hardcoded count here: the registry's counts are pinned by
* test/events/unit/registry.test.ts).
*
* Pattern: Discriminated Union (Gang of Four inspired, TS-native).
* Role: Contract layer of Event-Driven Hexagonal Architecture.
* Emits: (types only).
*/
import type { CompositionKind, ContextLifetime, ContextRecency, ContextRole, ContextSlot, ContextSource, LLMProviderName, ToolProtocol, ToolCallEventIdentity } from './types.js';
import type { LLMMessage, PermissionCapability } from '../provider-contract/types.js';
import type { MemoryFlavor, MemoryStrategyKind, MemoryType } from '../memory/define.types.js';
import type { ArtifactOp, ArtifactRefusalReason } from '../artifacts/capability.js';
import type { ArtifactOrigin, ArtifactSweepReason } from '../artifacts/types.js';
import type { DatasetTimeAxis } from '../time/axis.js';
import type { ThinkingBlock } from '../thinking/types.js';
import type { LoopMoment } from '../lib/moments.js';
import type { ScriptsVerdict, TurnEndScripts } from '../lib/scriptRecord.js';
import type { InstructionDeliveryLease, ToolResultStatus } from '../semantics/toolEffects.js';
import type { ToolSemantics } from '../semantics/types.js';
import type { Basis, Expect, Standing } from '../core/agent/findings/types.js';
import type { ArgumentAsked, ArgumentCheckFailed, ArgumentClaim, ArgumentSource } from '../core/agent/arguments/rows.js';
import type { AssessmentCheck, AssessmentReason, HonestyLayer } from '../core/agent/assessment/types.js';
export interface CompositionEnterPayload {
readonly kind: CompositionKind;
readonly id: string;
readonly name: string;
readonly childCount: number;
}
export interface CompositionExitPayload {
readonly kind: CompositionKind;
readonly id: string;
/** Display name supplied at composition build time (e.g., the
* `Sequence.create({ name: 'IntakePipeline' })` arg). Mirrors the
* `name` field on `CompositionEnterPayload` so consumers narrating
* the exit moment can reference the same human-readable identity
* used at entry — no name-cache required across the start/stop
* pair. Optional for back-compat with pre-v2.14.5 emitters. */
readonly name?: string;
readonly status: 'ok' | 'err' | 'break' | 'budget_exhausted';
readonly durationMs: number;
}
export interface ParallelForkStartPayload {
readonly parentId: string;
readonly branches: readonly {
id: string;
name: string;
}[];
}
export interface ParallelBranchCompletePayload {
readonly parentId: string;
readonly branchId: string;
readonly status: 'ok' | 'err';
readonly durationMs: number;
}
export interface ParallelMergeEndPayload {
readonly parentId: string;
/**
* Which merge strategy ran. `'fn'` = `mergeWithFn` (strict, plain
* results map). `'llm'` = `mergeWithLLM` (strict, LLM synthesis).
* `'outcomes-fn'` = `mergeOutcomesWithFn` (tolerant, full
* `BranchOutcome` map). Distinct values so consumers can render
* tolerant vs strict merges differently in dashboards.
*/
readonly strategy: 'llm' | 'fn' | 'outcomes-fn';
readonly resultSummary: string;
/** Number of branches whose result FED the merge — i.e., succeeded
* (or, in tolerant mode, those the merge fn actually consumed as
* `{ok: true}`). Failing branches are counted in `totalBranchCount
* - mergedBranchCount`. */
readonly mergedBranchCount: number;
/** Total number of branches declared on the Parallel — equals
* `mergedBranchCount` on all-success runs, larger on partial. */
readonly totalBranchCount: number;
}
export interface ConditionalRouteDecidedPayload {
readonly conditionalId: string;
readonly chosen: string;
readonly rationale?: string;
readonly evidence?: unknown;
}
export interface LoopIterationStartPayload {
readonly loopId: string;
readonly iteration: number;
}
export interface LoopIterationExitPayload {
readonly loopId: string;
readonly iteration: number;
readonly reason: 'budget' | 'guard_false' | 'break' | 'body_complete';
}
export interface AgentTurnStartPayload {
readonly turnIndex: number;
readonly userPrompt: string;
}
export interface AgentTurnEndPayload {
readonly turnIndex: number;
readonly finalContent: string;
readonly totalInputTokens: number;
readonly totalOutputTokens: number;
readonly iterationCount: number;
readonly durationMs: number;
/**
* Why the loop stopped before the model said it was done (9.56.0) — present
* ONLY on a turn a limit cut short, absent on every normal finish.
*
* It rides `turn_end` because that is the event a consumer already reads to
* render an outcome: the moment the answer exists. Reading `finalContent`
* without it, a dashboard cannot tell a finished answer from the fragment a
* loop stopped in the middle of — which is exactly how a half-sentence gets
* shown to a person under a green tick.
*
* `wrappedUp` says which of the two this is. `true` = the run spent one more
* call with the tools withheld and `finalContent` is that summary; absent =
* the turn ended on whatever the last call produced. Mirrors the committed
* `stoppedEarly` record (`agent.stoppedEarly()`), never a second source of
* it.
*/
readonly stoppedEarly?: {
readonly reason: 'max-iterations' | 'cost-budget';
readonly iteration: number;
readonly pendingToolCalls: number;
readonly wrappedUp?: true;
};
/**
* The limits of a TYPED answer, as data — present ONLY when the agent has an
* output schema and `.limitsTravelWithTheAnswer()`, and the run's tools
* declared something; absent on every other turn.
*
* A prose answer carries its limits as a block appended to `finalContent`.
* A typed answer cannot: it is JSON, and JSON followed by prose is not JSON.
* So `finalContent` stays the model's answer and the same limits ride here —
* what the tools `checked`, did not check and can never cover, folded across
* the run (duplicates said once, every entry kept). Mirrors the committed
* `answerCoverage` record (`agent.answerCoverage()`), never a second source
* of it.
*/
readonly answerCoverage?: {
readonly checked: readonly CoverageItemPayload[];
readonly notChecked: readonly CoverageItemPayload[];
readonly cannotCover: readonly CoverageItemPayload[];
/**
* The periods the calls' results declared (honesty step 7b) — one per
* declaring call, as declared; present only when one did. The data twin of
* a prose answer's `Period:` lines.
*/
readonly periods?: readonly (PeriodPayload & {
readonly toolName: string;
readonly toolCallId?: string;
})[];
/**
* What the calls' reads found still running — its outcome not known yet —
* one entry per declaring call, as declared; present only when one did.
* The data twin of a prose answer's "In progress" lines.
*/
readonly inProgress?: readonly {
readonly toolName: string;
readonly toolCallId?: string;
readonly items: readonly InProgressItemPayload[];
}[];
/**
* The values a tool's `assume` rule filled this turn (the inputs layer,
* honesty layer 2) — the SAME rows, read the same way, that the prose
* answer's "Assumed" block prints (`arguments/serve.ts` ·
* `assumedLinesFor`): one entry per distinct (tool, argument, value), a
* row a before-tool rewrite superseded left out. `value` is the tool's
* own argument view; `'REDACTED'` with `hidden` when that view hides it.
* Present only when a value was assumed.
*/
readonly assumed?: readonly {
readonly toolName: string;
readonly argument: string;
readonly value: string;
readonly hidden: boolean;
}[];
};
/**
* The answer's standing, as data — present ONLY on an agent with
* `.answerLayer()` (honesty layer 4), and then on every turn that ends in
* an answer. The same projection `agentfootprint.answer.assessed` carries
* ({@link AnswerAssessmentPayload}): the value, its rendering, the reason
* kinds and the checks that ran; never a value or a quote. It rides
* `turn_end` because that is the event a consumer already reads to render
* an outcome — including a composition that mounts the agent, whose
* dispatcher carries the agent's `turn_end` but not the `answer.*` domain.
*/
readonly answerAssessment?: AnswerAssessmentPayload;
/**
* The app's words the appended standing line named calls in — registered
* tool name → the tool's declared `displayName`. Present ONLY when the line
* (`.answerLayer({ standingLine: true })`) named a reason's calls by a
* declared name, so every other turn emits the exact payload it always did.
* The words are the app's own, already in `finalContent`; the record keeps
* them so a reader rebuilds the very line from the record.
*/
readonly standingLineNames?: Readonly<Record<string, string>>;
/** Runtime-composed typed negatives, with verification and delivery kept separate. */
readonly answerNegatives?: AnswerNegativesPayload;
/**
* The scripts the library attached to the answer — present ONLY under
* `.scriptTravelsWithTheAnswer()`, on every answer. Each `shown` entry is
* the program a call SUBMITTED to a declared code-running tool, read from
* the run record (never retyped by the model), with the call's label read
* from its execution record: `exitCode` only when the runner supplied one,
* `truncated` when the text is cut at `maxBytes`. `omitted` counts the
* calls not attached (the cap, a program over the bound, a call past the
* per-turn rows). A prose answer also carries them as text after its `---`
* separator; a typed answer carries them here only. `text` is a
* model-written string: under any redaction policy it is served as the
* placeholder, and the label and the counts survive.
*/
readonly scripts?: TurnEndScripts;
/**
* The answer was REFUSED — present ONLY then. Under the evidence gate's
* `'rails'` posture (`.namesAndNumbersFromEvidence()`), an answer stating
* values no tool result carried is not delivered: `run()` raises
* `UnsupportedValuesError`. `finalContent` stays for the record (the answer
* account explains the refusal from it), but it is NOT the agent's answer —
* a consumer rendering an outcome shows the refusal, never this text.
*/
readonly refused?: {
readonly by: 'evidence-rails';
};
}
/**
* A turn's budget ran out while the model was still working (9.56.0).
*
* Fires ONCE per turn, from the Route decider, at the boundary that refused to
* run the tool calls the model just asked for — the same moment
* `cost.limit_hit { kind: 'max_iterations' }` fires, and beside it rather than
* instead of it: that event reports a LIMIT being crossed, this one reports
* what the run then DID about it.
*
* `action` is the whole point. `'wrapped-up'` means one more call went out with
* the tools withheld and its answer is the turn's answer; `'cut-short'` means
* the turn ended on whatever text rode the last call — a fragment, mid-task,
* more often than not. A consumer that only ever sees `'wrapped-up'` is
* looking at a healthy default; one seeing `'cut-short'` is looking at a turn
* whose answer nobody should trust as complete.
*/
export interface AgentBudgetExhaustedPayload {
/** Which budget ran out. Only `'max-iterations'` is ever wrapped up. */
readonly reason: 'max-iterations' | 'cost-budget';
/** The iteration the budget ran out on. */
readonly iteration: number;
/**
* The budget itself — present ONLY for `'max-iterations'`, where it is
* `maxIterations`.
*
* Absent for `'cost-budget'`, deliberately: the run's committed state carries
* the cumulative SPEND, not the cap, and reporting spend as `limit` is the
* exact mistake `cost.limit_hit` documents and avoids. The real USD cap rides
* that event, emitted the moment the budget was crossed.
*/
readonly limit?: number;
/** How many tool calls the model asked for that will never run. */
readonly pendingToolCalls: number;
/** What the run did about it. */
readonly action: 'wrapped-up' | 'cut-short';
}
export interface AgentIterationStartPayload {
readonly turnIndex: number;
readonly iterIndex: number;
}
export interface AgentIterationEndPayload {
readonly turnIndex: number;
readonly iterIndex: number;
/**
* On the main loop, the calls the turn proposed. On a resume leg (9.113.0),
* the brackets THAT leg closed: the paused call plus each sibling the batch
* settlement answered — so a batch of three paused on its middle call
* reports 2, and the calls before the pause are counted by no
* `iteration_end` (their brackets closed on the leg that paused). For the
* batch's size, read `tool_start.parallelCount`. See `src/core/README.md` ·
* "A batch that pauses settles its un-dispatched siblings".
*/
readonly toolCallCount: number;
/** Conversation history (LLM messages) at the END of this
* iteration. Captured by `agent.run()` for fault-tolerant
* resume — `RunCheckpointError.checkpoint` snapshots this so
* `agent.resumeOnError(...)` can replay from the last good
* iteration. Optional for back-compat with v2.x recorders that
* subscribed without expecting this field. Typed as the messages it
* holds, so its field names and roles are the library's own words on a
* redacted record (`redaction/knownStrings.ts`). */
readonly history?: ReadonlyArray<LLMMessage>;
}
/**
* The `route_decided` rationales that are FIXED sentences — the library's own
* words, so a redacted record keeps them (`redaction/knownStrings.ts`). A
* rationale composed per turn (a count of calls, a skill's name) is a free
* string there, kept out under a policy like any other.
*/
export type FixedRouteRationale = 'LLM produced no tool calls — final answer' | 'maxIterations reached — forcing final' | 'costBudget reached (onExceed: halt) — forcing final' | 'tools are off after the evidence revision (answer validation enforces) — forcing final';
/**
* The `pause.request` reason the library writes when the pause carries none —
* the library's own words (`redaction/knownStrings.ts`). A reason the pause
* carries is a free string.
*/
export type FixedPauseReason = 'stage requested pause';
export interface AgentRouteDecidedPayload {
readonly turnIndex: number;
readonly iterIndex: number;
/**
* The branch the turn took. `'output-retry'` (7.26) appears only on an
* agent built with `.outputSchema(parser, { retries })`, and only on a
* turn whose answer failed the schema with retries left — the loop is
* about to ask again rather than finish. `'step-nudge'` (9.18.0) appears
* only on an agent with a stepped skill, and only on a turn whose
* would-be-final answer left declared steps unrun with the one teaching
* nudge still unspent. `'evidence-recheck'` (9.35.0) appears only on an
* agent built with `.namesAndNumbersFromEvidence({ posture: 'guard' |
* 'rails' })`, and only on a turn whose would-be-final answer stated values
* no tool result carried, with the one revision still unspent. `'wrap-up'`
* (9.56.0) appears only on a turn whose `maxIterations` ran out while the
* model was still asking for tools, on an agent that did not set
* `wrapUpAtMaxIterations: false` — the loop is about to spend one last call
* with the tools withheld so the turn ends with a summary.
*/
readonly chosen: 'tool-calls' | 'final' | 'output-retry' | 'step-nudge' | 'evidence-recheck' | 'negative-recheck' | 'wrap-up';
readonly rationale?: FixedRouteRationale | (string & Record<never, never>);
}
export interface AgentHandoffPayload {
readonly fromAgentId: string;
readonly toAgentId: string;
readonly reason?: string;
readonly viaProtocol?: 'native' | 'mcp' | 'http';
}
export interface LLMStartPayload {
readonly iteration: number;
readonly provider: LLMProviderName;
readonly model: string;
readonly systemPromptChars: number;
/**
* The ASSEMBLED system prompt, verbatim as sent to the provider (9.50.0) —
* every injection piece joined exactly the way the wire saw it. **OPT-IN,
* default OFF**: present only when the run was built with
* `recordSystemPrompt: true` (`AgentOptions` / `LLMCallOptions`).
*
* PRIVACY: the assembled prompt routinely carries what its pieces carry —
* skill bodies, RAG passages, memory recalls, per-user instructions. With
* the dial on, all of it lands in every event sink and every recording
* (`recordRun` → `persistRecording`), so treat those artifacts as being as
* sensitive as the prompt itself. Absent, the event keeps its exact prior
* bytes: only `systemPromptChars` (the length) is recorded, and the
* assembled string is honestly NOT in the recording.
*/
readonly systemPromptText?: string;
readonly messagesCount: number;
readonly toolsCount: number;
/**
* The tool CATALOG the model saw for this call — what was at its disposal when
* it chose (the menu behind its tool-selection reasoning). One `{ name,
* description }` per tool sent to the provider, in request order. Absent when
* the call had no tools. The structured "what the model saw" payload: pair it
* with the iteration's reasoning to debug WHY a tool was (or wasn't) picked.
* Names + descriptions only — full input schemas live in the snapshot.
*/
readonly tools?: readonly {
readonly name: string;
readonly description?: string;
}[];
readonly estimatedPromptTokens?: number;
readonly temperature?: number;
readonly providerRequestRef?: string;
/**
* WHICH BRAIN answered, when a brain rung won (9.19.0 — "the cursor picks
* the brain"). `via: 'skill'` = the active skill's declared brain served
* this call (`skillId` names the tenure); `via: 'escalation'` = the turn
* crossed its declared refusal threshold and the escalation brain is
* serving the rest of it. ABSENT whenever the agent's own configured /
* default model answered — an agent without brains keeps its exact prior
* event bytes. `provider` + `model` above always report what was actually
* called, whichever rung supplied them.
*/
readonly brain?: {
readonly via: 'skill' | 'escalation';
readonly skillId?: string;
};
}
export interface LLMEndPayload {
readonly iteration: number;
/** Draft text, or an empty string when contentWithheld is true. */
readonly content: string;
/** An output policy withheld draft text. This does not mean the model returned no text. */
readonly contentWithheld?: true;
readonly toolCallCount: number;
readonly usage: {
readonly input: number;
readonly output: number;
readonly cacheRead?: number;
readonly cacheWrite?: number;
/**
* 9.29.0 — reasoning tokens the model spent on this call, when the
* provider reports them (`LLMResponse.usage.thinking`).
*
* This field is the fix for a gap a field trial found by walking into it:
* a 256-token Gemini stream produced ONE visible chunk, and the run only
* made sense once the numbers were read — `input 21, output 9,
* thinking 243`. The provider had been reporting that third number all
* along and this payload's type dropped it, so the event that a consumer
* actually subscribes to could not explain its own run.
*
* **Not inside `output`.** On Gemini `candidatesTokenCount` counts visible
* tokens only; thinking is a separate, billed line item. A cost estimate
* built from `input + output` under-counts a thinking model by whatever it
* thought. `cost.tick` deliberately does NOT fold it in — `PricingTable`
* prices four kinds and thinking is not one of them, and inventing a rate
* would be this library guessing at somebody's invoice.
*
* Undefined when the provider does not report thinking tokens, which is
* most calls.
*/
readonly thinking?: number;
};
readonly stopReason: string;
readonly durationMs: number;
readonly providerResponseRef?: string;
}
export interface LLMTokenPayload {
readonly iteration: number;
readonly tokenIndex: number;
readonly content: string;
}
export interface ToolStartPayload {
readonly toolName: string;
readonly toolCallId: string;
readonly args: Readonly<Record<string, unknown>>;
readonly parallelCount?: number;
readonly protocol?: ToolProtocol;
/**
* 9.113.0 — present only on the bracket of a call that was NEVER
* DISPATCHED: a batch paused on an earlier call, and the resume answered
* this one with a fixed sentence instead of running it
* (`core/agent/stages/toolCalls.ts` · `bracketSettled`, the one stamping
* site). `pausedCall` names the call in the same batch the run paused on.
* No gate judged this call and no tool ran; `args` are the ones it would
* have run with.
*
* The same fact, the same shape, as the history message's marker — typed off
* `LLMMessage.notDispatched`, so there is one definition. Read it rather than
* inferring anything from the bracket's `durationMs: 0` or its sentence.
*
* Absent on every other bracket.
*/
readonly notDispatched?: LLMMessage['notDispatched'];
}
/**
* One progress report from inside a still-running tool call (9.52.0) — filed
* by `ctx.progress(payload)` from within `tool.execute`, in call order, always
* BETWEEN this call's `stream.tool_start` and its `stream.tool_end`.
*
* The split is deliberate: the three identity fields are stamped by the
* FRAMEWORK from the dispatch it is already holding (a tool cannot claim to be
* another call, another tool, or another iteration), and `payload` is the tool
* author's data forwarded verbatim. Nothing here is ever shown to the model —
* this is the telemetry channel, not the result channel.
*
* Absent entirely for every tool that never reports: a run whose tools call
* nothing files no `tool_progress` events at all.
*/
export interface ToolProgressPayload {
/** The invocation this report belongs to — matches `stream.tool_start.toolCallId`. */
readonly toolCallId: string;
/** The tool that reported. */
readonly toolName: string;
/** The ReAct iteration this call was dispatched on (`0` where there is no loop). */
readonly iteration: number;
/**
* The tool author's payload, verbatim — whatever shape they chose
* (`{ done: 3, total: 12 }`, a status string, a partial row). The library
* neither reads it nor normalizes it; it must survive `structuredClone`,
* because it lands in every event sink and every recording.
*/
readonly payload: unknown;
}
export interface ToolEndPayload {
readonly toolCallId: string;
readonly result: unknown;
readonly error?: boolean;
readonly durationMs: number;
/**
* The tool's OWN declared outcome (9.19.0) — present only when the tool
* returned a result envelope carrying `status`. Normalized vocabulary
* (`'denied'` must never read like `'success'`); route edges key on it
* via `onToolStatus`. Absent for every tool that never opted in.
*/
readonly status?: ToolResultStatus;
/**
* 9.113.0 — present only when this call was NEVER DISPATCHED: the batch
* settlement closed its bracket (`core/agent/stages/toolCalls.ts` ·
* `bracketSettled`). `result` is then the library's fixed sentence (the one
* the model read), `durationMs` is `0`, and `error` is ABSENT.
*
* `error` says a call FAILED — the tool threw or reported a failure, or the
* call could not run as written (an args-validation rejection, a `wants`
* block, a consent the tool needed and did not get, an unknown tool). A
* settled call did not fail: the library decided not to dispatch it, the
* way it decides for a call a permission policy denies or halts, whose
* brackets carry no `error` either. This field is the owner of WHY there is
* no result, and a reader that counts outcomes reads it: a bracket that
* carries it is neither a success nor a failure of the tool.
*
* Same shape as `ToolStartPayload.notDispatched` and as the history
* message's marker (typed off `LLMMessage.notDispatched`). Absent on every
* other bracket.
*/
readonly notDispatched?: LLMMessage['notDispatched'];
/**
* What the MODEL read for this call, when it differs from `result`: an
* `onToolResult` link rewrote or denied it, the `maxToolResultChars` cap
* cut the model's channel alone, or the library removed the record-only
* coverage fields (`short`, `kind` — see `CoverageItem`) from a coverage
* envelope before serving it. `result` stays the tool's own answer — this
* is the other half of that split, which until now lived only in history.
* Absent when the model read `result` itself (the common case; the two
* channels are then one reference). Stamped on all five dispatch paths.
*
* Cost, stated: a call whose tool declares `short` or `kind` carries its
* envelope twice here (`result` + `modelResult`), so the extra bytes are
* the size of that envelope — about 4 KB for a typical absence, and as
* large as the payload a ledger wraps. Under result PLACEMENT a difference
* that is only this strip counts as one value: both channels carry the one
* ticket and nothing is stamped. A tool that declares neither gets no stamp
* and no new byte.
*/
readonly modelResult?: unknown;
/**
* The NAMES of the argument keys whose value the tool ran with differs from
* the proposal `stream.tool_start` carried: set, rewritten or removed by an
* `onToolCall` link, or hidden by the tool's own redaction policy
* (`flowchartAsTool({ redact })`, `runbookAsTool({ redact })` —
* `core/toolShownArgs.ts` · `shownArgsOf`). Names only, never values: a
* link may ADD a value (a server-side key) the model never saw, and an event
* goes to every sink attached, each serializing what it gets. Taken BEFORE
* the tool runs, so nothing the tool writes into its arguments reaches it.
* A reader exporting the call's arguments shows these keys' values as
* withheld. Absent when the tool ran with the proposal as it was.
*
* Stamped on the batch-dispatch bracket only — the one whose `tool_start`
* precedes it in the same run.
*/
readonly changedArgKeys?: readonly string[];
/**
* A configured RULE refused the call, so the tool never ran: a permission
* policy denied or halted it, or an `onToolCall` link denied it. `result` is
* then the refusal the model read, and `error` is absent — this field is
* what tells such a bracket from a tool that ran and answered.
*
* NOT a general "never ran" marker, and its absence proves nothing: a call
* that could not run with no rule involved (an unknown tool name, an args
* rejection, a `wants` or credential block) carries `error: true` instead,
* a settled bracket carries `notDispatched`, and a resumed leg's bracket
* carries neither. Batch-dispatch bracket only.
*/
readonly notExecuted?: true;
}
export interface ContextInjectedPayload {
readonly contentSummary: string;
readonly contentHash: string;
readonly rawContent?: string;
readonly slot: ContextSlot;
readonly asRole?: ContextRole;
readonly asRecency?: ContextRecency;
readonly position?: number;
readonly sectionTag?: string;
readonly source: ContextSource;
readonly sourceId?: string;
readonly upstreamRef?: string;
readonly reason: string;
readonly retrievalScore?: number;
readonly rankPosition?: number;
readonly threshold?: number;
readonly budgetSpent?: {
readonly tokens: number;
readonly fractionOfCap: number;
};
readonly expiresAfter?: ContextLifetime;
}
export interface ContextEvictedPayload {
readonly slot: ContextSlot;
readonly contentHash: string;
readonly reason: 'budget' | 'stale' | 'low_score' | 'policy' | 'user_revoked';
readonly survivalMs: number;
}
export interface ContextSlotComposedPayload {
readonly slot: ContextSlot;
readonly iteration: number;
readonly budget: {
readonly cap: number;
readonly used: number;
readonly headroomChars: number;
};
readonly sourceBreakdown: Readonly<Partial<Record<ContextSource, {
readonly chars: number;
readonly count: number;
}>>>;
readonly orderingStrategy?: string;
readonly droppedCount: number;
readonly droppedSummaries: readonly string[];
}
/**
* Fired when a budget is exceeded — by a context SLOT composing over its
* `budgetCap`, or by a window STRATEGY measuring the window over its
* `thresholdTokens`.
*
* ## Read `unit` before you read a number (8.14.0)
*
* Two emitters share this event name and the `slot: 'messages'` value, and
* **they do not count in the same unit**:
*
* | emitter | what it measures | `unit` |
* |---|---|---|
* | the three context slots (`contextBudget`) | `String.length` of what it composed | `'chars'` |
* | a window strategy (`.window()` / `.compaction()`) | the provider's reported input tokens | `'tokens'` |
*
* Since `contextBudget` is on by default, one subscriber routinely receives
* both — and before 8.14.0 nothing in the payload told them apart, so
* "cap 200, projected 258" could be 258 characters or 258 tokens. `unit` is
* the answer; `cap` and `projected` are the same two numbers under names that
* do not assert an untrue one.
*
* `planAction: 'none'` means no mitigation was performed — nothing was
* evicted or truncated and the full content still went to the LLM. That
* is what the built-in slots report: they never truncate, so the event is
* the signal that the budget is not being respected.
*/
export interface ContextBudgetPressurePayload {
readonly slot: ContextSlot;
/** How far over the cap, in {@link unit}. Never carried a unit in its name. */
readonly overflowBy: number;
readonly planAction: 'evict' | 'summarize' | 'abort' | 'none';
/**
* What {@link cap}, {@link projected} and {@link overflowBy} are counted in
* (8.14.0). `'chars'` from a context slot, `'tokens'` from a window
* strategy. Branch on this before comparing any of the three numbers to
* anything.
*/
readonly unit: 'chars' | 'tokens';
/**
* The budget that was exceeded, in {@link unit}.
*
* Called `capTokens` until 9.0.0 — a name that asserted tokens on a channel
* that is CHARS half the time, since `contextBudget` is on by default and a
* slot counts `String.length`. Both spellings shipped through 8.x with the
* identical value; 9.0.0 keeps only this one.
*/
readonly cap: number;
/** What was measured, in {@link unit}. Called `projectedTokens` until 9.0.0
* — see {@link cap}. */
readonly projected: number;
}
/**
* Fired once per iteration by the Injection Engine after it evaluates every
* Injection's trigger — BEFORE the Context fork routes the survivors into the
* three slots. This is the "what was considered, what won, what was skipped
* and why" signal; `context.slot_composed` is its downstream counterpart
* ("what actually landed in each slot"). Pure observability — no flow stage
* reads it.
*/
/** A guard threshold value as the record carries it — plain data only. */
export type SkillGuardValueRecord = string | number | boolean | null | ReadonlyArray<string | number | boolean | null>;
/**
* One route-edge guard evaluation, as `cursorMove.guard` / `guardsClosed`
* carry it (9.51.0): the guarded edge, the tool result it judged, the
* verdict, and per-condition evidence — each declared condition
* (`key`/`op`/`value`), the summarized value it was judged against (bounded
* to 80 chars — evidence, not a transcript), and whether it passed. The
* operator vocabulary deliberately mirrors footprintjs's `WhereFilter`
* (`eq/ne/gt/gte/lt/lte/in/notIn`).
*/
export interface SkillGuardEvaluationRecord {
readonly from: string;
readonly to: string;
readonly toolName: string;
readonly toolCallId?: string;
readonly verdict: boolean;
readonly conditions: ReadonlyArray<{
readonly key: string;
readonly op: string;
readonly value: SkillGuardValueRecord;
readonly actualSummary: string;
readonly passed: boolean;
}>;
}
export interface ContextEvaluatedPayload {
readonly iteration: number;
/** Number of injections active this iteration. */
readonly activeCount: number;
/** Number skipped (predicate false counts as neither — only errors/unknown land here). */
readonly skippedCount: number;
/** Total injections evaluated (the full declared list). */
readonly evaluatedTotal: number;
/** Ids of the active injections, in evaluation order. */
readonly activeIds: readonly string[];
/** Why each skipped injection was skipped (errors / unknown trigger kinds). */
readonly skippedDetails: readonly {
readonly id: string;
/** `'unknown-fact'` (9.57.0): a templated instruction named a run-time
* fact this evaluation could not supply, so it was skipped whole.
* `'parked'` (9.58.0): a member of a map the mount kernel parked —
* served without corroboration for the grace window; the map's own
* cursor is untouched and evidence re-engages it.
* `'hidden'`: a skill the caller's role may not see (`skill_read`),
* refused whatever tried to activate it. */
readonly reason: 'predicate-threw' | 'unknown-trigger-kind' | 'unknown-fact' | 'parked' | 'hidden';
readonly error?: string;
}[];
/** Count of active injections by trigger kind (always / rule / on-tool-return / llm-activated). */
readonly triggerKindCounts: Readonly<Record<string, number>>;
/**
* The Skill CATALOG the LLM was offered this turn — every registered Skill's
* `id` + `description` (the same text that lands in the `read_skill` tool
* description). Lets observers pair "what was offered" against "what the LLM
* chose" (`read_skill` → `activatedInjectionIds`) when debugging a missed or
* wrong activation. Empty when no Skills are registered. Static across turns.
*/
readonly skillCatalog: readonly {
readonly id: string;
readonly description: string;
}[];
/**
* Routing PROVENANCE for the active injections that came from a `skillGraph()`
* — *why* each was reached. One entry per active skill-graph injection (a
* decision-tree leaf, a flat entry, or a route edge); absent when no active
* injection carries skill-graph metadata. The structured counterpart to the
* `context.routed` commentary line — lets the lens show the decision path, the
* matched predicate, and the tools a route unlocked. Structural shape (mirrors
* `SkillRouting` from the injection engine; events stay decoupled from it).
*/
readonly routing?: readonly {
readonly injectionId: string;
readonly flavor: string;
/** `'tree' | 'entry' | 'route' | 'model'`. */
readonly via: string;
/** Decision path (tree only): predicates root→leaf + branch taken. */
readonly path?: readonly {
readonly label: string;
readonly branch: string;
}[];
/** Entry/route edge caption. */
readonly label?: string;
/** Source skill id (route only). */
readonly from?: string;
/** Compiled trigger kind for a route. */
readonly triggerKind?: string;
/** Tool names this injection unlocked. */
readonly tools?: readonly string[];
}[];
/**
* How the skill-graph CURSOR moved on THIS iteration — the winning clause of the
* graph's one cursor resolver, reported by it (8.5.0). Present only for an agent
* built with `.skillGraph()` (and a graph new enough to explain itself).
*
* Distinct from `routing[]` above, and the distinction is the point: `routing[]`
* is per-SKILL build-time provenance ("how is this skill reachable"), while this
* is per-HOP runtime truth ("what moved us, this turn"). `by: 'model-pick'` is the
* case the two disagree on — a `read_skill` pick the gate accepted, into a skill
* that also has a declared edge whose predicate never fired.
*/
readonly cursorMove?: {
readonly from?: string;
readonly to?: string;
/** `'entry' | 'route' | 'model-pick' | 'intent' | 'continuity' | 'stay' | 'none'`. */
readonly by: string;
/** The turn-start MENU this model pick resolved (SG-C) — present only on
* the iteration an accepted pick closed an outstanding menu. */
readonly offered?: readonly string[];
/** The accepted pick was reachable but NOT on the offered menu — the
* model's divergence, on the record (data under `'assist'`, a refusal
* under `'guard'`/`'rails'`, which never reach here). */
readonly declinedOffer?: boolean;
/** WHAT the message said that routed this hop (9.28.0) — present only for a
* `by: 'entry'` move a DATA matcher (`match:` — RegExp / `{ keywords }` /
* `{ all }`) decided. `text` is the matched substring of the USER message,
* whitespace-collapsed and bounded to 80 characters (ellipsis included);
* `keyword` names WHICH declared keyword hit, for the keywords arm.
* Absent for `when` predicates (opaque code), unconditional entries, and
* every scorer verdict (whose evidence is its scores). */
readonly witness?: {
readonly text: string;
readonly keyword?: string;
};
/**
* The EVIDENCE the winning data GUARD routed on (9.51.0) — present only
* for a `by: 'route'` move whose firing edge declared a `guard:`. The
* full per-condition evaluation (verdict `true`): which edge, which tool
* result it judged, and every declared condition with the summarized
* value it saw. Structural shape (mirrors the graph's `GuardEvaluation`;
* events stay decoupled from it).
*/
readonly guard?: SkillGuardEvaluationRecord;
/**
* The data guards that REFUSED this iteration (9.51.0) — guarded edges
* out of the cursor whose other declared conditions a tool result met
* and whose guard said no (verdict `false`; at most one record per
* edge). Rides whatever move resulted — including a `'stay'`, where it
* answers "why didn't my guarded edge fire?" with the conditions and
* values that closed it. Absent when no guard decided anything.
*/
readonly guardsClosed?: readonly SkillGuardEvaluationRecord[];
/**
* The REACHABLE set from the cursor this move landed on (9.50.0) — the
* skill ids the `read_skill` gate will admit on THIS iteration: declared
* hops out of the landed cursor plus the open skills (`llm-activated`
* skills the graph wires no edge to). It is the same set, from the same
* two resolvers, that builds the `read_skill` offer prose and any
* `skill.rejected.allowed` list this iteration — as DATA, so no consumer
* has to parse the menu sentence back into ids. `[]` is a fact (a dead
* end: no hop and no open skill is admissible); ABSENT means the graph
* predates `reachableSkills` and the set was not on the record.
*/
readonly reachable?: readonly string[];
};
/**
* Skill-graph entries whose own `when` matched this iteration and which the cursor
* law kept OFF the wire (8.15.0). A conditional entry is active exactly while the
* cursor is on it — `when` says where a turn STARTS, not what stays loaded — so an
* entry whose rule still matches while the graph is elsewhere is suppressed.
*
* Read it beside `cursorMove`, which names where the graph went instead: together
* they answer "why isn't my entry loading?" without re-running a predicate to
* guess. Absent when nothing was suppressed, and for every non-skill-graph run.
*
* Distinct from `agentfootprint.skill.reroute_superseded`, which reports a DISCRETE
* broken promise (a `read_skill` pick the gate accepted and a declared edge
* outranked). This is a CONTINUOUS condition — an entry whose rule stays true while
* the cursor is parked elsewhere is suppressed on every iteration — so it rides the
* per-iteration event.
*/
readonly supersededIds?: readonly string[];
}
export interface ErrorFatalPayload {
readonly error: string;
readonly stage: string;
readonly scope: string;
}
export interface PauseRequestPayload {
readonly reason: FixedPauseReason | (string & Record<never, never>);
readonly questionPayload: Readonly<Record<string, unknown>>;
}
export interface PauseResumePayload {
/**
* The reply the run resumed with. For the inputs layer's own ask
* (`awaitingInput.context.agentfootprint.ask === 'arguments'`) every
* answered value reads `'REDACTED'` — the reply's shape (request id, field
* ids) travels, the person's values do not, because they fill arguments a
* tool's view may hide; the `answered` argument rows carry what each tool's
* view allows.
*/
readonly resumeInput: Readonly<Record<string, unknown>>;
readonly pausedDurationMs: number;
/** The registered component the paused ask nominated to collect its answer
* (9.24.0) — read from the checkpoint's own pause payload, whichever pause
* kind carried it. Absent when the ask was prose-only. */
readonly componentId?: string;
}
/** A tool declared `checkIn` and it tripped — the run paused to ask a human,
* with the evidence pack riding the ask. Emitted BEFORE the tool executes. */
export interface CheckInRequestPayload {
readonly toolName: string;
readonly toolCallId: string;
readonly iteration: number;
/** The typed ask + evidence pack (`CheckInRequest`). JSON/clone-safe. */
readonly request: Readonly<Record<string, unknown>>;
}
/** A human answered a pending check-in. Emitted on resume, BEFORE the tool
* runs (approve) or the decline result lands. */
export interface CheckInDecisionPayload {
readonly toolName: string;
readonly toolCallId: string;
readonly iteration: number;
/** The request the decision answered — `CheckInRequest.id` of the ask it
* closes, so the record joins each decision to the one request it was for. */
readonly requestId: string;
readonly approved: boolean;
readonly by: string;
readonly note?: string;
/** The registered component that COLLECTED this decision (9.24.0) — the
* `componentId` the ask carried. Absent when the ask was prose-only. The
* decision itself is unchanged: a structured fact, never parsed from the
* words a screen rendered. */
readonly componentId?: string;
}
/**
* One link of a `toolMiddleware` / `messageMiddleware` chain answered.
*
* Its own domain rather than a `permission.check` with a different label:
* a middleware decision is not a permission check, and reporting it as one
* would make every consumer reading that channel believe a checker they
* never configured had fired.
*
* Deliberately carries no `before` / `after` — the values live in the
* committed `middlewareDecisions` ledger, under whatever redaction the run
* configured. An event stream is a fan-out to sinks we do not control, and
* a scrubbed value should not leave the run through it.
*/
export interface MiddlewareDecisionPayload {
readonly middleware: string;
/** Where in the loop this happened — the vocabulary `.act()` is keyed on. */
readonly moment: LoopMoment;
/** The 7.18 spelling of the same fact. Narrow on `moment`. */
readonly at: 'tool' | 'message';
readonly phase?: 'input' | 'output';
readonly toolName?: string;
readonly toolCallId?: string;
readonly iteration: number;
readonly outcome: 'allow' | 'deny' | 'ask';
/** True when this link changed the value the chain carries forward. */
readonly changed: boolean;
/** The transform's `why`, the denial's `reason`, or the ask's `question`. */
readonly why?: string;
/** The registered component that COLLECTED this decision (9.24.0). Present
* only on the resume-side rows of an `ask` that carried one. */
readonly componentId?: string;
}
/**
* The model declared a BASIS for one tool call — `_findings.basis` peeled
* off the call's arguments before anything else read them (9.101.0).
*
* Identities, enums and counts ONLY, by the `MiddlewareDecisionPayload` law
* one screen up: the row itself lives in the committed `findingsLedger`
* key under whatever redaction the run configured, and an event stream is
* a fan-out to sinks we do not control. `malformed` counts the entries
* `splitFindings` dropped from this call's declaration — never coerced,
* never inferred — so a sink can see that the model tried and missed
* without seeing what it wrote.
*
* Fired by `recordFindings`, one per basis row, BEFORE `stream.tool_start`
* for the same `toolCallId`. Never fired on an agent without `.findings()`.
*/
export interface FindingsDeclaredPayload {
readonly toolName: string;
/** The provider's id for the call — what a reader joins to `tool_start`. */
readonly toolCallId: string;
readonly iteration: number;
readonly basis: Basis;
readonly expect?: Expect;
/**
* The call declared a `proposition` (what it tests) — a FLAG, never the
* text, by the same law as `malformed`: the line itself lives on the
* `BasisRow` in the committed key. Absent when none was declared.
*/
readonly hasProposition?: true;
/** Malformed `_findings` entries dropped from this call's declaration. */
readonly malformed?: number;
}
/**
* The model declared a STANDING for one PREVIOUS tool result — `fact`,
* `open`, `ruled-out` or `noise` — on its next tool call or as a top-level
* `_findings.previous` on a JSON answer (9.101.0).
*
* `toolCallId` names the RESULT being stood on, not the call that carried
* the declaration; `declaredOn` says which door the declaration came
* through. `assertionCount` is the number of assertions the row carries
* (always 0 for `noise`); the assertions themselves, `settles`, `line` and
* every other word the model wrote stay in the committed key — the same
* law as `FindingsDeclaredPayload`. `conflictKeys` lists the assertion keys
* whose conflict row THIS write created; a key that already had one is not
* repeated. `unknownId` marks a standing for an id the previous batch did
* not contain — recorded, never resolved.
*
* `agrees` (9.104.0, `.findings({ judge })`) compares this standing with the
* JUDGE's current judgment of the same result and is present exactly when
* a `JudgmentRow` exists for it: the judge files before the model call
* that declares, so this event is the one moment both readings exist. A
* comparison made for the sink, never written to the record, where the two
* sources stay two rows. Absent without a judge, after a failed judgment,
* and for an `unknownId`.
*
* Fired by `recordFindings`, one per standing row, in declaration order.
*/
export interface FindingsStandingPayload {
/** The id of the tool RESULT this standing is about. */
readonly toolCallId: string;
/** Absent when `unknownId` — the batch had no result to name. */
readonly toolName?: string;
/** The DECLARING iteration; the result's own stage is derivable from the
* commit log by `toolCallId`, never guessed here. */
readonly iteration: number;
readonly standing: Standing;
readonly declaredOn: 'tool-call' | 'answer';
readonly assertionCount: number;
/** Assertion keys whose conflict row this write created. */
readonly conflictKeys?: readonly string[];
/** The named id was not in the previous batch. */
readonly unknownId?: true;
/** Whether this standing equals the judge's current one for the result; present only when one exists. */
readonly agrees?: boolean;
}
/**
* A SECOND SOURCE judged one tool result (9.104.0, `.findings({ judge })`):
* the calibrated classifier's standing for the result against the call's
* declared proposition — or the user's question when none was declared —
* with its confidence and cost. The row (`JudgmentRow`) holds the whole
* distribution and the provider's model string in the committed
* `findingsLedger`; this payload carries identities, enums and NUMBERS only,
* never the state the judge read.
*
* No `agrees` here: the judge files BEFORE the model call that could
* declare a standing on this result, so at this event the model's reading
* does not exist yet. The comparison rides `FindingsStandingPayload.agrees`
* instead, the one moment both readings exist. Fired by `recordFindings`,
* one per judgment row, once the result's `toolResults` entry exists and
* before the next model call. Never fired without a configured judge.
*/
export interface FindingsJudgedPayload {
readonly toolCallId: string;
readonly toolName: string;
readonly iteration: number;
readonly against: 'proposition' | 'question';
readonly standing: Standing;
readonly confidence: number;
readonly latencyMs: number;
readonly inputTokens?: number;
readonly outputTokens?: number;
}
/**
* The judge was asked about one tool result and produced no answer
* (9.104.0): the provider's HTTP status when there was one, and the latency
* spent. The error text lives on the `JudgmentErrorRow`; no standing is
* guessed, and the run continues — the judge is advisory.
*/
export interface FindingsJudgeFailedPayload {
readonly toolCallId: string;
readonly toolName: string;
readonly iteration: number;
readonly status?: number;
readonly latencyMs: number;
}
/**
* The model USED a value that came only from results it had itself declared
* `open`, `noise` or `ruled-out` (9.110.0, `.findings()` beside
* `.namesAndNumbersFromEvidence()`): in its answer (`declaredOn: 'answer'`)
* or as an argument of a later tool call (`'tool-call'`, with the call's
* `toolCallId`). Declared standings plus the evidence corpus's provenance —
* nothing inferred, no judge. The row (`ContingentRow`) holds the value and
* every carrier with its standing in the committed `findingsLedger`; this
* payload carries the moment, how many results carried the value, the
* DISTINCT standings among them and the value's length — identities, enums
* and numbers only, never the token. Fired by `recordFindings`, one per
* contingent row: at the answer, after the evidence gate's verdict; at
* dispatch, after the call's basis row and before `stream.tool_start`.
* Never fired unless BOTH doors are armed.
*/
export interface FindingsContingentPayload {
readonly iteration: number;
readonly declaredOn: 'tool-call' | 'answer';
/** The dispatching call, when the value rode its arguments; absent for the answer. */
readonly toolCallId?: string;
/** How many results carried the value — every one judged non-fact. */
readonly carriers: number;
/** The distinct standings among the carriers, in carrier order. */
readonly standings: readonly Standing[];
/** The length of the value as the row holds it. */
readonly valueChars: number;
}
/**
* The inputs layer's verdict on ONE ruled argument of ONE tool call (honesty
* layer 2) — fired once per `argument` row the `sf-inputs` subflow files,
* before the call dispatches. This version files `source: 'default'` (the
* library filled the tool's declared `assume` value, or the model sent that
* same value) and `source: 'model'` (the model sent another value the record
* does not trace). Names, enums and counts only: never the value, a quote or
* the model's proposal — those stay on the committed row, in the tool's own
* argument view — and no `valueChars` when that view hides the argument. A
* host counts "1 assumed" from these events. The standing fold never reads
* them (it reads the committed rows).
*/
export interface FindingsArgumentPayload {
readonly toolCallId: string;
readonly toolName: string;
readonly iteration: number;
/** The conversation turn the row was filed in. */
readonly turn: number;
readonly argument: string;
readonly rule?: 'ask' | 'assume';
readonly period?: true;
readonly source?: ArgumentSource;
readonly asked?: ArgumentAsked;
readonly claimed?: ArgumentClaim;
readonly matched?: 'quote' | 'phrase' | 'spelling' | 'mention';
readonly reading?: true;
readonly earlier?: true;
readonly setAside?: 'open' | 'noise' | 'ruled-out';
readonly argumentsFrom?: 'listed' | 'unlisted';
readonly coincides?: 'person' | 'result' | 'app';
readonly free?: true;
readonly failed?: ArgumentCheckFailed;
readonly malformed?: number;
/** The length of the value as the row holds it — absent when the tool's view hides it. */
readonly valueChars?: number;
}
/**
* The results layer's verdict on ONE call's period (honesty layer 3, step 7b)
* — fired once per `period` row the `sf-results` subflow files at the loop
* head, after the call ran. The tool's name, the call's id, the stamps and the
* verdict word ONLY: never an instant (the declared period stays on the
* committed coverage row). `undeclared` means the tool declares a period
* argument and this result said nothing about what its read covered. A host
* counts "1 read the store did not hold" from these events; the standing fold
* never reads them (it reads the committed rows).
*/
export interface FindingsPeriodPayload {
readonly toolCallId: string;
readonly toolName: string;
readonly iteration: number;
/** The conversation turn the row was filed in. */
readonly turn: number;
readonly verdict: 'covered' | 'partly-held' | 'not-held' | 'unknown' | 'undeclared';
/**
* The time layer's result checks that hold on the row (step T8, under
* `.time()` only) — their names, never a range: `period-differs-from-asked`,
* `period-shifted`, `period-beyond-retention`, `partly-beyond-retention`.
* Absent when none holds, and on every unarmed run.
*/
readonly timeChecks?: readonly ('period-differs-from-asked' | 'period-shifted' | 'period-beyond-retention' | 'partly-beyond-retention')[];
}
/**
* One observe-only prose finding, filed with the answer: a detected claim or
* a unit the bounded grammar could not judge. This is a warning, never an
* enforcement decision or proof that the remaining prose is true.
*
* Only locations, checker codes, phrase-pack identities and native result
* references leave the ledger. No answer text, candidate entity labels,
* tool values or stale-read dates are included. Codes are owned by the
* checker, not duplicated in this low-level event contract.
*/
interface FindingsNegativeClaimLocation {
readonly iteration: number;
/** The conversation turn when stamped by the runtime. */
readonly turn?: number;
/** JSON pointer into the answer; at most 4,096 UTF-16 code units. */
readonly field: string;
readonly span: {
readonly start: number;
readonly end: number;
};
/** At most 128 UTF-16 code units. */
readonly packVersion: string;
/** At most 128 UTF-16 code units. */
readonly phraseId?: string;
/** Some metadata was shortened or omitted; references are not exhaustive. */
readonly truncated?: true;
}
export type FindingsNegativeClaimPayload = FindingsNegativeClaimLocation & ({
readonly kind: 'negative-claim';
readonly claim: 'none' | 'complete';
/** At most 32 checker codes, each at most 128 UTF-16 code units. */
readonly notes: readonly string[];
/** At most 32 native result ids, each at most 256 UTF-16 code units. */
readonly results: readonly string[];
} | {
readonly kind: 'negative-claim-note';
/** The checker's unread-unit code, at most 128 UTF-16 code units. */
readonly code: string;
});
/**
* A classifier picked a tool for one model call (9.105.0, `.toolChoice()`):
* the provider's pick among the OFFERED tools (the merged wire minus the
* always-served doors), its confidence, how many were offered and how many
* were served, whether the served list was narrowed to the top-N, and the
* cost. The row (`ToolChoiceRow`) holds the whole distribution and the
* provider's model string in the committed `toolChoices`; this payload
* carries identities (tool names), enums, numbers and a boolean — never the
* descriptions the classifier read, never the user's message. Fired by
* `recordToolChoice` from the tools slot, BEFORE the model call.
*/
export interface ToolChoicePickedPayload {
readonly iteration: number;
readonly chosen?: string;
readonly confidence: number;
readonly offered: number;
readonly served: number;
readonly narrowed: boolean;
readonly narrowedSkipped?: 'unavailable' | 'too-few' | 'after-miss' | 'wrap-up' | 'evidence-revision';
readonly latencyMs: number;
readonly inputTokens?: number;
readonly outputTokens?: number;
}
/**
* What the model DID on the call the pick was made for (9.105.0): the tools
* it called in order (empty on an answer), whether its first call is the
* classifier's pick (`firstAgrees`, absent when either side is absent) and
* the names it asked for outside a NARROWED served list (`missed`). Fired by
* `recordToolChoice` from `callLLM`, after the reply.
*/
export interface ToolChoiceOutcomePayload {
readonly iteration: number;
readonly called: readonly string[];
readonly firstAgrees?: boolean;
readonly missed?: readonly string[];
}
/**
* The classifier was asked which tool and produced no answer (9.105.0): the
* provider's HTTP status when there was one and the latency spent. The
* error text lives on the `ToolChoiceErrorRow`; the full tool set was served
* (fail open, never fail narrow) and the run continues.
*/
export interface ToolChoiceFailedPayload {
readonly iteration: number;
readonly status?: number;
readonly latencyMs: number;
}
/**
* The declared ontology was served on one model call (9.106.0,
* `.ontology()`): the map's identities (`id`, `version`, the declaration's
* `hash`) and its three counts. The map itself is the run constant
* `AgentState.ontology`; the served text is on the receipt and rebuilt by
* `servedAt`. This payload carries identities and numbers only — never a
* meaning, a coverage sentence or a node name. Fired by `callLLM` at
* request assembly, once per call that served the piece.
*/
export interface OntologyServedPayload {
readonly iteration: number;
readonly id: string;
readonly version: string;
readonly hash: string;
readonly nodes: number;
readonly sources: number;
readonly edges: number;
}
/**
* The answer's standing as data — the SAME projection on
* `agentfootprint.answer.assessed` and on `turn_end.answerAssessment`, built
* by one function (`assessment/compose.ts` · `assessmentDataOf`) from the one
* fold (`assessAnswer`) the answer layer runs at the head of the final
* branch. Names, enums and counts only: the value, its rendering, the reason
* KINDS and the checks that ran — never a value from the answer, a quote, a
* witness pointer or the support's digest (those stay on the committed rows
* `agent.assessment()` folds, with their pointers).
*/
export interface AnswerAssessmentPayload {
/** The value: `known | unrefuted | unknown | not-applicable`. */
readonly assessment: 'known' | 'unrefuted' | 'unknown' | 'not-applicable';
/** The owner's words for it: known · consistent · not sure · ask · not assessed. */
readonly standing: 'known' | 'consistent' | 'not-sure' | 'ask' | 'not-assessed';
/** Every reason that fired, by kind, in the fold's order (the ask first, then by layer). */
readonly reasons: readonly AssessmentReason[];
/** What actually ran this turn, printed as it is — never a fixed list. */
readonly checked: readonly {
readonly layer: HonestyLayer;
readonly check: AssessmentCheck;
readonly ran: number;
readonly of: number;
}[];
}
/**
* The answer layer assessed the turn's answer (honesty layer 4,
* `.answerLayer()`): ONE event per answer, fired at the head of the final
* branch — after the Route decider's checks filed their verdicts and before
* the turn's payload is captured. It carries the standing as data
* ({@link AnswerAssessmentPayload}) with the conversation turn and the
* answer's iteration. The fold it reports reads committed rows only, so the
* same recording read afterwards with `assessAnswer` folds the same value
* (without app declarations — `assessAnswer(record, declarations)` with
* `rowsAt` can say more than the run did).
*
* Where it reaches, and what it is about:
* - the agent's own listeners only — a composition that mounts the agent
* bridges the agent domain, not `answer.*`, so read
* `turn_end.answerAssessment` there;
* - the answer the run COMPOSED: under the evidence gate's `rails` posture a
* refused answer is assessed too, and then the run throws (no answer is
* delivered, and `agent.assessment()` returns `undefined`).
*/
export interface AnswerAssessedPayload extends AnswerAssessmentPayload {
/** `AgentState.turnNumber` — the conversation turn. */
readonly turn: number;
/** The iteration whose answer was assessed. */
readonly iteration: number;
}
export interface MemoryStrategyAppliedPayload {
readonly strategyId: string;
readonly strategyKind: 'sliding-window' | 'summarizing' | 'semantic' | 'fact-extraction' | 'hybrid';
readonly reason: string;
readonly scoreEvidence?: Readonly<Record<string, unknown>>;
readonly inputMemoryCount: number;
readonly outputMemoryCount: number;
readonly droppedIds: readonly string[];
readonly addedIds: readonly string[];
}
/**
* One piece of stored content reached the prompt.
*
* Emitted once per admitted chunk by the memory formatter (8.8.0 — the
* payload was declared in 2.x and nothing emitted it until then).
* `memoryId` is the STORE ENTRY's id, which for an indexed corpus is the
* chunk id you can cite. Which retriever produced it is
* `meta.runtimeStageId` (`sf-memory-read-<id>/format-default#N`) — the
* house rule is that events correlate by runtime stage id, not by
* duplicating the owner into every payload.
*/
export interface MemoryAttachedPayload {
readonly memoryId: string;
readonly contentSummary: string;
readonly score?: number;
readonly rank?: number;
readonly source: 'store' | 'auto-extract' | 'manual';
readonly retriever?: 'pinecone' | 'weaviate' | 'qdrant' | 'chroma' | 'custom';
}
/** One candidate a retrieval considered, admitted or not. */
export interface RetrievedCandidatePayload {
readonly id: string;
readonly score: number;
readonly rank: number;
readonly admitted: boolean;
/**
* Why it did not reach the prompt. `'over-char-budget'` (8.19.0) means it
* cleared the score floor and the count rule, and the retriever's
* `maxChars` budget was already spent by better-ranked passages.
*/
readonly reason?: 'below-threshold' | 'over-budget' | 'over-max-entries' | 'over-char-budget';
readonly docUri?: string;
readonly page?: number;
readonly heading?: string;
}
/**
* A retrieval happened — here is everything it considered (8.8.0).
*
* The event that makes "why did the agent NOT read that passage"
* answerable. Until 8.8.0 the quality floor was applied inside the store,
* so a rejected candidate never came back and a retrieval that injected
* nothing left no trace of what it nearly injected.
*
* `candidates` absent means the store could not tell us (see
* `candidatesOmittedReason`) — it never means there were none. That case
* is `candidates: []` with `consideredCount: 0` and `corpusEmpty: true`.
*/
export interface MemoryRetrievedPayload {
/**
* Which rule ruled — the `RetrievalStrategy.name` that produced the verdicts
* below, e.g. `'top-k'`. The port promises its name "appears in the
* recording"; this is where. Without it a shipped `topK` and a consumer's own
* re-ranker leave records that are identical except for the numbers they
* disagree about.
*/
readonly strategy: string;
/** Stable hash of the query text. The text itself is already in the recording once. */
readonly queryHash: string;
/** How many chunks the retriever was willing to admit. */
readonly k: number;
/** The quality floor, when the retriever set one. */
readonly threshold?: number;
/**
* The character budget the admitted passages were spent against (8.19.0),
* when the retriever set one. A count bound (`k`) is not a size bound;
* this is the size bound, and the candidates it dropped carry
* `reason: 'over-char-budget'`.
*/
readonly maxChars?: number;
/**
* Passage characters the admitted set spends. Present exactly when
* `maxChars` is. Passage text only — the `<source …>` wrapper and the
* block header are added later by the formatter.
*/
readonly charsUsed?: number;
readonly embedderId?: string;
/** Length of the query vector. */
readonly dimensions?: number;
readonly consideredCount: number;
readonly admittedCount: number;
readonly rejectedCount: number;
readonly candidates?: readonly RetrievedCandidatePayload[];
/**
* Whether `candidates` is every candidate that existed, or only as far
* as the requested pool reached. `false` never weakens the ADMITTED
* set — only the rejected list is then a sample.
*/
readonly candidatesComplete: boolean;
readonly candidatesOmittedReason?: string;
/**
* The namespace held nothing at all. Almost always means the corpus was
* indexed under a different identity than the one being queried.
*/
readonly corpusEmpty: boolean;
/** The namespace that was searched. */
readonly namespace?: string;
}
export interface MemoryDetachedPayload {
readonly memoryId: string;
readonly reason: 'stale' | 'budget' | 'score_low' | 'policy';
}
export interface MemoryWrittenPayload {
readonly memoryId: string;
readonly contentSummary: string;
readonly source: 'auto' | 'manual';
readonly actor?: string;
}
export interface ToolsOfferedPayload {
readonly availableIds: readonly string[];
readonly withheldIds: readonly string[];
readonly withheldReasons: Readonly<Record<string, 'permission' | 'skill_inactive' | 'gated' | 'cost_guard'>>;
readonly reason: string;
}
export interface ToolsActivatedPayload {
readonly toolId: string;
readonly reason: 'skill_activated' | 'autoActivate' | 'permission_granted';
readonly source?: string;
}
export interface ToolsDeactivatedPayload {
readonly toolId: string;
readonly reason: 'skill_deactivated' | 'permission_revoked';
}
/**
* Emitted at the start of a `ToolProvider.list(ctx)` call inside the
* Discover stage. Pairs with `tools.discovery_completed` (success) or
* `tools.discovery_failed` (error). Use the pair to measure async-
* provider latency per iteration without joining stages by hand.
*/
export interface ToolsDiscoveryStartedPayload {
readonly providerId: string | undefined;
readonly iteration: number;
}
/**
* Emitted when `ToolProvider.list(ctx)` resolves successfully. The
* `durationMs` is the wall-clock between `tools.discovery_started` and
* resolution; `toolCount` is the size of the returned tool list. For
* sync providers `durationMs` is ~0; for async hub-backed providers
* this is your observability hook for catalog-fetch latency.
*/
export interface ToolsDiscoveryCompletedPayload {
readonly providerId: string | undefined;
readonly iteration: number;
readonly durationMs: number;
readonly toolCount: number;
}
/**
* Emitted when a custom `ToolProvider.list(ctx)` throws or rejects.
* The iteration is aborted; a configured `reliability` rule decides
* whether to retry, fall back, or fail-fast. `providerId` lets
* consumers route alerts to the right hub adapter (rube / mcp /
* custom-discovery). `durationMs` measures how long the failed call
* spent before throwing, so timeouts vs immediate rejections are
* distinguishable.
*/
export interface ToolsDiscoveryFailedPayload {
readonly providerId: string | undefined;
readonly error: string;
readonly errorName: string;
readonly iteration: number;
readonly durationMs: number;
}
/**
* The parties that can put a tool name on the wire or answer to one — the
* vocabulary `tools.shadowed` and `tools.claim_swallowed` name them in (9.92.0).
*
* • `registry` — a static `.tool()` registration;
* • `provider` — a `ToolProvider` (`.toolProvider()`, MCP through `staticTools`,
* `.selfExplain()`'s trace pack — its id says which);
* • `skill` — a skill's `tools:[]`, always-visible or scoped;
* • `framework` — an auto-attach the framework owns: `read_skill`, `present`,
* `skip_step`.
*/
export type ToolNameChannel = 'provider' | 'registry' | 'skill' | 'framework';
/**
* Emitted, per iteration, for a tool name that two parties BOTH put forward
* for the wire and only one could win (8.7.0; re-subjected 9.92.0).
*
* Until 9.92.0 the two halves of a call could come from different parties:
* the tools slot merges `[static, provider, skill, step]` first-wins while the
* dispatcher resolved a build-time map first, so the model read one contract
* and called another implementation. Since 9.92.0 dispatch FOLLOWS THE OFFER —
* the party whose contract is on the wire is the party that answers — so
* `schemaFrom` and `dispatchTo` name the same party, and both are the wire's.
* The event still fires because the collision is still real: the losing
* party's contract was hidden behind the winner's on this iteration, and a
* rename is still the fix. The loser is named by the companion
* `tools.claim_swallowed`, which fires for every party that lost a name whether
* or not its contract competed this iteration.
*
* Not an error event: the run continues and a tool really executes. It fires
* once per contested name PER ITERATION, because a provider list is resolved
* per iteration and a collision can begin mid-run.
*
* Carries names only — never args, never results, never a description body.
*/
export interface ToolsShadowedPayload {
/** The contested tool name. */
readonly toolName: string;
readonly iteration: number;
/** Which party's CONTRACT the model was shown — the wire's party. */
readonly schemaFrom: ToolNameChannel;
/** That party's id — the `ToolProvider.id` or the skill id, when it has one. */
readonly schemaFromId?: string;
/** Which party's IMPLEMENTATION answers a call to this name — since 9.92.0
* always the wire's party, so it equals `schemaFrom`. Kept because a
* consumer that branched on it keeps working and the sentence stays true. */
readonly dispatchTo: ToolNameChannel;
/** That party's id, when it has one. */
readonly dispatchToId?: string;
}
/**
* Emitted, per iteration, for a party whose claim to a tool name is DEAD on
* that iteration: another party holds the name on the wire, and since dispatch
* follows the offer the loser's implementation cannot be reached by any call
* (9.92.0 — the `claim-swallowed` family of the divergence walk).
*
* It fires whether or not the loser's contract competed for the wire this
* iteration — a provider tool whose name a static `.tool()` already holds, a
* skill tool named after a framework auto-attach, a never-activated scoped
* skill's tool whose name a provider serves. Silence about a dead claim is the
* defect this event exists to end: the party registered a tool the model can
* never reach, and the trace said nothing.
*
* Carries names only — never args, never results, never a description body.
*/
export interface ToolsClaimSwallowedPayload {
/** The contested tool name. */
readonly toolName: string;
readonly iteration: number;
/** The party whose claim is dead this iteration. */
readonly lostBy: ToolNameChannel;
/** That party's id — the `ToolProvider.id` or the skill id, when it has one. */
readonly lostById?: string;
/** The party holding the name on the wire — and therefore answering it. */
readonly wonBy: ToolNameChannel;
/** That party's id, when it has one. */
readonly wonById?: string;
}
/**
* Emitted when the dispatcher hands a call to a tool whose name was NOT on
* this iteration's wire (9.92.0).
*
* The capability law keeps a registry-routed tool dispatchable after a
* narrowing takes it off the offer — a later step's tool, a parked map's tool,
* a scoped skill's tool named from a restored transcript — and that is
* deliberate. What was missing was the record: a call answered by a tool the
* model was not shown on THIS call looked exactly like one that was. This is
* that record, once per such call. It fires ONLY when the answering party is
* the one the model last read this name under (or the name's only claimant
* when it was never served); a name whose last-served contract belonged to a
* party that can no longer answer is refused as a recorded tool result
* instead (`toolCalls.ts` · `notServedResult`), never handed to somebody else.
*
* Carries names only — never args, never results.
*/
export interface ToolsAnsweredOffWirePayload {
readonly toolName: string;
readonly toolCallId: string;
readonly iteration: number;
/** The party whose implementation answered. */
readonly answeredBy: ToolNameChannel;
/** That party's id — the skill id or the `ToolProvider.id`, when it has one. */
readonly answeredById?: string;
}
/** Shared shape of the four `tools.session_*` payloads. */
interface ToolSessionPayloadBase {
/** The tool that registered the cleanup. */
readonly tool: string;
/** Which scope it asked for — how long it expected to live. */
readonly scope: 'call' | 'run' | 'session' | 'shutdown';
/** Digest of the isolation key. SHA-256 (12 hex chars) where `node:crypto`
* resolves, FNV-1a in a browser bundle. NEVER the key. */
readonly keyHash: string;
/** The adapter holding the resource — `CodeRunner.id`, say. */
readonly runnerId?: string;
/** One fact the tool chose to state about what was opened (the language, the
* browser profile). Never user data. */
readonly label?: string;
}
/** A tool opened something and registered its cleanup. */
export type ToolsSessionStartedPayload = ToolSessionPayloadBase;
/**
* A later call reused the session already held under this key.
*
* This is the payoff being measured: `calls` is how many dispatches have shared
* one start-up. It is also the liveness signal the idle sweep and the LRU bound
* read, which is why a tool re-registers on every execute rather than only once.
*/
export interface ToolsSessionReusedPayload extends ToolSessionPayloadBase {
/** How many calls have now shared this session, including this one. */
readonly calls: number;
}
/** The cleanup ran. `reason` says which firing site ran it. */
export interface ToolsSessionClosedPayload extends ToolSessionPayloadBase {
readonly reason: 'call-end' | 'run-end' | 'session-end' | 'shutdown' | 'idle' | 'evicted';
/** Wall-clock from registration to close. */
readonly durationMs: number;
}
/**
* The cleanup threw, or outran `toolTeardownTimeoutMs`.
*
* Teardown never throws into the run — but it is never SILENT either, and this
* event is the difference. A vendor `Stop` that fails leaves a live sandbox
* somebody is still paying for; the run has no way to say so, so this does.
* `errorClass` mirrors `credential.failed`'s 9.4.0 addition: routable without
* parsing prose (`'ToolTeardownTimeoutError'` for the budget case).
*/
export interface ToolsSessionCloseFailedPayload extends ToolSessionPayloadBase {
readonly reason: 'call-end' | 'run-end' | 'session-end' | 'shutdown' | 'idle' | 'evicted';
readonly durationMs: number;
readonly error: string;
readonly errorClass?: string;
}
/**
* Emitted when LLM-produced tool args fail validation against the tool's
* declared `inputSchema` (backlog #9). Fires for BOTH modes that validate:
* `enforced: true` means the call was rejected before dispatch and the
* model received a structured retry message as the tool result;
* `enforced: false` ('warn' mode) means the tool executed anyway.
* `issues` name paths, expectations, and received TYPES — never the
* supplied values (they can carry PII / injection payloads).
*/
export interface ValidationArgsInvalidPayload {
readonly toolName: string;
readonly toolCallId: string;
readonly iteration: number;
readonly issues: ReadonlyArray<{
readonly path: string;
readonly expected: string;
readonly got: string;
/**
* A capped excerpt of the offending STRING — present on string-shape
* issues (`pattern` / `minLength` / `maxLength`) only, where the
* complaint IS the value's shape. Structural issues (type, enum,
* required, additionalProperties) still carry no value at all.
*/
readonly value?: string;
/** The parameter's own schema `description`, when it declares one. */
readonly hint?: string;
}>;
readonly enforced: boolean;
}
/**
* The turn-start routing verdict (SG-C) — one per turn on skill-graph agents
* whose graph ran the cascade (`classify` configured, or
* `continuity: 'conversation'` with something to decide). Fired by the
* RouteTurn stage DURING traversal (collect-during-traversal; never rebuilt),
* BEFORE iteration 1, so it precedes every `context.evaluated` of its turn.
*
* The LOSERS are on the record: `scores` ranks every candidate the tier-2
* scorer judged (role-hidden skills are excluded before scoring — a hidden
* capability is never named, not even to observers of this run); `offered`
* names the menu the model was handed; `policy` stamps the exact thresholds
* that judged the hop, so no observer has to guess which numbers decided.
*/
export interface SkillTurnRoutedPayload {
/** The tier that decided the turn's start:
* `'entry'` — a tier-1 rule (the recorded vocabulary for rule-won starts);
* `'intent'` — the tier-2 scorer was decisive;
* `'continuity'` — the inherited cursor held (incumbent won / near-tie /
* sticky default / the decider picked STAY — `decider` says which);
* `'menu'` — a menu was put in-band (near-tie or unmatched);
* `'decider'` — the configured tier-3 decider resolved the menu
* out-of-band (9.19.0; `decider` carries the pick);
* `'none'` — the cascade decided nothing the loop may act on (a rails
* menu — the offer is still recorded here — or a dropped resume). */
readonly by: 'entry' | 'intent' | 'continuity' | 'menu' | 'decider' | 'none';
/** The inherited cursor (continuity), when one existed. */
readonly from?: string;
/** Where the turn starts. Absent = menu pending / none. */
readonly to?: string;
/** `IntentScorer.name` / entry-scorer name, when tier 2 ran. */
readonly scorer?: string;
/** EVERY candidate, ranked best-first — the losers, with the numbers that
* lost. `score` is raw (strategy-specific); `relevance` is the
* full-softmax share. */
readonly scores?: ReadonlyArray<{
readonly id: string;
readonly score: number;
readonly relevance: number;
}>;
/** The top-vs-2nd PAIRWISE gap actually judged (count-independent — see
* routingPolicy.ts; NOT the difference of two `relevance` shares). */
readonly runnerUp?: {
readonly id: string;
readonly gap: number;
};
/** Whether tier 2 cleared the margin + floor. */
readonly decisive?: boolean;
/** WHAT the message said that won tier 1 (9.28.0) — present only on a
* `by: 'entry'` verdict a DATA matcher (`match:` — RegExp / `{ keywords }` /
* `{ all }`) decided. `text` is the matched substring of the USER message
* (never tool or system text), whitespace-collapsed and bounded to 80
* characters, ellipsis included; `keyword` names WHICH declared keyword hit
* (keywords arm only). Absent for `when` predicates — opaque code the
* library cannot quote — and for intent/scorer verdicts, whose evidence is
* the `scores` above. */
readonly witness?: {
readonly text: string;
readonly keyword?: string;
};
/** The tier-3 menu (near-tie cluster, or every entry when unmatched). */
readonly offered?: readonly string[];
/** STAY was a first-class option in that menu (mid-conversation). */
readonly stayOffered?: boolean;
/** The thresholds that judged this turn, verbatim. `floor` is the EFFECTIVE
* floor (`policy.floor ?? scorer.floor`); absent = no floor, so
* `unmatched` was unreachable and near-tie governed. */
readonly policy: {
readonly nearTieMargin: number;
readonly menuSize: number;
readonly floor?: number;
};
/** Prior conversational turns the scorer saw (its declared window). */
readonly window?: number;
/** A continuity cursor that could not be honored — the stored id is not a
* node of the currently mounted graph (a deploy changed it). The turn
* started cold instead, and says so. */
readonly droppedResume?: {
readonly id: string;
readonly reason: 'unknown-skill';
};
/**
* The tier-3 DECIDER's involvement (9.19.0) — present whenever one was
* configured AND a menu verdict reached it. `picked` is the constrained
* pick that resolved the menu (an offered id, or `'stay'`); ABSENT when
* the decider declined (`'none'` / parse failure / a throw) and the
* in-band envelope stood. Recorded on every outcome so the record always
* says the decider was consulted — a resolver that ran and said nothing
* is still a fact.
*/
readonly decider?: {
readonly provider: string;
readonly model?: string;
readonly picked?: string;
};
}
export interface SkillActivatedPayload {
readonly skillId: string;
readonly reason: 'autoActivate' | 'read_skill_result' | 'manual';
readonly injectedTools?: readonly string[];
readonly injectedSystemPromptChars?: number;
}
export interface SkillDeactivatedPayload {
readonly skillId: string;
readonly reason: string;
}
/**
* Fired by the skill-graph read_skill GATE when the model tries to `read_skill`
* a skill that is NOT reachable from the current cursor. The jump is rejected
* (cursor/activations unchanged); the model gets a synthetic re-prompt naming
* `allowed`. Powers the lens / Why-panel "it tried to leave the graph here".
*/
/**
* Fired when a `read_skill` pick the gate ACCEPTED did not end up active — the
* model was told "Skill 'X' activated for the next iteration" and something else
* won. There is exactly one way to get here: a declared route edge fired on the
* same turn (the model emitted a domain tool AND `read_skill` in one message, and
* the tool's result matched the edge). The author's declared edge wins by design
* (`D1 > D2`), so the pick is superseded — and reported here rather than silently
* dropped. The next iteration shows the model where it actually is.
*/
export interface SkillRerouteSupersededPayload {
/** The skill the model picked with `read_skill` (accepted, then superseded). */
readonly volunteeredId: string;
/** The skill the declared edge routed to instead (the cursor that won). */
readonly wonId?: string;
/** The cursor the hop started from. */
readonly fromSkillId?: string;
/** The ReAct iteration whose evaluation dropped the pick. */
readonly iteration: number;
/**
* WHO volunteered the superseded move (9.19.0). Absent = the model's
* `read_skill` pick, exactly as this event has always meant;
* `'tool-proposal'` = an accepted `propose-transition` effect that a
* same-batch declared edge outran (D1 wins over the proposal exactly as
* it wins over a pick — the author's determinism is never overridden).
*/
readonly source?: 'tool-proposal';
}
/**
* The DECLARED skill map, as the author drew it (9.50.0) — fired ONCE per run,
* right after `agent.run_configured`, for every agent whose `.skillGraph()`
* mount could state its map. Nodes and edges come from the BUILT graph
* (`graph.nodes` / `graph.edges`), never inferred from runtime hops — so a
* recording carries the complete authored topology, not the lower bound that
* per-hop `routing[]` provenance names (an edge appears there only once it
* FIRES). A consumer drawing the graph from a recording no longer has to say
* "partial" or ask the caller to pass the built graph in.
*
* ABSENT (no event) when no graph is mounted, or when a structurally-typed
* graph carries no `nodes` — the map is then honestly not on the record,
* never guessed.
*/
export interface SkillGraphDeclaredPayload {
/** The drawn nodes: `kind: 'skill'` boxes, plus `'predicate'` diamonds for a
* decision `tree()`. `description` is the skill's catalog description,
* verbatim (the text the model reads) — absent when the skill declares
* none. `label` is the drawn caption (predicate nodes). */
readonly nodes: ReadonlyArray<{
readonly id: string;
/** `'skill' | 'predicate'` (reported verbatim from the graph). */
readonly kind: string;
readonly description?: string;
readonly label?: string;
/** The skill's declared plain name for a person (`defineSkill({ title })`)
* — "array estate report" for `array-inventory`. Absent when the skill
* declares none (the label a graph draws for a skill node is its id, so
* a reader wanting a plain name reads this, never `label`). The model
* never reads it. */
readonly title?: string;
}>;
/** The author's edges, verbatim. `from: null` is the synthetic START (an
* entry edge — the lens's declared-edge consumers filter on
* `from !== null`). `kind` is the declared `SkillEdgeKind`
* (`'entry' | 'predicate' | 'on-tool-return' | 'on-tool-status' | 'guard'
* | 'model'`). `guard` (9.51.0) is the edge's declared DATA guard,
* verbatim — conditions in declaration order, all ANDed, the operator
* vocabulary mirroring footprintjs's `WhereFilter` — so a recording's
* SkillMap shows its guard conditions without anyone re-reading source. */
readonly edges: ReadonlyArray<{
readonly from: string | null;
readonly to: string;
readonly kind: string;
readonly label?: string;
readonly guard?: {
readonly conditions: ReadonlyArray<{
readonly key: string;
readonly op: string;
readonly value: SkillGuardValueRecord;
}>;
};
}>;
}
export interface SkillRejectedPayload {
/** The skill id the model requested via `read_skill`. */
readonly requestedId: string;
/** The cursor it was at (undefined = cold start, before any entry resolved). */
readonly currentSkillId?: string;
/**
* What the model was TOLD it could reach — role-filtered (9.86.0).
*
* No longer the set the call was bounded to: admission still reads the
* graph's raw set, and this is the SPEAKING set, so a skill the caller's
* `PermissionChecker` hides never appears here. Empty is therefore two
* different facts — nothing was reachable, or nothing reachable may be named
* — and a reader that needs them apart wants the run's graph, not this row.
*/
readonly allowed: readonly string[];
/** The ReAct iteration the rejection fired on. */
readonly iteration: number;
/** Present when a POSTURE, not reachability, refused (SG-C `strictness`):
* `'guard'` — the pick was reachable but off the outstanding menu (or no
* menu was outstanding); `'rails'` — routing picks are refused outright
* (rules/scorer route turn starts; declared routes handle transitions).
* Absent = today's reachability refusal, unchanged. */
readonly posture?: 'guard' | 'rails';
/** WHICH ARM of the gate answered (9.84.0) — optional, so every consumer
* written against the older shape keeps working unchanged:
*
* • `'self-call'` — `requestedId === currentSkillId`. The model asked for
* the skill it is already in; the cursor did not move because there was
* nowhere to move it, and the tool result is an acknowledgement naming
* the tools it can call, not a decline. Consumers that count refusals as
* routing failures should read this one as a NAMING failure instead.
* • `'unreachable'` — a genuine hop the cursor cannot make.
* • `'posture'` — a reachable hop a declared `strictness` declined; pairs
* with {@link SkillRejectedPayload.posture}, which names which one.
*
* Before this, telling a self-call from an unreachable hop meant comparing
* `requestedId` against `currentSkillId` and knowing why that mattered. */
readonly reason?: 'self-call' | 'unreachable' | 'posture';
}
/**
* Fired when two or more tool results of ONE parallel batch matched skill-graph
* edges to DIFFERENT targets (9.16.0). The first match in call order wins the
* cursor move; every later result matching another target is suppressed — and
* reported here so the record explains the hop the run did not take. Before
* 9.16.0 there was no conflict to report because there was no batch: only the
* LAST call of a batch was consulted, and the earlier calls' routing
* implications were silently dropped.
*
* Not fired for same-target matches (they all asked for the move that
* happened), nor for batches where only one result matched. Distinct from
* `skill.reroute_superseded`, which reports a declared edge outranking a
* `read_skill` pick — this one is edge-vs-edge, inside one batch.
*/
export interface SkillRouteConflictPayload {
/** The ReAct iteration whose evaluation resolved the batch. */
readonly iteration: number;
/** The cursor the winning hop started from. */
readonly fromSkillId?: string;
/** The call-order-first match — the one that moved the cursor. */
readonly winner: {
/** The provider's tool_use id for the winning call, when known. */
readonly toolCallId?: string;
readonly toolName: string;
/** The skill the cursor moved to. */
readonly target: string;
};
/** Later matches to other targets, in call order — the suppressed hops. */
readonly losers: ReadonlyArray<{
readonly toolCallId?: string;
readonly toolName: string;
/** The skill this result would have routed to. */
readonly target: string;
}>;
/**
* WHAT conflicted (9.19.0). Absent = declared route edges, exactly as
* this event has always meant; `'tool-proposal'` = two or more accepted
* `propose-transition` effects of one batch named different targets —
* first accepted in call order wins, the rest are suppressed and
* reported here under the same law.
*/
readonly source?: 'tool-proposal';
}
/**
* A declared step completed: its tool returned (non-error) while it was
* current (9.18.0). Fired at the tool-return boundary, in batch call order —
* two adjacent steps naming the same tool advance twice in one batch, each
* on the record. A skip is NOT a completion: a `skip_step` that moved the
* pointer fires `step_skipped { policy: 'advance' }` alone.
*/
export interface SkillStepAdvancedPayload {
readonly skillId: string;
/** The step that just completed (or was skipped past). */
readonly step: {
readonly index: number;
readonly total: number;
readonly tool: string;
readonly note: string;
};
readonly iteration: number;
/** The provider's tool_use id for the completing call, when known. */
readonly toolCallId?: string;
/** Present on the LAST step: the procedure finished. */
readonly completed?: true;
}
/**
* The model declined a step, with its reason — the integrity condition, not
* polish (9.18.0). The framework moved on or held per the skill's declared
* `onSkip`; both facts are here. An empty reason never gets this far: the
* tool-calls stage answers it with a teaching result and no event.
*/
export interface SkillStepSkippedPayload {
readonly skillId: string;
readonly step: SkillStepAdvancedPayload['step'];
/** The model's own words. */
readonly reason: string;
readonly policy: 'advance' | 'hold';
readonly iteration: number;
readonly toolCallId?: string;
}
/**
* The turn is ending with steps unrun (9.18.0). `'nudged'` = the one
* teaching re-ask went back (at most once per turn); `'accepted'` = the
* model stopped again and the framework honored it — never a forced
* continue; `'cut-short'` = a limit (max-iterations / cost-budget) ended
* the turn, and a nudge would have spent an iteration the limit refused.
*/
export interface SkillStepsUnfinishedPayload {
readonly skillId: string;
readonly remaining: ReadonlyArray<{
readonly index: number;
readonly tool: string;
readonly note: string;
}>;
readonly total: number;
readonly action: 'nudged' | 'accepted' | 'cut-short';
readonly iteration: number;
}
/**
* The turn's refusal loop crossed the declared threshold and the rest of the
* turn runs on the escalation brain (9.19.0). Fired ONCE per turn, at the
* flip, from the gate that counted the refusals — escalation is always on
* recorded evidence (`skill.rejected` — reachability, posture or a self-call,
* all three refusal arms), never on
* vibes. De-escalation is the next turn's seed; no event marks it.
*/
export interface SkillEscalatedPayload {
/** The ReAct iteration whose refusal tripped the threshold. */
readonly iteration: number;
/** The declared threshold (`escalation.afterRefusals`). */
readonly afterRefusals: number;
/** The observed refusal count that tripped it (== afterRefusals at the
* flip; the counter keeps counting but the event fires once). */
readonly refusals: number;
/** The brain that WAS serving — resolved by the same precedence chain
* callLLM applies (skill brain > configured > default). */
readonly from: {
readonly provider: string;
readonly model: string;
};
/** The declared escalation brain. `model` absent = it inherits down the
* chain (same-provider escalations only; enforced at build). */
readonly to: {
readonly provider: string;
readonly model?: string;
};
}
/**
* One typed tool effect was judged (9.19.0) — the effects channel's own
* record, fired in batch call order from the stage that judged it.
*
* • `'accepted'` — a `propose-transition` passed the graph's reachability
* law (the cursor moves at the next evaluation unless a same-batch
* declared edge wins — that suppression is `skill.reroute_superseded
* { source: 'tool-proposal' }`), or a `require-instruction` named a
* registered instruction and its lease was granted.
* • `'refused'` — the teaching refusal: unreachable target, no graph to
* route, unknown instruction id, a body whose declared channel cannot
* be pushed, or a malformed effect. `refusalReason` says which.
* • `'superseded'` — an accepted transition proposal lost the batch to an
* EARLIER accepted proposal naming a different target
* (`skill.route_conflict { source: 'tool-proposal' }` reports the same
* batch once, winner + losers).
*
* No effects = this event never fires (zero-cost-when-unused).
*/
export interface ToolEffectPayload {
readonly kind: 'propose-transition' | 'require-instruction';
readonly outcome: 'accepted' | 'refused' | 'superseded';
/**
* The accepted `propose-transition` named the skill the cursor was ALREADY
* in — a STAY, accepted as a no-op (9.86.0).
*
* Nothing moved and nothing activated, because the run is already in the
* state the proposal asked for; no `pendingToolTransition` was written and
* the tool's result carries no refusal suffix. Before this the reachable set
* — which filters the cursor out of its own successor set, correctly, for a
* MOVE — made a stay look unreachable and the proposal was refused.
*
* An ADDITIVE optional field rather than a fourth `outcome`: a consumer
* switching exhaustively over `outcome` must keep compiling. Read it to tell
* a no-op stay from a hop that will actually move the cursor.
*
* A stay COMPETES for the batch's one transition slot like any other
* accepted proposal (9.86.1): judged first, it holds the slot, and a later
* sibling proposing a hop is `'superseded'` with the batch's
* `skill.route_conflict` naming the stay as the winner; judged after an
* accepted hop, the stay is the one superseded. 9.86.0 let a stay through
* without claiming the slot, so two `'accepted'` events could land in one
* batch while the cursor moved.
*/
readonly stay?: true;
/** The tool whose result carried the effect. */
readonly toolName: string;
readonly toolCallId?: string;
readonly iteration: number;
/** propose-transition: the proposed cursor target. */
readonly targetSkillId?: string;
/** propose-transition: the effect's own declared reason. */
readonly reason?: string;
/** require-instruction: the registered injection id it pushes. */
readonly instructionId?: string;
/** require-instruction: the granted (or asked-for) lease. */
readonly deliveryLease?: InstructionDeliveryLease;
/** outcome `'refused'`: the teaching sentence, verbatim. */
readonly refusalReason?: string;
/** outcome `'superseded'`: what outran it. */
readonly supersededBy?: 'earlier-proposal';
}
/**
* A tool's result was refused for exceeding the tool's own declared
* `resultCeiling` (9.20.0) — the record of the size the content channels never
* carry. The model read a teaching refusal ("No data was returned", plus how
* to narrow); the oversized payload entered NO channel — not history, not
* `stream.tool_end`, not this event. The delivered result carries status
* `'invalid'`, so `onToolStatus` edges can route the overflow.
*
* No `resultCeiling` on the tool = this event never fires
* (zero-cost-when-unused).
*/
export interface ToolResultRefusedPayload {
readonly toolName: string;
readonly toolCallId: string;
readonly iteration: number;
/** The stringified result's TRUE length — the fact the refusal replaced. */
readonly sizeChars: number;
/** The tool's declared ceiling it exceeded. */
readonly maxChars: number;
/** The declared parameter names the refusal suggested narrowing by. */
readonly narrowBy?: readonly string[];
/** When the refused result was an effects envelope: the status the tool
* DECLARED before delivery was refused (its declared effects were still
* judged — a proposed transition does not die with an oversized payload). */
readonly declaredStatus?: ToolResultStatus;
}
/**
* A tool returned exactly what it returned before, for exactly the same
* arguments (9.26.0) — and the model was told so.
*
* A NOTE was appended to that result; the call ran, the result is unchanged
* beside the note, and nothing was refused. Payloads never ride this event:
* `argsFingerprint` and `resultFingerprint` are short non-cryptographic
* digests, which answer "is this the same?" and nothing else. See
* `core/agent/repeatedCall.ts` for why the fingerprints and not the values.
*/
export interface ToolRepeatedCallPayload {
readonly toolName: string;
readonly toolCallId: string;
readonly iteration: number;
/** How many times this exact call+result has landed this turn, including
* this one. The note fires on the threshold landing only. */
readonly occurrences: number;
/** Digest of the arguments — never the arguments. */
readonly argsFingerprint: string;
/** Digest of the result — never the result. */
readonly resultFingerprint: string;
/**
* Which fingerprint decided the match, when it was not the usual pair
* (9.62.0). `'arguments'` means this landing matched on the tool's
* arguments ALONE — the tool declared `repeatedWhen: 'arguments'`
* (`tools/tools.ts`), so `resultFingerprint` above is still a true digest
* of what came back but played no part in deciding this was a repeat.
*
* Additive and default-omitted: every event this library emitted before
* 9.62.0, and every event for a tool that has not opted in, carries no
* `mode` key at all — not `mode: undefined` written out, the key simply
* absent — so an existing payload-key-set assertion anywhere does not see
* a new field appear under it.
*/
readonly mode?: 'arguments';
}
/**
* A tool looked and found nothing, and said so with `absent(…)`.
*
* The event exists because the RECORD is where "we looked here and there was
* nothing" has to survive: read off the answer alone, a clean run and a run
* that searched an empty collector look identical. Carries the coverage the
* tool declared — never the arguments the model passed, which are the model's
* own words about what it wanted, not the tool's about what it covered.
*/
export interface ToolAbsentPayload {
readonly toolName: string;
readonly toolCallId: string;
readonly iteration: number;
/** What the tool says it was looking for (its own prose). */
readonly lookedFor?: string;
/** Ground the search DID cover. Non-empty by construction — `absent()`
* refuses a declaration that names no coverage. */
readonly checked: readonly CoverageItemPayload[];
/** Ground this search did not reach — the absence proves nothing there. */
readonly notChecked?: readonly CoverageItemPayload[];
/** Ground no call to this tool can reach. No retry changes it. */
readonly cannotCover?: readonly CoverageItemPayload[];
/**
* Where the tool said to go instead — its `tryInstead` sentence, trimmed as
* `absent()` mints it (9.113.0; an envelope minted elsewhere may have
* handed the model a padded copy). Prose for the model: never parse a tool
* name out of it (a name found inside prose is an inference); read
* {@link ToolAbsentPayload.tryInsteadTool}.
*
* Before 9.113.0 the suggestion was never on this event: it rode only
* inside the raw tool result (`agentfootprint.stream.tool_end`'s `result`,
* the tool turn in history), where a reader had to recognize the envelope
* again to find it.
*
* Additive and default-omitted: an absence that suggests nothing carries no
* `tryInstead` key at all (the `ToolRepeatedCallPayload.mode` precedent).
*/
readonly tryInstead?: string;
/**
* The other TOOL the suggestion points at, typed (9.113.0) — the tool's
* `tryInsteadTool` as declared, copied. The name is not looked up: the tool
* may be served by a provider, or never have been offered on this run.
* Default-omitted like {@link ToolAbsentPayload.tryInstead}; either may
* come without the other.
*/
readonly tryInsteadTool?: TryInsteadToolPayload;
/**
* Where the search looked and when that source was measured, as the tool
* declared it (honesty step 7b) — `absent({ …, provenance })`, in the
* record's camelCase form. Default-omitted: an absence that declares no
* source carries no key, and one whose source the rule set refuses (an
* envelope minted elsewhere) carries none either (dev-warned).
*/
readonly provenance?: ProvenancePayload;
/**
* What the search's READ covered in time, as the tool declared it (honesty
* step 7b) — instants verbatim, camelCase like `lookedFor`. Default-omitted.
*/
readonly period?: PeriodPayload;
/**
* What the LIBRARY composed for a tool that declares its `search` — the
* facets by reference, the gaps, `complete`. Present only for such a tool;
* every other absence carries no key. Composed VALUES ride no event in any
* mode: they live on the committed `coverageDeclared` row, under the run's
* redaction policy. For a `checked: 'declared'` absence, `checked` above
* holds the declaration's constants only (the source, the fixed
* qualifiers) and may be empty.
*/
readonly composition?: CoverageCompositionPayload;
/**
* In place of `lookedFor` while `.negativeClaims()` is armed — a reference
* to the committed row's field. Unarmed, `lookedFor` is today's bytes.
*/
readonly lookedForRef?: CoverageFieldReference;
}
/** Where a value an event leaves out lives: the row the result identity addresses. */
export interface CoverageFieldReference {
/** The row's address — `claim-result/<namespace>/<runId>/<ordinal>`. */
readonly resultId: string;
readonly field: 'lookedFor';
}
/**
* One composed facet as it rides an event — a REFERENCE, never its value:
* `{ resultId, argument }` names the committed row and the argument whose
* value lives there.
*/
export interface CoverageFacetReferencePayload {
readonly facet: 'source' | 'subject' | 'population' | 'filter' | 'period' | 'names';
readonly from: 'call' | 'default' | 'host' | 'result';
readonly ref: {
readonly resultId: string;
readonly argument?: string;
};
/** Omission narrowed nothing (`whenOmitted: { any: true }`) — no value to keep out. */
readonly any?: true;
}
/** One typed gap as it rides an event — the words name arguments and verdicts, never a value. */
export interface CoverageGapPayload {
readonly kind: 'argument-unsent' | 'cap-reached' | 'paged' | 'period' | 'partial' | 'credential-limited' | 'match' | 'ranking' | 'scope' | 'prose';
readonly what: string;
readonly why?: string;
readonly bears?: 'source' | 'population' | 'period' | 'completeness' | {
readonly filter: string;
readonly values?: readonly (string | number | boolean)[];
};
readonly argument?: string;
}
/** What the library composed for one call of a `search` tool, as an event carries it. */
export interface CoverageCompositionPayload {
/** `'remote'` — a declaration over MCP or from a ToolProvider; it establishes nothing. */
readonly origin: 'local' | 'remote';
/** The committed row's address. */
readonly resultId: string;
readonly complete: boolean;
/** Each facet that came from the call, a default, the host or the result — by reference. */
readonly facets: readonly CoverageFacetReferencePayload[];
readonly gaps: readonly CoverageGapPayload[];
}
/** An absence's source and time as it rides an event — detached plain data. */
export interface ProvenancePayload {
readonly measuredAt: string;
readonly source: string;
readonly ageSeconds?: number;
readonly sourceExportDate?: string;
}
/**
* A declared period as it rides an event (honesty step 7b) — detached plain
* data, the instants the tool declared, never parsed or reformatted.
*/
export interface PeriodPayload {
readonly queried: {
readonly from: string;
readonly to: string;
};
/** What the store holds, or `'unknown'` — the tool said it cannot vouch for it. */
readonly held: {
readonly from: string;
readonly to: string;
} | 'unknown';
readonly readAt?: string;
}
/** A suggestion to try another tool, as it rides an event: detached plain
* data, copied out of the tool's own declaration. */
export interface TryInsteadToolPayload {
readonly tool: string;
readonly why?: string;
}
/**
* A tool returned a verdict WITH its own boundary — `coverage(result, …)`.
*
* The sibling of the evidence gate on the record: the gate's events say which
* VALUES an answer could not support, this one says which LIMITS it did not
* state. Fires per declaring call, whatever `.limitsTravelWithTheAnswer()`
* is set to — the recording half is unconditional, only the appending half is
* opt-in.
*/
/**
* A code-runner tool ran a program the model wrote.
*
* **The code itself is deliberately NOT here.** Generated code quotes the data
* it was given — a row, a serial, an address — so shipping it to every attached
* exporter would put customer data on a wire nobody audited for it. This is the
* same rule the session events already follow with `keyHash`, never the key.
*
* What travels instead is `shapeHash`: the program with its string literals,
* numbers and identifier names stripped, leaving the CALL SHAPE — which
* operations, in what order. Two runs that compute the same thing over
* different data share a shape hash, which is exactly the grouping that makes
* this useful: rank the shapes by how often they recur and the top of that list
* is a backlog of tools somebody keeps having to write by hand.
*/
export interface ToolsCodeRunPayload {
/** The name the model CALLED the code runner by, since an app may mount
* more than one — the registered name, which differs from the runner's
* own option name when a runner is re-exposed under another schema name. */
readonly tool: string;
/** The language the runner was configured for. */
readonly language: string;
/** How many declared artifact inputs were staged as files for this run. */
readonly stagedInputs: number;
/** Characters of stdout returned, AFTER the runner's cap. */
readonly outputChars: number;
/** True when the output hit `maxOutputChars` and was cut. */
readonly truncated: boolean;
/**
* Whether the program ran to completion, as the code runner REPORTED it.
* ABSENT when the runner reported neither an exit status nor an error —
* the outcome is unknown, and an absent `ok` is never success. A `false`
* is told apart by the three fields below.
*/
readonly ok?: boolean;
/** The program's exit status — exactly when the code runner reported one;
* never inferred, never defaulted. */
readonly exitCode?: number;
/** `true`: the code runner flagged an error and reported no exit status, so
* whether the code ran is NOT confirmed — not a failed program. */
readonly serviceError?: true;
/** The signal that ended the process before it exited, when the code runner
* knows it (`'SIGKILL'`): no exit status exists, and none is recorded. */
readonly terminatedBy?: string;
/** A stable hash of the normalized call shape. Never the code. */
readonly shapeHash: string;
}
/**
* One use of an `mcpClientPool` in a run — a listing (`at: 'list'`, filed by the
* tools slot after `list(ctx)`) or a tool call (`at: 'call'`, filed by the
* dispatch loop for that call). Ids, digests, evidence and outcomes only: never
* an identity value, a remote session id or handle, a header value, an
* argument, a result, a definition or a server's error text. Rides the
* `agentfootprint.tools.` prefix: a new domain would need a new bridge and
* wildcard (see the `tools.session_*` note above).
*/
export interface ToolsMcpClientPayload {
/** The pool's name, which `Tool.source` also carries. */
readonly server: string;
readonly at: 'list' | 'call';
/** At `'call'`. */
readonly toolCallId?: string;
/**
* The tool-session digest of the conversation's key (`hashSessionKey`):
* enough to join two rows, not enough to name anyone. Absent when the caller
* named no identity, so there was no key.
*/
readonly keyHash?: string;
/** The pool's own random id for the client touched; absent when none was. */
readonly clientId?: string;
/** 1 = the key's first client, 2 = its first replacement, … */
readonly generation?: number;
/** What happened to the conversation's client. */
readonly client: 'opened' | 'joined' | 'reused' | 'not-needed' | 'refused' | 'expired' | 'handle-lost';
/** Why the key's previous client left, when this use opened its replacement. */
readonly previous?: 'idle' | 'evicted' | 'expired' | 'handle-lost' | 'terminated';
/** With `'expired'` or `'handle-lost'`: what proves it. */
readonly evidence?: 'server-404' | 'transport-forgot-handle';
readonly refusal?: 'pool-full' | 'principal-full' | 'ending-full' | 'init-timeout' | 'init-failed' | 'no-identity' | 'incomplete-identity' | 'identity-mismatch' | 'stale-tools' | 'binding-changed' | 'request-unmatched' | 'lease-expired' | 'operations-full' | 'closed';
/** Opened or joined: how long the open took. */
readonly initMs?: number;
/** At `'list'`: a shared entry, or the conversation's own client's list. */
readonly definitions?: 'shared' | 'listed';
/** When shared: the entry's age. */
readonly definitionsAgeMs?: number;
/** At `'call'`: the call met a replacement client and was checked against its fresh list. */
readonly revalidated?: 'kept' | 'refused';
}
/**
* What one attempt of an MCP request came to.
*
* - `'answered'` — an HTTP response arrived and no retry rule refused it. It
* is delivered as it came, WHATEVER its status: a 503 is `answered` with
* `status: 503`. A response received is not an operation that succeeded,
* so nothing here is called success; read `status`. Nor is it the MCP
* call's answer: it records the HTTP exchange. A `202 Accepted`, or a
* stream that closes before the call's result, is `answered`, and the
* JSON-RPC answer may still never come — the end's `mayHaveRun` says when
* the call failed that way.
* - `'refused'` — a response the retry policy recognises as a refusal
* (`kind`): HTTP 429, or AgentCore Runtime's session-busy reply. It records
* the response the server sent, not proof that nothing ran: a 429 says the
* request was rate-limited, nothing more (RFC 6585 §4). `effect` says what
* is known; `permission` says what allowed asking again.
* - `'failed'` — the request threw before an answer arrived (a dropped
* connection, a DNS failure). The server may have run it, so it is asked
* again only when the app declared that for this tool (`replay`).
* - `'timed-out'` — the call's deadline (`maxElapsedMs`) cut the request
* while it was in flight.
* - `'cut'` — the call was cancelled while the request was in flight: the
* run's or the client's signal, the MCP SDK giving it up (its own timeout),
* or the call settling first. The request WAS sent; the server may already
* have received it and run it. A cut is never a "not run".
*
* The last three never had a response, so they carry `remote: 'unknown'`.
*
* Every request the series sent is a row, or is counted in `omitted`: the rows
* and `omitted` together are the requests the series sent. A request that was
* never sent — the next attempt after a cancelled wait, or anything after a
* signal that was already aborted — has no row.
*/
export type ToolRequestAttemptOutcome = 'answered' | 'refused' | 'failed' | 'timed-out' | 'cut';
/**
* How an MCP request ended: its last attempt's outcome — or `'cancelled'`, when
* the call was cancelled with NO request in flight: while it waited to ask
* again, or before its first request. Nothing more was sent; every request
* that was sent has its own row and its own outcome.
*/
export type ToolRequestOutcome = ToolRequestAttemptOutcome | 'cancelled';
/**
* Whether the server received and ran a request, when that is not known: the
* attempt ended without a response (`failed`, `timed-out`, `cut`), so the
* request may have reached the server and run there, or not. The library never
* records such a request as not run.
*/
export type ToolRequestRemote = 'unknown';
/**
* Why the request went on after an attempt, or ended with it.
*
* - `'retryable'` — another attempt follows after `waitMs`; `permission`
* says what allowed it. The only reason that is not an end.
* - `'answered'` — answered; the response is delivered.
* - `'exhausted'` — the request has made every attempt it may.
* - `'wait-limit'` — the wait would pass the call's total waiting budget
* (`maxWaitMs`). A stated `Retry-After` is never shortened to fit: the
* refusal is delivered instead.
* - `'no-time-left'` — the wait would pass the call's deadline; or the
* deadline cut a request or a wait.
* - `'not-replayable'` — the request's body cannot be sent twice.
* - `'not-retryable'` — the attempt failed without an answer, and nothing
* permits asking again after that failure: the app declared no repeat of
* this tool on it (`replay`), or it is not a kind the series repeats.
* - `'not-permitted'` — refused with a 429, and the policy in force does not
* permit asking this tool again on it. Under `'app-authorized'`, only the
* app's declaration does: a generic 429 does not prove the server ran
* nothing.
* - `'changes-without-key'` — the app declared this tool as making changes,
* and the call carries no key the server is declared to deduplicate by. It
* is never asked again automatically, under any policy.
* - `'cancelled'` — the caller's signal ended the request.
*/
export type ToolRequestReason = 'retryable' | 'answered' | 'exhausted' | 'wait-limit' | 'no-time-left' | 'not-replayable' | 'not-retryable' | 'not-permitted' | 'changes-without-key' | 'cancelled';
/** The refusals a retry rule recognises: HTTP 429, and AgentCore Runtime's session-busy reply. */
export type ToolRequestRefusalKind = 'throttle' | 'session-busy';
/**
* What is known about whether an attempt took effect on the server —
* footprintjs's effect law (`AttemptEffect`), read for an MCP request:
*
* - `'none'` — it ran nothing, and that is established: the request was
* never sent (an `end` whose `attempts` is `0`). No reply establishes it.
* A 429 says the request was rate-limited, and AgentCore's session-busy
* body is matched by its code and words — a server behind the runtime can
* answer with the same error after it ran the call.
* - `'unknown'` — anything sent that did not complete: a 429, a session-busy
* reply, a lost connection or a timeout after the request was sent, a cut,
* a 5xx, a JSON-RPC error, a tool error (one can follow a write that
* succeeded).
* - `'applied'` — the call completed: the server answered it, and not with
* an error. It is never proof that anything was written.
*/
export type ToolRequestEffect = 'none' | 'unknown' | 'applied';
/** The failure kinds the app may declare a tool repeatable on (`replay.tools[name].on`). */
export type ToolRequestReplayKind = 'throttle' | 'network';
/**
* What allowed the next attempt — present exactly when `reason` is
* `'retryable'`. Permission and effect are separate facts: neither is ever
* read from the other.
*
* - `by: 'declaration'` — the app's entry for this tool names the failure
* kind (`on`).
* - `by: 'adapter'` — the adapter the app chose retries this refusal as its
* own policy: AgentCore Runtime's session-busy reply, which AWS documents
* as a retryable conflict while a session starts or stops. It says what
* it assumes in doing so (`assumes`).
* - `by: 'compatibility'` — the compatibility policy, today's default: it
* repeats a 429 for every tool, and says what it assumes in doing so
* (`assumes`).
*
* An assumption is a policy, never evidence: the row's `effect` stays
* `'unknown'`.
*/
export interface ToolRequestPermission {
readonly by: 'declaration' | 'adapter' | 'compatibility';
/** `declaration` only — the failure kind the app's entry names. */
readonly on?: ToolRequestReplayKind;
/** `adapter` and `compatibility` only — what the policy assumes of the refusal it repeats. */
readonly assumes?: 'refused-before-execution';
/**
* The app declared this tool as making changes, so it was asked again only
* because the server is declared to deduplicate by a key this call carries.
* The key protects only if the server uses it.
*/
readonly deduplicated?: true;
}
/**
* footprintjs's failure categories (`AttemptCategory`) — the one fixed set
* every attempt record uses, so a saved request groups with every other.
*/
export type ToolRequestFailureCategory = 'timeout' | 'rate-limited' | 'server' | 'network' | 'auth' | 'permanent' | 'unknown';
/**
* A failure as the record keeps it: a fixed sentence and a code. The thrown
* error's own message, name and fields are NEVER kept — a network or SDK
* error's text can carry a URL with a token in it, a host's internal name or
* a server's reply, and none of that is the library's to store.
*/
export interface ToolRequestError {
readonly message: 'The request failed before an answer arrived. Its error is not kept.' | 'The call ran out of time before an answer arrived.';
/** The failure's category — the same word as `failure`. */
readonly code: ToolRequestFailureCategory;
}
/** One attempt of an MCP request, as its retry series recorded it. */
export interface ToolRequestAttempt {
/** 1-based. */
readonly attempt: number;
readonly outcome: ToolRequestAttemptOutcome;
readonly reason: ToolRequestReason;
/** The response's HTTP status — present when one arrived (`answered`, `refused`). */
readonly status?: number;
/** Which refusal a retry rule recognised — `refused` only. */
readonly kind?: ToolRequestRefusalKind;
/**
* The failure's category (footprintjs's `AttemptCategory`) — absent for
* `answered`. Named `failure`, not `category`: that key belongs to a retired
* contract the redaction rule no longer treats as the library's own.
*/
readonly failure?: ToolRequestFailureCategory;
/** The wait before the next attempt, in ms — present exactly when `reason` is `'retryable'`. */
readonly waitMs?: number;
/**
* The wait the server stated in `Retry-After`, in ms — when it stated one.
* Absent beside a `waitMs` means that wait was the library's own backoff.
*/
readonly retryAfterMs?: number;
/** `failed` and `timed-out` only. */
readonly error?: ToolRequestError;
/**
* `'unknown'` when the attempt ended without a response (`failed`,
* `timed-out`, `cut`): it was sent, and whether the server received and ran
* it is not known. Absent when a response arrived: the record keeps what the
* server answered (`status`) — for a `refused` attempt that is the server's
* own refusal, not proof that nothing ran.
*/
readonly remote?: ToolRequestRemote;
/**
* What is known about whether this attempt took effect (see
* {@link ToolRequestEffect}). On every attempt the library files.
*/
readonly effect?: ToolRequestEffect;
/** What allowed the next attempt — present exactly when `reason` is `'retryable'`. */
readonly permission?: ToolRequestPermission;
/** How long the attempt took, in ms, from sending to its end. On every attempt the library files. */
readonly durationMs?: number;
}
/** How an MCP request ended. */
export interface ToolRequestEnd {
readonly outcome: ToolRequestOutcome;
readonly reason: ToolRequestReason;
/** The delivered response's HTTP status — `answered` and `refused`. */
readonly status?: number;
/** The refusal that was delivered — `refused` only. */
readonly kind?: ToolRequestRefusalKind;
/** The failure's category, as on an attempt. */
readonly failure?: ToolRequestFailureCategory;
/**
* Present when the request was CUT, and says what it was doing then.
* `'request'` — its LAST attempt was in flight (`cut`, `timed-out`): that
* attempt was sent and is the last row, or, past 100 attempts, the last one
* counted in `omitted`; whether the server ran it is unknown. `'wait'` — it
* was waiting to ask again, so no request was in flight and the next one was
* never sent: a cancellation cut the wait (`cancelled`), or the deadline left
* no time to finish it and the last refusal was delivered (`refused`,
* `no-time-left`). Absent when nothing was cut — and when the request was
* cancelled before its first attempt, so that nothing was sent.
*/
readonly during?: 'request' | 'wait';
/** `failed` and `timed-out` only. */
readonly error?: ToolRequestError;
/**
* `'unknown'` when the request ended on an attempt with no response
* (`failed`, `timed-out`, `cut`): whether the server ran that request is not
* known.
*/
readonly remote?: ToolRequestRemote;
/**
* How many requests the series sent: `attempts.length + omitted`. On every
* end the library files.
*/
readonly attempts?: number;
/**
* What is known about the series' effect as a whole. Uncertainty survives:
* once any attempt's effect is `'unknown'`, so is the series' — a later
* proven refusal does not resolve it, and a later success does not prove the
* operation happened only once. `'applied'` only when the call completed and
* no attempt's effect is unknown; `'none'` only when every attempt is proven
* to have run nothing (or none was sent). On every end the library files.
*/
readonly effect?: ToolRequestEffect;
/**
* The call failed as "may have run" (footprintjs's `UnknownEffectError`, its
* final error — a later refusal's, or the timeout — as the `cause`): under
* the `'app-authorized'` policy, the call genuinely lacked an answer
* somewhere and did not complete. Lacking an answer is not an unknown
* effect: it is an attempt that got no response at all (`remote:
* 'unknown'`), or a request accepted over HTTP (an `answered` 2xx) whose
* JSON-RPC answer never came. A received refusal or error IS an answer —
* its effect unknown, delivered as the server sent it — so a call whose
* every attempt received one has no `mayHaveRun`. Absent under
* `'compatibility'`, where the caller sees the error it always did, and
* for a call its caller cancelled, which ends with the caller's reason.
*/
readonly mayHaveRun?: true;
}
/**
* What one MCP request's retry series did: every attempt it made, why it went
* on or stopped after each, how long it waited, and how the request ended.
*
* Filed by `mcpClient`'s retry series for a call the agent dispatched, ONCE per
* request, when the call settles — so the last attempt's `effect` can say
* whether the call completed — and at the moment of a cancellation, so a
* run cancelled during a wait still carries it when it is saved. A call that
* settles before its series (the MCP SDK's own timeout, say) cancels the series
* there and then, inside the call, so it is never filed after its run, and no
* request is sent after it. The framework stamps `toolCallId`, `toolName` and
* `iteration` from the dispatch, so two calls, or two runs, sharing one MCP
* connection never mix their attempts.
*
* ALLOW-LISTED, field by field: numbers, the library's own words, the
* endpoint label the app declared, or else the endpoint's origin. Never the
* URL's path, query or credentials, the call's arguments, the server's answer
* or an error's text.
*
* Only a transport `mcpClient` builds runs a series that files this; with
* retry off (`retryOnThrottle: false` on a plain transport) there is no series
* and no event.
*/
export interface ToolsRequestAttemptsPayload {
readonly toolCallId: string;
readonly toolName: string;
readonly iteration: number;
/** The `endpointLabel` the app declared for this endpoint, when it declared one. */
readonly endpoint?: string;
/** Otherwise the endpoint's ORIGIN — scheme, host and port, nothing else. */
readonly origin?: string;
/**
* The attempts, in order — at most 100. Every request the series sent is
* here or counted in `omitted`, a cut one included: `attempts.length +
* omitted` is the number of requests the series sent. A request it never
* sent has no row.
*/
readonly attempts: readonly ToolRequestAttempt[];
/** Attempts made past the first 100, counted and not listed. `0` when every attempt is listed. */
readonly omitted: number;
readonly end: ToolRequestEnd;
}
export interface ToolCoverageDeclaredPayload {
readonly toolName: string;
readonly toolCallId: string;
readonly iteration: number;
readonly checked?: readonly CoverageItemPayload[];
readonly notChecked?: readonly CoverageItemPayload[];
readonly cannotCover?: readonly CoverageItemPayload[];
/**
* What the read behind the value covered in time (honesty step 7b) — a
* `coverage()`'s `period`, or a described result's (whose boundary this
* event carries). Default-omitted.
*/
readonly period?: PeriodPayload;
/**
* What a `coverage()`'s read found still running — its outcome not known
* yet — as the tool declared it (a copy). Default-omitted: present only when
* the ledger declared a well-formed, non-empty `in_progress`.
*/
readonly inProgress?: readonly InProgressItemPayload[];
/** What the library composed for a `search` tool — see {@link ToolAbsentPayload.composition}. */
readonly composition?: CoverageCompositionPayload;
}
/** One thing a read found still running, as it rides an event: detached plain
* data, copied out of the tool's own declaration. */
export interface InProgressItemPayload {
readonly what: string;
readonly why?: string;
/** RECORD-ONLY short form — the model was served the envelope without it. */
readonly short?: string;
/** How many things this entry stands for; absent = one. Served. */
readonly count?: number;
}
/** One piece of declared ground, as it rides an event: detached plain data,
* copied out of the tool's own declaration. */
export interface CoverageItemPayload {
readonly what: string;
readonly why?: string;
/** The item's short plain form, as the tool declared it — RECORD-ONLY: the
* model was served the envelope without it. Absent when not declared (or
* declared invalid by an envelope minted outside `absent()` / `coverage()`,
* which is read, never repaired). */
readonly short?: string;
/** What kind of ground the item is (`'existence' | 'scope'`), on
* `notChecked` / `cannotCover` only — RECORD-ONLY, like `short`. Absent =
* not declared; never inferred from `what`. */
readonly kind?: 'existence' | 'scope';
}
/**
* A tool returned a semantic envelope (`describedResult(…)`) — typed
* series/facts/edges with the caveats that make them honest (grain,
* provenance, coverage) as data.
*
* The event carries the FULL envelope — render hints, the three-list
* coverage detail, the marker, everything — because this is the channel
* recordings and UIs read; the MODEL received only the compact
* rendering-free projection (`semanticsForModel`). Emitted BEFORE the
* result ceiling is measured, so grain and provenance survive to the record
* even when the content itself was refused as oversized. Detached plain
* data (structuredClone), never a live reference into the tool's own value.
*/
export interface ToolSemanticsDeclaredPayload {
readonly toolName: string;
readonly toolCallId: string;
readonly iteration: number;
readonly semantics: ToolSemantics;
}
export interface PermissionCheckPayload extends Partial<ToolCallEventIdentity> {
/** 9.11.0 — the shared vocabulary, so the event cannot drift from the
* request. `'skill_read'` joined it when skill activation became something
* a policy can refuse. */
readonly capability: PermissionCapability;
readonly actor: string;
readonly target?: string;
readonly result: 'allow' | 'deny' | 'halt' | 'gate_open';
readonly policyEngine?: 'opa' | 'cerbos' | 'custom';
readonly policyRuleId?: string;
readonly rationale?: string;
/** v2.12 — telemetry tag carried through from PermissionDecision.reason. */
readonly reason?: string;
}
export interface PermissionGateOpenedPayload {
readonly gateId: string;
readonly openedBy: string;
readonly expiresAt?: number;
}
/** A tool's declared credential is being resolved before invocation. */
export interface CredentialRequestedPayload extends Partial<ToolCallEventIdentity> {
readonly service: string;
readonly mode?: 'machine' | 'user';
}
/** A credential was issued. Carries the `kind` only — NEVER the token/secret. */
export interface CredentialAcquiredPayload extends Partial<ToolCallEventIdentity> {
readonly service: string;
readonly kind: string;
readonly expiresAt?: number;
}
/** 3-legged consent is required (the tool is not run until the user authorizes).
* Carries `sessionId` for correlation, NOT the authorization URL. */
export interface CredentialAuthorizationRequiredPayload extends Partial<ToolCallEventIdentity> {
readonly service: string;
readonly sessionId: string;
}
/**
* Credential resolution failed — the provider threw, so the tool is not run
* (fail-closed: never half-authed).
*
* **Never carries the credential, the token, or the authorization URL.** See
* the security contract on `CredentialProvider`: `reason` is the provider's own
* thrown message, so a provider must scrub secrets before throwing.
*
* `tool` and `errorClass` were added in 9.4.0, for the operator watching a
* dashboard rather than reading a transcript. Without the tool name this event
* says a service failed but not what stopped working; without the class every
* distinct failure is one undifferentiated string. Both are optional because
* neither is always knowable: `tool` is absent when the resolution was not made
* on a tool's behalf, and `errorClass` is absent when what was thrown was not
* an `Error`.
*/
export interface CredentialFailedPayload extends Partial<ToolCallEventIdentity> {
/** The service id the tool declared (or asked for), never the credential. */
readonly service: string;
/** The provider's thrown message. */
readonly reason: string;
/** The tool whose call needed it — the thing that actually stopped working. */
readonly tool?: string;
/** Constructor name of what was thrown (e.g. `'TypeError'`, `'TimeoutError'`)
* — the routable half of the failure, so alerts do not have to parse prose. */
readonly errorClass?: string;
}
export interface PermissionGateClosedPayload {
readonly gateId: string;
readonly reason: string;
}
/**
* Emitted (v2.12) when a `PermissionChecker.check()` returns
* `{ result: 'halt', ... }`. Pairs with the typed `PolicyHaltError`
* thrown by `Agent.run()` — the event is the OBSERVABILITY signal,
* the error is the RUNTIME signal. Both carry the same `reason` for
* routing (e.g. `'security:exfiltration'` → PagerDuty).
*
* Fires AFTER the synthetic tool_result has been written to scope.history
* but BEFORE the run terminates, so observability adapters see the
* halt while the conversation history is consistent for downstream
* audit/replay.
*/
export interface PermissionHaltPayload extends Partial<ToolCallEventIdentity> {
readonly checkerId?: string;
readonly target: string;
readonly reason: string;
readonly tellLLM?: string;
readonly iteration: number;
readonly sequenceLength: number;
}
export interface FallbackTriggeredPayload {
readonly kind: 'provider' | 'tool' | 'skill';
readonly primary: string;
readonly fallback: string;
readonly reason: string;
}
export interface CostTickPayload {
readonly scope: 'iteration' | 'turn' | 'run';
/**
* The model this money was spent on — the one `pricingTable.pricePerToken`
* was asked about.
*
* Always present: a tick exists because a priced call happened, and a call
* has a model. Without it the event said what was spent and not on what, so
* a run that switched models mid-loop (a `'retry-other'` failover, a
* summarizer on a cheaper model) produced a bill nothing could break down.
*/
readonly model: string;
/**
* The provider that billed it, by `LLMProvider.name` — the other half of
* attribution, since one model name can be served by several providers
* (Bedrock and Anthropic both answer to a Claude model id, at different
* prices).
*
* Optional for ONE honest reason: a window/compaction strategy reports its
* summarizer spend through `WindowStrategyResult.spend`, and the provider is
* read from that strategy's `billing` declaration, which the seam leaves
* optional. Every shipped strategy declares it. Absent therefore means "the
* strategy that billed this did not say", never "the agent's provider".
*/
readonly provider?: string;
readonly tokensInput: number;
readonly tokensOutput: number;
readonly estimatedUsd: number;
readonly cumulative: {
readonly tokensInput: number;
readonly tokensOutput: number;
readonly estimatedUsd: number;
};
}
export interface CostLimitHitPayload {
readonly kind: 'max_tokens' | 'max_cost' | 'max_iterations' | 'max_wallclock';
readonly limit: number;
readonly actual: number;
readonly action: 'abort' | 'warn' | 'degrade';
}
export interface EvalScorePayload {
readonly metricId: string;
readonly value: number;
readonly threshold?: number;
readonly target: 'iteration' | 'turn' | 'run' | 'toolCall';
/**
* What the score is about: the run id (`event.meta.runId`) for `'run'` /
* `'turn'`, the iteration index for `'iteration'`, the provider's call id
* for `'toolCall'`. A NAME, not a capability: run ids are minted in
* sequence and anyone can write one, so a host filing a score from user
* input (a feedback button) checks the run is that user's before it emits.
* `otelObservability` parents a score to a run only when the score's own
* meta places it in that run's session; otherwise it rides a span of its
* own that names the ref.
*/
readonly targetRef: string;
readonly evaluator?: 'llm' | 'fn' | 'heuristic';
readonly evidence?: Readonly<Record<string, unknown>>;
/**
* The evaluator's own reading of the score, in a small vocabulary it
* documents — `'pass'` / `'fail'`, `'relevant'` / `'not_relevant'`.
* Optional and never derived: a `threshold` does not say which
* side of it passes, so no label is ever computed from one.
* `otelObservability` exports it BY DEFAULT as
* `gen_ai.evaluation.score.label` (the spec's "SHOULD have low
* cardinality"), so it must be a word from that vocabulary — never a
* judge's sentence, which belongs in {@link EvalScorePayload.explanation}.
*/
readonly label?: string;
/**
* The evaluator's free-form reason for the score. It is CONTENT —
* a judge's reason can quote the answer it graded — so
* `otelObservability` exports it (`gen_ai.evaluation.explanation`) only
* under `captureContent`.
*/
readonly explanation?: string;
}
export interface EvalThresholdCrossedPayload {
readonly metricId: string;
readonly direction: 'above' | 'below';
readonly value: number;
readonly threshold: number;
readonly actionTaken?: string;
}
/**
* Emitted (v2.13) when the agent's final answer fails the agent's
* configured `outputSchema` (the parser passed to
* `Agent.create({...}).outputSchema(parser)`).
*
* Scope: ONLY agent-level final-answer validation. Tool-input validation
* (`LLMToolSchema.inputSchema`) is a different concern handled by
* provider-side type checks; this event does NOT fire for tool-arg
* validation failures.
*
* Lives in the `agent.*` domain (parallel to `agent.turn_end`) because
* final-answer validation is a turn-level concern, not a generic
* evaluation metric.
*
* Pairs with `agentfootprint.error.retried` (when a reliability rule
* routes the failure to retry with feedback) or
* `agentfootprint.reliability.fail_fast` (when retries are exhausted).
*
* The event is the OBSERVABILITY signal — it fires on EVERY validation
* failure, regardless of whether retries are configured. Use the
* `attempt` + `cumulativeRetries` fields to drive operator dashboards
* for retry-rate trending (a leading indicator for model drift).
*
* Fires BEFORE PostDecide rules evaluate, so observability sees the
* failure even if a buggy rule routes to fail-fast or swallows it.
*/
/**
* Emitted (7.26) once per failed final answer that the run is about to
* ASK AGAIN about, on an agent built with
* `.outputSchema(parser, { retries })`.
*
* Its sibling `agent.output_schema_validation_failed` reports a failure the
* reliability gate is handling INSIDE one `call-llm` stage. This one reports
* a failure the LOOP is handling: the corrective message named here joins the
* conversation, the ReAct loop re-enters, and the next attempt arrives with
* its own `stream.llm_start` / `stream.llm_end` bracket and its own
* `cost.tick`. Subscribe to it to see how often a model needs a second ask —
* a leading indicator of drift, and of a schema that is harder to hit than
* its author thinks.
*
* Fires from the retry branch, after the corrective turn is committed, so a
* consumer that reads `snapshot.sharedState.outputAttempts` finds the
* matching row (joined by `correctiveMessageHash`) already there.
*/
/**
* The run's answer does NOT satisfy its own `outputSchema` (8.18.0).
*
* Fires once, from the Route decider, on the run that hands back an answer the
* contract rejects — including the default `retries: 0` case, where the first
* answer is the only one. It is the event that was missing: `run()` returned
* the failing string, `runTyped()` threw at a boundary the caller may not have
* used, and nothing in between said a contract had been missed.
*
* Alert on it. A rise in `stage: 'json-parse'` is a model that stopped
* honouring the instruction; a rise in `'schema-validate'` is drift against
* the shape; any `brokenBy` at all is one of your own output rules.
*/
export interface AgentOutputContractUnmetPayload {
/** Which half of validation failed. */
readonly stage: 'json-parse' | 'schema-validate';
/** The validator's own message, verbatim. DATA, not narrative. */
readonly error: string;
/** Failing field path when the parser exposes one (Zod-style issues). */
readonly path?: string;
/** Answers judged in this run, the first included. `1` under `retries: 0`. */
readonly attempts: number;
/** Corrective re-asks the run paid for. `0` under `retries: 0`. */
readonly retriesSpent: number;
/** True when `.outputFallback()` is configured — a tier `runTyped()` reaches
* and `run()` does not. */
readonly fallbackConfigured: boolean;
/** The ReAct iteration that produced the answer. */
readonly iteration: number;
/** Present when an `act({ output })` middleware rewrote an answer that had
* PASSED into one that fails — the name of that middleware. The run stops
* re-asking when this is set: the model's answer was already right. */
readonly brokenBy?: string;
}
export interface AgentOutputSchemaRetryPayload {
/** 1-based attempt that just failed. `1` is the first answer. */
readonly attempt: number;
/** Corrective asks left AFTER this one. `0` means this is the last. */
readonly retriesRemaining: number;
/** The ReAct iteration the failed answer came from. A retry consumes an
* iteration, so the next attempt reports `iteration + 1`. */
readonly iteration: number;
/** Which half of validation failed — `'json-parse'` (the model emitted
* prose) vs `'schema-validate'` (JSON, wrong shape). They trend
* differently under model drift. */
readonly stage: 'json-parse' | 'schema-validate';
/** The validator's own message, verbatim. DATA, not narrative: it is
* quoted into the corrective message after an authored frame, and it is
* quoted here the same way. */
readonly error: string;
/** Failing field path when the parser exposes one (Zod-style issues). */
readonly path?: string;
/** `fnv1a` of the corrective message that went back to the model — the
* join to the message in `history` and to the `outputAttempts` row. */
readonly correctiveMessageHash: string;
}
/**
* One verdict from the evidence gate (`.namesAndNumbersFromEvidence()`,
* 9.35.0) — fired once per would-be-final answer the gate judged, whatever
* the posture and whatever the outcome.
*
* It rides the emit channel rather than a CommitBundle because it is a fact
* about ONE attempt, not conversation state; the only thing that reaches the
* commit log is the terminal verdict (`unsupportedValues`), which the boundary
* needs.
*
* **What this event does NOT claim:** the check catches values that appear in
* no tool result. It cannot see a false statement assembled from real values,
* so `action: 'grounded'` means "every name and number was read from
* somewhere", never "the answer is true".
*/
export interface AgentEvidenceCheckedPayload {
/** The ReAct iteration that produced the judged answer. */
readonly iteration: number;
/** The posture in force — `'assist'` records only, `'guard'` may revise
* once, `'rails'` may withhold the answer. */
readonly posture: 'assist' | 'guard' | 'rails';
/** How many distinct values the answer had to ground. `0` means the answer
* asserted nothing the extractor treats as data. INCLUDES exempt values —
* see {@link lookedUp}. */
readonly candidates: number;
/**
* How many of `candidates` the gate actually looked up in the tool results:
* the values found plus the values not found, before `unsupported` is
* sliced. The rest were EXEMPT — the person's message, the conversation or
* the app's own instructions (recalled memory included) already held them —
* and were never looked up. Present on every row from the release that
* added it; absent on a recording made before it, which therefore cannot
* say how many values were looked up.
*/
readonly lookedUp?: number;
/** The values that appear in no tool result, truncated to a readable list.
* Empty when `action` is `'grounded'`. Under `code: 'match'` each says
* where it sat: `'code'` when every occurrence was inside a fenced block
* that matched no submission, `'prose'` otherwise. */
readonly unsupported: readonly {
readonly value: string;
readonly shape: string;
readonly region?: 'prose' | 'code';
}[];
/**
* • `'grounded'` — every value was found; the answer stands.
* • `'revision-asked'` — the values went back to the model for one more
* turn (emitted by the EvidenceRecheck branch).
* • `'flagged'` — the answer ships carrying them, on the record.
* • `'refused'` — `'rails'`: the answer was withheld and the
* boundary raises `UnsupportedValuesError`.
*/
readonly action: 'grounded' | 'flagged' | 'revision-asked' | 'refused';
/** True when this judgement ran on an answer a revision already corrected —
* which is how a reader tells "the revision fixed it" (`grounded`, after
* revision) from "it did not" (`flagged`/`refused`, after revision). */
readonly afterRevision: boolean;
/** Set when the evidence index hit its ceiling and could not hold every
* tool result. The gate then records its verdict WITHOUT acting on it —
* a partial corpus can call a grounded value fabricated. */
readonly evidenceTruncated?: boolean;
/**
* WHICH RESULTS OF THIS TURN THE ANSWER'S VALUES WERE READ FROM — on the
* verdicts about an answer that ships (`'grounded'`, `'flagged'`,
* `'refused'`), never on `'revision-asked'` (a draft). One entry per
* `role: 'tool'` result of this turn that carried at least one of the
* answer's names and numbers: `values` = how many, `only` = how many of
* those no other result of this turn carried. A result of this turn that
* is NOT listed carried none of them — a fact about tokens, not meaning:
* read it as "the answer cites nothing from it", never "unused" (an answer
* that states no value can still rest on a result). `[]` is a fact too:
* no result of this turn carried any of them (an answer drawn from an
* earlier turn, or one that states no value — `lookedUp` tells which).
*
* ABSENT when the list cannot be whole: the evidence index hit its ceiling,
* or one value was carried by more results than the index keeps. Absent on
* recordings made before the field existed. Identities and counts only,
* never a value (`evidence/answerCarriers.ts`).
*/
readonly carriedBy?: readonly {
readonly toolCallId: string;
readonly values: number;
readonly only: number;
}[];
/**
* On `action: 'revision-asked'` only, and only when the turn held placed
* artifact tickets a served `wants` tool can spend: the refs the
* correction NAMED as the route to a grounded number. Absent otherwise —
* including for every agent without a `wants`-declaring tool.
*/
readonly stagedRefs?: readonly {
readonly ref: string;
readonly kind: string;
}[];
/** The spender tools the correction named, beside {@link stagedRefs}. */
readonly spenderTools?: readonly string[];
/**
* Under the figures dial (`namesAndNumbersFromEvidence({ figures: true })`),
* on `'grounded'`, `'flagged'` and `'refused'`: the numbers no tool result
* carried that ARE a declared derivation of the numbers the results carry —
* each with its `derivation` (`rounded`, `unit-scale`, `column-sum`,
* `column-ratio`, `column-difference`, `complement`) and `from` (the
* operands, in the results' own keys). Not read and not invented: the model
* computed them. Present (possibly `[]`) exactly when the dial is on, so its
* presence says the derivation check ran; the `unsupported` numbers beside
* it matched no value AND no derivation. Sliced like `unsupported`.
*/
readonly computed?: readonly {
readonly value: string;
readonly shape: string;
readonly derivation: string;
readonly from: string;
}[];
/**
* Under `.namesAndNumbersFromEvidence({ code: 'match' })`, on a prose
* answer: one verdict per fenced block the answer showed — a block equal,
* byte for byte after removing only the Markdown wrapper, to a program a
* call of THIS turn submitted to a declared code-running tool carries that
* call's id, tool and label (`completed`, `outcome-unknown`, …) and was not
* read for values; any other block is `unmatched` with a reason and was
* read as prose. `earlierTurnRows` counts earlier turns' script rows (never
* compared); `regionsDropped` the blocks past the cap. Never the code.
*/
readonly scripts?: ScriptsVerdict;
}
/**
* The staged-refs nudge fired (`.namesAndNumbersFromEvidence({ nudge: true })`)
* — one per iteration whose request gained the late line naming staged
* artifact refs and the `wants` tool that spends them.
*
* The line itself is request-only (never history), so this event is the ONE
* record that it existed: which refs, which spenders, which iteration. A run
* where the nudge never armed emits none of these.
*/
export interface AgentGroundingNudgedPayload {
/** The ReAct iteration whose request carried the line. */
readonly iteration: number;
/** The staged refs the line named — `ref` verbatim, `kind` as minted
* (`Tool.resultKind` / `tool-result/<toolName>`). Capped; see
* {@link refsOmitted}. */
readonly refs: readonly {
readonly ref: string;
readonly kind: string;
}[];
/** Matched refs beyond the cap, stated rather than silently dropped.
* Absent when every match was named. */
readonly refsOmitted?: number;
/** The spender tools named, by their registered names — the tools whose
* declared `wants` matched a staged ref's kind, exact-string, never by
* tool name. */
readonly tools: readonly string[];
}
/**
* The model this run STARTS with, and what may replace it mid-run.
*
* `model` is the agent's resolved default — the one `callLLM` sends unless
* something overrides it. It is deliberately not the "final" model: a
* `.configure()` resolver reads the run's input in the seed stage (AFTER this
* event), and per-skill brains pick per iteration. Resolving `.configure()`
* here to make this field "effective" would call a consumer's resolver twice
* per run, and two calls can disagree — so the manifest names the starting
* choice and {@link RunConfiguredLlmPayload.modelOverrides} names who is
* allowed to change it. What each call REALLY used is on
* `agentfootprint.stream.llm_start`, per call, as it always was.
*/
export interface RunConfiguredLlmPayload {
/** `LLMProvider.name` — the effective provider, decorators included. */
readonly provider: string;
/** The agent's resolved default model. */
readonly model: string;
/**
* Which mounted mechanisms may replace `model` during this run, by NAME —
* `'configure'` (a `.configure()` resolver) and `'skill-brains'` (per-skill
* providers/models, escalation included). Absent when nothing can: then
* `model` is what every call of this run used, and an experiment can group
* on it alone.
*/
readonly modelOverrides?: readonly ('configure' | 'skill-brains')[];
}
/**
* One mounted memory, by the names it DECLARED.
*
* The store itself is not named: `MemoryStore` declares no id and no kind, so
* naming which backend was in play would mean inventing a label (a class name
* that survives no bundler, say) and an invented label is exactly what an arm
* must not be grouped on. What IS declared is here.
*/
export interface RunConfiguredMemoryPayload {
/** The `defineMemory({ id })` — the same id `memory.*` events carry. */
readonly id: string;
/** `'episodic' | 'semantic' | 'narrative' | 'causal'`. */
readonly type: MemoryType;
/** The strategy KIND it was compiled from (`'window'`, `'topK'`, …).
* Absent for a hand-built definition, which declares none. */
readonly strategy?: MemoryStrategyKind;
/** `RetrievalStrategy.name`, when the memory was given a spelled-out
* retrieval rule. Absent for the `{ topK, threshold }` shorthand — which
* is a rule with no name, not the default one. */
readonly retrieval?: string;
/** The embedder in play, by its own `Embedder.id` (or the declared
* `embedderId` when the store embeds server-side and no embedder was
* passed). Absent when neither side named one. */
readonly embedderId?: string;
/** `'memory'` (conversation recall) or `'rag'` (corpus retrieval), when the
* definition declared which claim it makes. */
readonly flavor?: MemoryFlavor;
}
/**
* The mounted skill graph's routing posture. The OBJECT's presence means a
* graph is mounted; each absent field means that half was not declared.
*/
export interface RunConfiguredSkillGraphPayload {
/** The mount's `strictness` — how much authority the graph has over the
* model's own picks. Absent for a graph mounted without the turn-start
* cascade options (routing then works exactly as it did in 9.16). */
readonly routing?: 'assist' | 'guard' | 'rails';
/** Whether the cursor survives the turn. Absent for the same reason. */
readonly continuity?: 'turn' | 'conversation';
/**
* `IntentScorer.name` — the `.classify()` scorer that routes turn starts.
* Absent when the graph declares no classifier, INCLUDING a graph routed by
* `entryBy()` / `entryByRelevance()`: those hand the agent a bound
* `scoreEntries` function and never the strategy that owns it, so there is
* no name here to report. `skill.turn_routed.scorer` names the scorer that
* really ran, whichever door it came through.
*/
readonly scorer?: string;
}
/**
* The artifacts wiring. Presence means a store is configured; like
* `MemoryStore`, `ArtifactStore` declares no id, so WHICH store is not named.
*/
export interface RunConfiguredArtifactsPayload {
/** True when the placement dial is on — oversized tool results travel to
* the model as a claim ticket instead of inline text. The threshold is a
* number, not a name, so it is not carried. */
readonly placement?: true;
/** True when each run is filed as a recording artifact. */
readonly recordings?: true;
}
/**
* One applied recipe — the declared, versioned composition that produced part
* of this agent (9.48.0).
*
* The pair is what makes the row worth reading: two runs of `support-desk` that
* answered differently are a mystery until the record says one was `1.2.0` and
* the other `1.3.0`. The recipe's `description` is deliberately not here — it
* is prose about the composition, and the manifest carries what a consumer
* BRANCHES on.
*/
export interface RunConfiguredRecipePayload {
/** The composition's plain name, e.g. `'support-desk'`. */
readonly id: string;
/** Its SemVer version, e.g. `'1.2.0'`. Kept a SEPARATE field from `id` —
* a composed `'support-desk@1.2.0'` would be one string that two different
* pairs could produce as soon as either half may contain the separator. */
readonly version: string;
}
/**
* The run-configuration manifest — ONE event at run start naming which
* adapters and strategies this run is about to use.
*
* ## Why it exists
*
* Swapping a strategy in this library is easy; ATTRIBUTING an outcome to it
* was not. Every other event carries `meta.runId`, and the numbers are already
* there (tokens, latency, iterations, routing, refusals, evidence verdicts) —
* what was missing was the JOIN KEY that says which of them belong to the same
* ARM. Two ports used to name what varied (`skill.turn_routed.scorer`,
* `memory.retrieved.strategy`); everything else was anonymous, and grouping N
* runs into labelled arms was the experimenter's own bookkeeping, differently
* spelled in every study. Group by this payload and the arms name themselves.
*
* ## Names and ids ONLY — the hard rule
*
* No credentials, endpoints, connection strings, keys, tenants or principals,
* and no config VALUE that could be one. A manifest that leaks an endpoint is
* worse than no manifest: it travels into every recording, every vendor sink
* and every shared trace, which is precisely where a secret must not be. When
* the only handle on a component is a value (a directory, a URL, a table), the
* component is reported as PRESENT and left unnamed. Pinned by test.
*
* ## Absence is a fact, never a guess
*
* An absent field means "not configured" or "the strategy did not say" — never
* `'default'` or `'unknown'` filled in on a reader's behalf, the same
* distinction `CostTickPayload.provider` already makes. `memories: []` is the
* explicit "no memory is mounted".
*
* ## What it costs
*
* One small event per run, by design — the manifest of a run that emitted no
* manifest cannot be recovered afterwards. It carries names, never lists that
* grow with the workload (tool names ride `tools.offered`, injections ride
* `context.injected`), so its size is bounded by the agent's CONFIGURATION and
* not by the turn. Skipped entirely when nothing is listening.
*
* @example Group two runs into two arms
* ```ts
* const arms = new Map<string, string>(); // runId → arm label
* agent.on('agentfootprint.agent.run_configured', (e) => {
* arms.set(e.meta.runId, `${e.payload.llm.model}/${e.payload.memories[0]?.retrieval ?? 'no-retrieval'}`);
* });
* agent.on('agentfootprint.agent.turn_end', (e) => {
* record(arms.get(e.meta.runId), e.payload.durationMs);
* });
* ```
*/
export interface AgentRunConfiguredPayload {
/** The agent's stable id — the same one `meta.compositionPath` carries.
* A study that runs two differently-configured agents groups on this. */
readonly agentId: string;
readonly llm: RunConfiguredLlmPayload;
/** The configured cache strategy cannot apply the declared policy.
* Configuration metadata only; absent when caching is off or supported. */
readonly cache?: {
readonly requested: 'explicit' | 'default';
readonly strategy: 'none';
readonly status: 'not-applicable';
};
/** Which chart shape ran the loop. A strategy choice with its own
* behaviour, not a cosmetic one (see `AgentOptions.reactMode`). */
readonly reactMode: 'classic' | 'dynamic' | 'dynamic-grouped';
/** Every mounted memory, declaration order. `[]` means none is mounted —
* stated rather than omitted, because "no memory" is itself an arm. */
readonly memories: readonly RunConfiguredMemoryPayload[];
/** `WindowStrategy.name` — the one strategy `.window()` or `.compaction()`
* mounted (both doors set the same strategy). Absent = no window stage. */
readonly window?: string;
readonly skillGraph?: RunConfiguredSkillGraphPayload;
/** The evidence gate's posture (`.namesAndNumbersFromEvidence()`).
* Presence means the gate is on; absence means it is not mounted. */
readonly evidenceGate?: 'assist' | 'guard' | 'rails';
readonly artifacts?: RunConfiguredArtifactsPayload;
/**
* The recipes `.recipe()` applied, in declaration order — which declared,
* versioned composition produced this agent (9.48.0).
*
* ABSENT when none was applied, and that is the one asymmetry with
* {@link memories}: "no memory" is an arm a study compares against, while
* "no recipe" is the state of every agent written before recipes existed,
* and an empty list on all of them would be new bytes in every recording for
* a feature nobody used.
*/
readonly recipes?: readonly RunConfiguredRecipePayload[];
}
export interface AgentOutputSchemaValidationFailedPayload {
/** Validation error message (from Zod / parser), or a fixed sentence when
* `draftWithheld` is true — parser text can quote the draft. */
readonly message: string;
/** Validation stage — JSON parse vs schema validate. Lets dashboards
* distinguish "model emitted prose" (`json-parse`) from "model emitted
* JSON but wrong shape" (`schema-validate`); they trend differently
* under model drift. */
readonly stage: 'json-parse' | 'schema-validate';
/** Failing field path when the parser exposes one (e.g. `'amount.currency'`).
* Only set when `stage === 'schema-validate'`. */
readonly path?: string;
/** The raw string output that failed — useful for narrative entries showing
* "what the model actually said" alongside the validation error. Absent
* when `draftWithheld` is true. */
readonly rawOutput?: string;
/** An output policy governs delivery, and this draft never passed output
* admission: its text (`rawOutput`, and the parser's message) is withheld
* from the event stream. Rules still read it on `validationError`. */
readonly draftWithheld?: true;
/** 1-indexed attempt counter. `1` for the first failure, `2` for the
* retry that also failed, etc. */
readonly attempt: number;
/** Total output-schema failures in this gate execution. Same as
* `validationErrorHistory.length`. Distinct from `attempt` because a
* gate can also retry on non-validation errors (5xx, etc.) — this
* counts ONLY the schema-driven failures. */
readonly cumulativeRetries: number;
}
export interface ErrorRetriedPayload {
readonly attempt: number;
readonly maxAttempts: number;
readonly lastError: string;
/** How long the decorator waited before the attempt. */
readonly backoffMs: number;
readonly reason: string;
/**
* The wait the failure STATED (Retry-After, a gateway's "try again in N
* seconds"), in ms. Absent when it stated none; `backoffMs` is max(the
* schedule, this), capped by `maxDelayMs`.
*/
readonly statedWaitMs?: number;
}
export interface ErrorRecoveredPayload {
readonly attempt: number;
readonly totalDurationMs: number;
}
/**
* v9.32 — `withCircuitBreaker` moved between states.
*
* The gap this fills, named: until 9.32 the breaker reported nothing at all
* through the in-run channel. `onStateChange` was the only way to see a trip,
* and that is a consumer-level callback where a run's correlation ids are not
* available — so a breaker that opened mid-run was invisible in the trace
* beside the tool calls it stopped. An independent reviewer (2026-08-13, on a
* local harness of scripted failures) watched a breaker open after two
* failures, serve the next request from fallback, half-open after cooldown and
* close after two probes: every step correct, every step off the record.
*
* Transitions ONLY. A hundred requests rejected while the breaker is already
* open produce zero events — a re-entry into the same state is not a change,
* and reporting it would turn a state log into a request log.
*
* On the `error.*` domain because that is where the provider decorators
* already report; `reliability.*` is the separate rules-based loop.
*/
export interface ErrorCircuitChangedPayload {
/** The state entered. */
readonly state: 'closed' | 'open' | 'half-open';
/**
* WHY, in the breaker's own words — `'3 consecutive failures'`,
* `'cooldown elapsed'`, `'2 probe successes'`, `'half-open probe failed'`.
*
* Never an error's message: the failure that tripped it is reported by
* whoever threw it, and copying it here would attribute one vendor's text to
* a state machine that only counted.
*/
readonly reason: string;
/** WHICH provider this breaker wraps — `inner.name`, not a composite chain
* name. Two breakers under one fallback produce one stream, and this is
* what tells them apart. */
readonly providerName: string;
}
/**
* Fired when the rules loop (or the reliability gate chart) gives up via
* a `fail-fast` decision. Superset shape: `phase`/`kind`/`attempt` are
* always present; the remaining fields are populated by whichever site
* emits (the loop carries `label`/`providerUsed`/`errorKind`; the gate
* chart carries `reason`).
*/
export interface ReliabilityFailFastPayload {
readonly phase: 'pre-check' | 'post-decide';
/** The matched rule's `kind` (machine-readable bucket). */
readonly kind: string;
/** 1-indexed attempt counter at the point of failure. */
readonly attempt: number;
/** Human-readable label of the matched rule (loop sites). */
readonly label?: string;
/** Free-form reason string (gate-chart sites). */
readonly reason?: string;
/** Provider in use when the loop failed fast. */
readonly providerUsed?: string;
/** Classification of the failure being failed-fast on. */
readonly errorKind?: string;
/** Originating error message, when present. Absent for a schema failure
* under an output policy — the parser's message can quote the draft. */
readonly errorMessage?: string;
}
/**
* Fired each time the rules loop decides to RETRY after a failed attempt
* — `action` distinguishes a same-provider retry from a provider failover.
*/
export interface ReliabilityRetriedPayload {
/** 1-indexed counter of the attempt that just FAILED and is being retried. */
readonly attempt: number;
/** `retry` = same provider again; `retry-other` = switch provider. */
readonly action: 'retry' | 'retry-other';
/** Classification of the failure being retried. */
readonly errorKind: string;
/** Originating error message, when present. Absent for a schema failure
* under an output policy — the parser's message can quote the draft. */
readonly errorMessage?: string;
/** Provider that just failed. */
readonly fromProvider: string;
/** Provider the NEXT attempt will use (equals `fromProvider` for `retry`). */
readonly toProvider: string;
}
/**
* Fired when the rules loop produces a successful response AFTER one or
* more failed attempts (self-healed). `recoveredVia` names the mechanism
* of the final successful step.
*/
export interface ReliabilityRecoveredPayload {
/** 1-indexed attempt number that finally succeeded. */
readonly attempt: number;
/** How recovery happened. */
readonly recoveredVia: 'retry' | 'retry-other' | 'fallback';
/** How many attempts failed before this success. */
readonly priorFailures: number;
/** Classification of the LAST failure before recovery. */
readonly errorKind: string;
}
/**
* Tier 2 engaged: `parseOutput()` / `runTyped()` could not validate the
* model's answer against `outputSchema`, and the consumer-supplied
* `fallback` function is about to run. Fires BEFORE the fallback executes,
* so it is a record of degradation starting — not of it succeeding.
*/
export interface ResilienceOutputFallbackTriggeredPayload {
/** Which half of validation failed: bad JSON, or good JSON of the wrong shape. */
readonly stage: 'json-parse' | 'schema-validate';
/** First 200 chars of the model's raw answer. Truncated by construction —
* never the whole output. */
readonly rawOutputPreview: string;
/** Message of the `OutputSchemaError` that opened the ladder. */
readonly primaryErrorMessage: string;
/** Corrective re-asks this run already paid for before the answer reached
* the ladder (the `outputAttempts` ledger minus the first attempt).
* ABSENT when the caller parsed a string that did not come from this
* agent's last run — absent means unknown, never zero. */
readonly retriesSpent?: number;
}
/**
* Tier 3 engaged: the fallback function itself failed (threw, or returned a
* value that also failed the schema) and the static `canned` value is being
* returned instead. This is the event that says a run billed real tokens and
* ended in a constant — with `canned` configured, `runTyped()` is
* structurally unable to throw, so this event is the ONLY signal.
*/
export interface ResilienceOutputCannedUsedPayload {
/** First 200 chars of the model's raw answer. Truncated by construction. */
readonly rawOutputPreview: string;
/** Why tier 2 did not hold — the fallback's own throw, or the validation
* error its return value produced. */
readonly fallbackErrorMessage: string;
/** Corrective re-asks this run already paid for. ABSENT when unknown. */
readonly retriesSpent?: number;
}
export interface EmbeddingGeneratedPayload {
readonly model: string;
readonly provider: 'openai' | 'cohere' | 'voyage' | 'local' | 'custom';
readonly inputKind: 'query' | 'document';
readonly dimension: number;
readonly count: number;
readonly durationMs: number;
readonly tokensSpent?: number;
}
/**
* Emitted (v2.14) per provider chunk that carries thinking-content
* tokens. Lives in `stream.*` domain — parallel to `stream.token` for
* visible-content tokens.
*
* **Provider behavior:**
* - Anthropic: fires for every `content_block_delta` with
* `delta.type === 'thinking_delta'`. May fire 100s of times per turn.
* - OpenAI o1/o3: NEVER fires (OpenAI doesn't stream reasoning content
* as of early 2026). Only `thinking_end` fires at response completion.
* - Custom providers: fire when `ThinkingHandler.parseChunk()` returns
* a non-empty `thinkingDelta`.
*
* **Default consumer behavior:** thinking_delta events are emitted but
* not surfaced to end users unless a consumer explicitly subscribes to
* this event (e.g. for reasoning-as-it-streams UIs).
*
* **Sensitive data:** `content` is raw model thinking text. Use
* `RedactionPolicy.thinkingPatterns` (Phase 3) to scrub before audit-log
* adapters fire. Same risk profile as `stream.token`.
*/
export interface StreamThinkingDeltaPayload {
readonly iteration: number;
readonly tokenIndex: number;
/** Per-chunk delta text, NOT accumulated. ~10–50 chars typical. */
readonly content: string;
}
/**
* Emitted (v2.14) once per LLM call where thinking blocks were
* produced. Pairs with the leading `stream.thinking_delta` events when
* streaming, OR fires standalone for non-streaming providers (OpenAI).
*
* Use this event for live per-iteration UIs (chat-bubble reasoning
* pills, retry-rate dashboards, telemetry). The `blocks` field carries
* the same content that lands on `LLMMessage.thinkingBlocks` — read it
* here for live display instead of post-walking `scope.history` after
* the run completes (the framework's "collect during traversal" rule).
*
* **`tokens` field population:**
* - Anthropic: `undefined` currently — Anthropic's `response.usage`
* doesn't break out thinking tokens (bundled in `output_tokens`).
* May change in future Anthropic API revisions.
* - OpenAI o1/o3: populated from
* `response.usage.completion_tokens_details.reasoning_tokens`.
* - Custom providers: populated when handler computes it during
* `normalize()`.
*
* **Sensitive data:** the `blocks` field carries reasoning content.
* Same risk profile as `stream.token` — wildcard (`*`) recorders
* piping to external sinks (Datadog, CloudWatch, OTel) will see this.
* Treat thinking content with the same redaction posture you give
* visible response tokens. `providerMeta` is already stripped by the
* framework before persistence (Phase 6 invariant), so the blocks
* here match the audit-log surface bytes-exactly.
*/
export interface StreamThinkingEndPayload {
readonly iteration: number;
readonly blockCount: number;
readonly totalChars: number;
readonly tokens?: number;
/**
* v2.14+ — the normalized thinking blocks for this LLM call.
*
* Same data the framework persists to `LLMMessage.thinkingBlocks`
* (post-`providerMeta` strip). Lets live consumers render the
* model's chain-of-thought per iteration without scope-walking
* after the run.
*
* Empty / undefined when no thinking content was produced this
* call (handler returned `[]`). Non-empty when at least one
* thinking or redacted_thinking block landed.
*/
readonly blocks?: readonly ThinkingBlock[];
}
/**
* Emitted (v2.14) when a `ThinkingHandler.normalize()` call throws.
* The framework catches the throw, drops the thinking blocks (they
* don't land on `LLMMessage.thinkingBlocks`), and continues the agent
* run. Same graceful-failure pattern as v2.11.6
* `tools.discovery_failed`.
*
* Lives in `agent.*` domain (NOT `stream.*`) because parse failure is
* a turn-level error concern — recovery happens at the agent loop
* level, not at the SDK call level.
*
* **Anti-pattern (provider authors):** sanitize error messages before
* throwing. NEVER include raw unparsed thinking content in the error
* — the message ends up in audit logs and can leak reasoning content
* the consumer expected to be redacted. Same guidance as
* `tools.discovery_failed.error`.
*/
export interface AgentThinkingParseFailedPayload {
readonly providerName: string;
readonly subflowId: string;
readonly error: string;
readonly errorName: string;
readonly iteration: number;
}
/** One completed-turn thinking block omitted before a provider request. */
export interface AgentThinkingOmittedPayload {
readonly providerName: string;
readonly model: string;
readonly messageIndex: number;
readonly blockIndex: number;
readonly reason: import('../thinking/replay.js').ThinkingReplayReason;
/** Preparation is observable even when the subsequent transport fails. */
readonly phase: 'prepared';
}
/** A payload was checked in and the store minted its claim ticket — by a
* tool (`tool` names it), or by no tool at all (see {@link tool}). */
export interface ArtifactMintedPayload {
readonly ref: string;
/** Consumer vocabulary: 'dataset/rows', 'chart/spec', … */
readonly kind: string;
readonly mediaType: string;
readonly bytes: number;
readonly label?: string;
/** `sha-256:<hex>` when the put asked for one. Metadata, never the key. */
readonly digest?: string;
/** Unix ms when the ref stops resolving — stated at mint, never sprung. */
readonly expiresAt?: number;
/** The join to the trace: the run and tool call that minted it. */
readonly origin?: ArtifactOrigin;
/** Derivation facts — validated at mint, so they cannot dangle at birth. */
readonly parentRefs?: readonly string[];
/** The rows' declared time axis — validated at mint (see `ArtifactMeta.timeAxis`). */
readonly timeAxis?: DatasetTimeAxis;
/** The tool whose execute minted it. ABSENT when no tool did — the run's own
* recording (9.26.0, the `recordings` dial) or a host filing for its turn
* through `HostReply.turnArtifacts`; the event's `meta.sessionId` then names
* the session the host filed for. Typed as always-present until now, and
* already absent at run time since 9.26.0 — the 9.23.0 `resolved` /
* `refused` precedent. Naming a phantom tool would be an actor a dashboard
* groups by that does not exist. */
readonly tool?: string;
}
/** A ref was redeemed — described (`head`) or read (`get`) — under scope. */
export interface ArtifactResolvedPayload {
readonly ref: string;
/** `head` is the render-by-ref decision; `get` pays for the payload. */
readonly via: 'head' | 'get';
readonly kind: string;
readonly bytes: number;
/** The tool that redeemed it. ABSENT when the redemption came through the
* hosting door instead of a tool (9.23.0) — a screen resolving
* `artifact-head` / `artifact-get` over the wire, under the requesting
* session's identity. Stamping a phantom tool name there would be a
* dashboard grouping by an actor that does not exist. */
readonly tool?: string;
}
/** An artifact left the store without its owner asking — the calendar (ttl)
* or a budget (max-bytes / max-count). Reported by the put that swept it. */
export interface ArtifactExpiredPayload {
readonly ref: string;
/** One vocabulary, defined in `artifacts/` and imported here — the
* PermissionCapability lesson: two copies of one union can drift. */
readonly reason: ArtifactSweepReason;
readonly kind: string;
readonly bytes: number;
/** The tool whose put discovered/forced the sweep. ABSENT when the put was
* not a tool's — the run's own recording, or a host filing for its turn
* (the {@link ArtifactMintedPayload.tool} rule). */
readonly tool?: string;
}
/** An artifact verb refused — or answered "no data" — and said why. `no-store`
* is the fail-closed capability teaching how to attach a store;
* `missing-or-expired` keeps the API's deliberate ambiguity while the record
* still shows a resolve that found nothing; `digest-mismatch` is integrity
* refusing to deliver corrupt bytes as whole. Since 9.22.0, `op: 'dispatch'`
* is the framework's own door: a tool's declared `wants` argument (or a
* `present` call) that could not be delivered — the tool did not run and the
* model read a teaching refusal listing what CAN resolve
* (`kind-mismatch` names a ref that resolved to the wrong kind). */
export interface ArtifactRefusedPayload {
readonly op: ArtifactOp;
readonly reason: ArtifactRefusalReason;
readonly ref?: string;
/** The refusal sentence, when one was thrown. */
readonly detail?: string;
/** The tool whose call was refused. ABSENT when the refusal answered the
* hosting door instead of a tool (9.23.0) — a wire resolution that found
* nothing (`missing-or-expired`: the one indistinguishable answer for
* missing, expired and another-session's alike) or reached an agent with
* no store (`no-store`). */
readonly tool?: string;
}
/**
* Why a turn's artifact hand-over (`HostReply.turnArtifacts`) did not go
* cleanly — one vocabulary, shared by this event and the ingress record's
* `turnArtifactsFailure`:
*
* - `'hook'` — the host's hook threw or rejected (and not merely by
* re-throwing an operation's own failure, which is counted
* once, as `'operation'`);
* - `'operation'` — a verb started through the hand-over failed, awaited or
* not: a store refusal, an I/O error;
* - `'timeout'` — the hook and the operations it started outran the ceiling
* (`turnArtifactsTimeoutMs`); the binding was revoked and the
* reply delivered, and what was in flight may still land;
* - `'abort'` — the request's own signal fired (the caller hung up) before
* they settled; the same consequences;
* - `'expired'` — a verb was called after its turn ended and was refused.
*/
export type ArtifactHandOverFailureCause = 'hook' | 'operation' | 'timeout' | 'abort' | 'expired';
/**
* A turn's artifact hand-over failed (the host's own filing — a story, the
* person's clicks). The reply was delivered regardless: the hand-over never
* decides it. Emitted on the serving agent for the session it was produced
* for (`meta.sessionId`, and `meta.runId` = the turn's run when it had one).
*
* Class only, never the message: the error came from the host's own code or
* its store, and either may carry anything — META ONLY, like every
* `artifacts.*` payload. `errorClass` is routable without parsing prose (the
* `tools.session_close_failed` precedent).
*/
export interface ArtifactHandOverFailedPayload {
readonly cause: ArtifactHandOverFailureCause;
/** The verb, for `'operation'` and `'expired'`. */
readonly op?: ArtifactOp;
/** Constructor name of what was thrown, when it was an `Error`. */
readonly errorClass?: string;
/** This package's (or the store's) own `code`, when the error carried one. */
readonly errorCode?: string;
}
/** The model handed an artifact to the screen (9.22.0): `present({ ref, as,
* label? })` resolved under the run's scope. `snapshot` is the description
* the claim ticket carries at speak time — `{ kind, mediaType, bytes,
* label }` — the same snapshot stored INSIDE the tool result so a reloaded
* conversation can render an honest placeholder after the artifact expired.
* `as` is the model's consumer vocabulary ('bar-chart', 'table', …), stored
* as data — the component registry that would validate it is a later phase.
* Meta only, never the payload: the screen redeems the ref itself, under
* its own identity. */
export interface ArtifactPresentedPayload {
readonly ref: string;
readonly as: string;
readonly snapshot: {
readonly kind: string;
readonly mediaType: string;
readonly bytes: number;
readonly label?: string;
};
/** The presenting call — the join to the trace. */
readonly toolCallId: string;
readonly iteration: number;
}
/** A mounted map's contributions are riding the calls again (or for the
* first time). `by` is the strength of the evidence the standing rests on
* — `assumed`/`lexical`/`semantic` engagements decay without corroboration;
* `structural`/`explicit` ones stand. `reengaged` marks a recovery from a
* park; `upgraded` marks a guess that real evidence confirmed. The map's own
* cursor is never touched by any of this: engagement is the kernel's axis,
* position is the map's. */
export interface MapEngagedPayload {
readonly mapId: string;
readonly iteration: number;
readonly by: 'explicit' | 'structural' | 'semantic' | 'lexical' | 'assumed';
/** The matched text that founded a guessed engagement (bounded upstream). */
readonly witness?: string;
readonly reengaged?: true;
/** The standing was UPGRADED in place by corroborating evidence (9.59.0).
* Emitted so the record shows the moment a guess became a fact instead of
* silently rewriting `by` and leaving a reviewer to believe it was always
* that strong. */
readonly upgraded?: true;
/** On an `upgraded` or `tenantChanged` change: the cause the engagement was
* FOUNDED on, which never changes for the life of the record. */
readonly foundedBy?: 'explicit' | 'structural' | 'semantic' | 'lexical' | 'assumed';
/** The CURSOR moved to a different member of an already-engaged map (9.59.0)
* — a TENANT CHANGE. Eligibility was re-derived from the new member's own
* evidence rather than inherited from the old one's: a declared edge out of
* an explicitly-requested skill does not hand the next skill a user request
* it never received. `by` is the new member's strength; `foundedBy` still
* names how the engagement began. */
readonly tenantChanged?: true;
/** The member the cursor moved to, on a `tenantChanged` change. */
readonly at?: string;
}
/** A mounted map was PARKED: its contribution was served for `idleCalls`
* consecutive passes in which none of its tools was called while the turn
* went elsewhere — the measured shape of the recorded 30-call stuck turn,
* and all three of those clauses are checked (9.59.0). Its prompt and tools
* stop riding; its cursor stays exactly where the map put it; explicit or
* structural evidence — including the model picking one of its skills by
* name — re-engages it. */
export interface MapParkedPayload {
readonly mapId: string;
readonly iteration: number;
/** Strength of the evidence the parked engagement had rested on. */
readonly by: 'explicit' | 'structural' | 'semantic' | 'lexical' | 'assumed';
readonly idleCalls: number;
readonly witness?: string;
}
/**
* One detected context error, filed as a finding: two channels asserted
* things that cannot both be true, an argument nothing served, an offered
* action whose inputs left scope, a settled action re-executed, or a claim
* the record does not support. The payload IS the finding — uniform across
* seams so findings are a corpus; deduplicated by identity upstream, so
* one defect is one event however many passes re-detect it. Synthetic
* (canary) findings carry `synthetic` and are quarantined from real counts.
*/
export type IntegrityContextErrorPayload = import('../integrity/finding/types.js').ContextError & {
/** The iteration the finding was filed on, when filed mid-loop. */
readonly iteration?: number;
};
/**
* One run's disposition accounting — one row per registered (check, seam),
* filed ONCE at the run boundary. The rows answer the question a findings
* stream cannot: did the checkers RUN? `checked` with zero `findings` is
* health; a registered row with zero encounters while `workExisted` is the
* wiring-rot signal `assertAlive` names in dev posture. Synthetic (canary)
* counts ride each row quarantined from the real numbers.
*/
export interface IntegrityDispositionPayload {
readonly posture: 'observe' | 'dev';
/** Whether the run did any work a checker could have seen (≥1 LLM call). */
readonly workExisted: boolean;
readonly rows: readonly import('../integrity/disposition/types.js').CheckReport[];
}
/**
* One argument value excused by an APP-ASSERTED external ground (9.72.0) —
* the audit trail of the `externalGrounds` door. The choice-seam check found
* the value in none of what the run served; the app's provider vouched for
* it, so no finding was filed, and THIS event is the record of that excusal.
* `source` is the app's own label ('viewer-selection'): the library records
* what the app asserts — verifying the assertion was the app's duty at the
* moment it yielded the entry. Fired per excused value per call, deliberately
* NOT deduplicated the way findings are: a repeated call is a fresh choice.
*/
export interface IntegrityExternalGroundUsedPayload {
readonly toolName: string;
/** The provider's id for the call — what a reader joins to `tool_start`. */
readonly toolCallId: string;
/** Dot-path of the argument leaf the ground excused. */
readonly path: string;
readonly value: string;
/** The app's label for where the verified value came from. */
readonly source: string;
readonly iteration: number;
}
/** One library-written negative statement, shared by delivery and its event. */
export interface DeliveredNegative {
readonly kind: 'none' | 'not-found' | 'complete' | 'unknown';
/** The single public population value derived from admitted searches, never model wording.
* Omitted for multiple or unknown subjects; their individual scopes remain in the statement. */
readonly about?: string | number;
readonly restsOn: readonly string[];
readonly statement: string;
readonly delivery: 'record-composed';
readonly version: string;
}
/** Served negative statements. Codes and identities are diagnostics; the statement is library-written. */
export interface AnswerNegativesPayload {
readonly field: string;
readonly version: string;
readonly delivery: 'as-checked' | 'recorded' | 'fallback';
readonly claims: readonly DeliveredNegative[];
readonly checks: readonly {
readonly id: string;
readonly path: string;
readonly index: number;
readonly kind: 'none' | 'not-found' | 'complete' | 'unknown';
/** Audit-only: none/not-found was normalized over an explicitly declared absence. */
readonly notes?: readonly 'absence-kind-normalized'[];
readonly code: string;
readonly disposition: 'checked-fail' | 'not-applicable' | 'unreachable';
readonly tools: readonly string[];
readonly results: readonly string[];
/** On a `'stale-turn'` check: the turn the cited result was read on, and when
* the read ran (the library's own clock), when it was recorded. */
readonly staleSince?: {
readonly turn: number;
readonly readAt?: string;
};
}[];
readonly coverage: readonly {
readonly tool: string;
readonly checked: number;
}[];
readonly actions: readonly {
readonly tool: string;
readonly outcome: 'ok' | 'error' | 'not-run';
}[];
}
export {};
//# sourceMappingURL=payloads.d.ts.map