agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
310 lines (309 loc) • 14.1 kB
TypeScript
/**
* recordingEnvelope — the versioned, serialized contract around a recording.
*
* `recordRun` already freezes a run into `{ events, snapshot, structure }`, and
* that shape is exactly right for handing to a viewer in the same process. What
* it is NOT is a thing you can put on disk and read back next quarter: it
* carries no format marker, no producer version, no statement of which run it
* is, and no statement of whether it is the WHOLE run. So every consumer that
* wanted to archive one, attach one to a bug report, or feed one to an analysis
* tool invented its own wrapper — and each wrapper made a different guess about
* the same missing facts.
*
* This is the producer-owned answer: one envelope, one format string, and every
* field either a fact the library can prove or a fact the caller stated.
*
* import { persistRecording, fileRecordingSink } from 'agentfootprint/observe';
*
* const recorder = recordRun(agent);
* await agent.run({ message });
* await persistRecording(recorder, {
* sink: fileRecordingSink({ directory: './run-archive' }),
* run: { complete: true },
* });
*
* ## The rule this file exists to keep: never stamp a fact you had to guess
*
* An envelope is read by people and tools that were not there when the run
* happened. That makes every field a claim, and a claim that turns out to be a
* guess is worse than a missing field — a missing field sends the reader to
* look, a wrong field stops them looking. So each one has a stated source:
*
* runId, sessionId, derived from the EVENT META, which is the only
* principal, tenant place these are recorded, or stated by the caller.
* Never synthesized. See "identity" below.
* startedAt / endedAt derived from event wall clocks, but only where the
* stream can honestly supply them (see below).
* complete CALLER INPUT, always. Nothing in a finished
* recording says whether the run reached its end.
* droppedEvents read off the live recorder, which counts them; a
* bare recording carries no count, so it is asked for
* rather than assumed to be 0.
* configuration lifted from the run's own `run_configured` event —
* the manifest, which is names-and-ids only by law.
* producer read from the package manifests at runtime.
*
* Where a fact is neither derivable nor supplied, this REFUSES
* ({@link IndeterminateRunFactError}) instead of filling in a plausible value.
*
* ## Identity is never invented
*
* `principal` and `tenant` come from `EventMeta`, and `EventMeta`'s own law
* (see src/events/types.ts) is that they are stamped ONLY from an explicit
* `run(input, { identity })` — never from the run's internal identity, and
* never from a session id, because "a conversation id is not an actor". By
* sourcing them from the meta and nowhere else, this envelope inherits that
* guarantee whole: an anonymous run produces an envelope with no `principal`
* key at all, not a placeholder and not a session id wearing an actor's name.
*
* ## `runId` has two namespaces, and this one is deliberate
*
* A footprintjs snapshot carries its OWN engine-level `runId`
* (`<timestamp>-<padded counter>`, minted per `executor.run()`), while the
* agentfootprint events carry `run-<timestamp>-<seq>` from `makeRunId()`. They
* are different ids for different layers and they do not match. This envelope
* uses the EVENT-meta one exclusively, because that is the id `sessionId`,
* `principal` and `tenant` ride beside and the one every typed-event consumer
* correlates on. When the events cannot supply it, this refuses rather than
* falling back to the snapshot's — quietly substituting an id from another
* namespace would make two envelopes look comparable when they are not.
*/
import type { Recording, RunRecorder } from './recordRun.js';
import { type RunManifestLike } from './recordedManifest.js';
/**
* The format marker. Bumped only for a change an older reader could not
* survive — a reader that does not recognise the string must refuse the file,
* never half-read it.
*/
export declare const RECORDING_ENVELOPE_FORMAT = "agentfootprint.recording.v1";
/**
* The privacy policy id a `'full'` envelope carries when the caller names none.
* A stable string so a retention rule can match on it rather than on prose.
*/
export declare const FULL_PRIVACY_POLICY_ID = "agentfootprint.privacy.full.v1";
/**
* What a caller may ASK for. Only `'full'` is implemented in v1; the other two
* are named here so the refusal can name them back, and so a consumer writing
* against a future version gets a compile-time hint rather than a typo.
*/
export type RecordingPrivacyMode = 'full' | 'structure-only' | 'redacted';
/** Versions of the two libraries that produced the bytes. */
export interface RecordingProducer {
/** This library's version, or `'unknown'` when the manifest is unreadable. */
readonly agentfootprintVersion: string;
/** The engine underneath it, or `'unknown'`. */
readonly footprintjsVersion: string;
}
/**
* WHICH run this is, and how much of it this is.
*
* Every field is either derived from the recording's own events or stated by
* the caller — see the module header for the per-field source.
*/
export interface RecordingRun {
/** From `EventMeta.runId` — the agentfootprint run id, not the engine's. */
readonly runId: string;
/** Present only when the run was session-bound. Never invented. */
readonly sessionId?: string;
/** The actor the caller NAMED. Absent for an anonymous run. */
readonly principal?: string;
/** The tenant boundary the caller NAMED. Absent for a single-tenant run. */
readonly tenant?: string;
/** ISO 8601. The first recorded event's wall clock, unless the caller said. */
readonly startedAt: string;
/**
* ISO 8601. Absent when the recording is not `complete` and the caller named
* no end — a run that had not finished has no end time to report.
*/
readonly endedAt?: string;
/**
* Did this recording capture the run through to its end?
*
* ALWAYS the caller's statement. A frozen recording looks identical whether
* it was taken after the run or from a crash handler mid-run, and there is no
* run-terminal event in the registry to check, so the library cannot know.
* Defaulting it to `true` would make every crash dump claim to be whole.
*/
readonly complete: boolean;
/**
* Events discarded to stay under the recorder's `maxEvents` cap. `0` means
* "none were dropped", proven — never "we did not look".
*/
readonly droppedEvents: number;
/**
* The ORIGINAL stream position of the first retained event (9.60.0) — the
* envelope's events are stream positions
* `[firstRetainedEventIndex, firstRetainedEventIndex + events.length)`.
* A drop COUNT says how much is gone; this says WHERE the kept window
* starts, which is what lets a reader align this tail against another
* record of the same run. Present exactly when the source could prove it
* (the live recordRun handle, or the caller's own statement beside
* `droppedEvents`); absent means "not proven", never 0-by-assumption.
*/
readonly firstRetainedEventIndex?: number;
}
/** What the agent was configured as — names and ids only, no values. */
export interface RecordingConfiguration {
readonly agentId?: string;
/**
* The run manifest, as the run itself declared it in
* `agentfootprint.agent.run_configured`.
*
* Safe to archive by construction: the manifest's own law is NAMES AND IDS
* ONLY — no endpoints, directories, connection strings or keys — precisely
* because it was built to ride into recordings and shared traces.
*/
readonly manifest?: RunManifestLike;
}
/** What was done to the bytes before they were stored. */
export interface RecordingPrivacy {
/**
* `'full'` — the recording is exactly what `recordRun` captured, unredacted.
*
* A v1 envelope can say nothing else: the redacting modes are not built, and
* this refuses to produce a label it cannot honour.
*/
readonly mode: 'full';
/** Names the policy the producer applied. See {@link FULL_PRIVACY_POLICY_ID}. */
readonly policyId: string;
}
/**
* One archived run: the recording, plus everything a reader needs to know what
* they are holding.
*
* Plain JSON by construction — no Dates, no Maps, no live handles — so
* `JSON.parse(JSON.stringify(envelope))` is the same envelope. Absent optional
* fields are absent KEYS rather than keys with `undefined`, which is what makes
* that round trip exact.
*/
export interface RecordingEnvelope {
readonly format: typeof RECORDING_ENVELOPE_FORMAT;
readonly producer: RecordingProducer;
readonly run: RecordingRun;
readonly configuration?: RecordingConfiguration;
readonly privacy: RecordingPrivacy;
/** The recording itself, unmodified — `{ snapshot, events, structure }`. */
readonly recording: Recording;
}
/** A timestamp a caller may state, in any of the three obvious spellings. */
export type RecordingTimestamp = string | number | Date;
/** The run facts a caller states — the half the library cannot derive. */
export interface RecordingRunFacts {
/**
* Did the recording capture the run through to its end?
*
* Required, and deliberately so: this is the one field with no derivation and
* no safe default, so the API asks rather than guesses. Say `false` for a
* recording frozen from a crash handler, a timeout, or mid-stream.
*/
readonly complete: boolean;
/** Override the event-derived run id, or supply it when events cannot. */
readonly runId?: string;
readonly sessionId?: string;
readonly principal?: string;
readonly tenant?: string;
readonly startedAt?: RecordingTimestamp;
readonly endedAt?: RecordingTimestamp;
/**
* Events lost to the recorder's cap. Read off a live `recordRun` handle
* automatically; state it when persisting a bare `Recording`, whose shape
* carries no count.
*/
readonly droppedEvents?: number;
/**
* Where the kept event window starts in the original stream. Read off a
* live handle automatically; state it only when persisting a bare
* `Recording` AND you can prove it. Refused if negative or fractional.
*/
readonly firstRetainedEventIndex?: number;
}
/**
* Where an envelope goes. One method, so a destination is a few lines: a
* directory, an object store, a table, an HTTP endpoint.
*/
export interface RecordingSink {
/**
* Store one envelope.
*
* @returns `id` the sink's own handle for what it just stored.
* `uri` where it landed, when the sink has a meaningful address.
*/
write(envelope: RecordingEnvelope): Promise<{
id: string;
uri?: string;
}>;
}
/** Options for {@link persistRecording}. */
export interface PersistRecordingOptions {
readonly sink: RecordingSink;
/**
* The run facts. Required because {@link RecordingRunFacts.complete} is:
* an envelope that did not state whether it holds a whole run is an envelope
* whose reader has to guess.
*/
readonly run: RecordingRunFacts;
/** Override the manifest lifted from the run's own `run_configured` event. */
readonly configuration?: RecordingConfiguration;
readonly privacy?: {
readonly mode?: RecordingPrivacyMode;
readonly policyId?: string;
};
}
/** Options for {@link buildRecordingEnvelope}. */
export type BuildRecordingEnvelopeOptions = Omit<PersistRecordingOptions, 'sink'>;
/** A recording to envelope, or the live handle that is still collecting one. */
export type RecordingSource = Recording | RunRecorder;
/**
* Raised when a privacy mode is asked for that this version cannot honour.
*
* Its own class because the answer is never "retry": a caller catching this has
* to change what they store or how they store it.
*/
export declare class UnsupportedPrivacyModeError extends Error {
readonly code: "ERR_UNSUPPORTED_PRIVACY_MODE";
readonly mode: string;
constructor(mode: string);
}
/**
* Raised when a required run fact is neither derivable from the recording nor
* stated by the caller.
*
* Carries the `field` so a caller can catch it and supply exactly that one.
*/
export declare class IndeterminateRunFactError extends Error {
readonly code: "ERR_INDETERMINATE_RUN_FACT";
readonly field: string;
constructor(field: string, detail: string);
}
/**
* Freeze a recording into an archivable envelope, without storing it.
*
* The half of {@link persistRecording} that has no destination — useful when
* the envelope goes somewhere this library should not know about (a request
* body, a queue), and the unit under test for the contract itself.
*/
export declare function buildRecordingEnvelope(source: RecordingSource, options: BuildRecordingEnvelopeOptions): RecordingEnvelope;
/**
* Build the envelope and hand it to a sink.
*
* @param source the handle from `recordRun(agent)`, or the recording it made.
* Prefer the handle: it is the only thing that knows how many
* events the cap discarded.
* @param options the destination, the run facts, and the privacy statement.
*
* @example
* ```ts
* const recorder = recordRun(agent);
* await agent.run({ message: 'Weather in San Francisco?' });
*
* const { uri } = await persistRecording(recorder, {
* sink: fileRecordingSink({ directory: './run-archive' }),
* run: { complete: true },
* });
* recorder.stop();
* ```
*/
export declare function persistRecording(source: RecordingSource, options: PersistRecordingOptions): Promise<{
id: string;
uri?: string;
}>;