agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
400 lines • 20.2 kB
JavaScript
/**
* 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 { engineVersion, libraryVersion } from '../../lib/libraryVersion.js';
import { manifestFromEvents, } 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 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 const FULL_PRIVACY_POLICY_ID = 'agentfootprint.privacy.full.v1';
// ─── Refusals ────────────────────────────────────────────────────────
/**
* 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 class UnsupportedPrivacyModeError extends Error {
code = 'ERR_UNSUPPORTED_PRIVACY_MODE';
mode;
constructor(mode) {
super(`[recording-envelope] privacy mode '${mode}' is not implemented. ` +
`${RECORDING_ENVELOPE_FORMAT} can produce 'full' envelopes only — the bytes exactly ` +
`as recordRun captured them.\n\n` +
`This refuses rather than approximating because the label is what downstream readers ` +
`act on: an archive browser decides what to show, a retention rule decides how long to ` +
`keep it, and a triage tool decides who may open it — all from this field. An envelope ` +
`stamped '${mode}' over un-redacted bytes would be handled with LESS care than one ` +
`that admits it is raw, so silently storing the raw bytes under that label is the ` +
`worse outcome, not the convenient one.\n\n` +
`Either persist with privacy: { mode: 'full' } and hold the result under the rules raw ` +
`run data needs, or minimize/redact EVERY exported channel before persisting. ` +
`recordRun(agent, { boundaryDetail: 'lean' }) removes captured content only from ` +
`BoundaryEvents; the event tail and engine state can still contain raw content. ` +
`serializeTrace/redactContent operate on their own projection, not an arbitrary ` +
`whole recording.`);
this.name = 'UnsupportedPrivacyModeError';
this.mode = mode;
}
}
/**
* 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 class IndeterminateRunFactError extends Error {
code = 'ERR_INDETERMINATE_RUN_FACT';
field;
constructor(field, detail) {
super(`[recording-envelope] cannot determine run.${field}: ${detail}`);
this.name = 'IndeterminateRunFactError';
this.field = field;
}
}
// ─── Reading the recording ───────────────────────────────────────────
const isRecord = (value) => typeof value === 'object' && value !== null;
const isFn = (value) => typeof value === 'function';
/** A live `recordRun` handle is the one that can also report its drop count. */
function asRunRecorder(source) {
return isRecord(source) && isFn(source.toRecording)
? source
: undefined;
}
/**
* Every DISTINCT defined value of one `meta` field across the stream.
*
* Distinct rather than first-wins on purpose. One recording is meant to be one
* run, but a recorder left attached across `run()` and then `resume()` sees two
* run ids, and a stream carrying two principals is two actors. Collapsing
* either to "the first one" is how an archive ends up labelled with the wrong
* actor — so the caller is made to say which, rather than being told a guess.
*/
function distinctMetaValues(events, field) {
const seen = new Set();
for (const event of events) {
const meta = isRecord(event) ? event.meta : undefined;
if (!isRecord(meta))
continue;
const value = meta[field];
if (typeof value === 'string' && value !== '')
seen.add(value);
}
return [...seen];
}
/** The wall clock of the first / last event that carries one, in epoch ms. */
function edgeWallClock(events, edge) {
const ordered = edge === 'first' ? events : [...events].reverse();
for (const event of ordered) {
const meta = isRecord(event) ? event.meta : undefined;
if (!isRecord(meta))
continue;
const ms = meta.wallClockMs;
if (typeof ms === 'number' && Number.isFinite(ms))
return ms;
}
return undefined;
}
/**
* One optional identity field, derived under the never-invent rule.
*
* @returns the single value, or `undefined` meaning ABSENT — which is a real
* answer here ("this run named no tenant"), not a missing one.
*/
function soleIdentity(events, field, stated) {
if (stated !== undefined)
return stated;
const found = distinctMetaValues(events, field);
if (found.length === 0)
return undefined;
if (found.length === 1)
return found[0];
throw new IndeterminateRunFactError(field, `the recording's events carry ${String(found.length)} different values ` +
`(${found.map((v) => JSON.stringify(v)).join(', ')}), so there is no single one to ` +
`stamp. Naming one of them here would put a value in the envelope that is wrong for ` +
`some of the events inside it. Split the recording per ${field}, or state the intended ` +
`one explicitly as run.${field}.`);
}
/** Normalize a caller timestamp to ISO 8601, refusing anything unreadable. */
function toIso(value, field) {
const date = value instanceof Date ? value : typeof value === 'number' ? new Date(value) : new Date(value);
const ms = date.getTime();
if (Number.isNaN(ms)) {
throw new IndeterminateRunFactError(field, `${JSON.stringify(String(value))} is not a readable date. Pass an ISO 8601 string, epoch ` +
`milliseconds, or a Date.`);
}
return date.toISOString();
}
// ─── Building ────────────────────────────────────────────────────────
/** `'full'`, or a named refusal. Never a silent downgrade. */
function resolvePrivacy(privacy) {
const mode = privacy?.mode ?? 'full';
if (mode !== 'full')
throw new UnsupportedPrivacyModeError(String(mode));
return { mode: 'full', policyId: privacy?.policyId ?? FULL_PRIVACY_POLICY_ID };
}
/**
* Where the kept window starts — proven, stated, or honestly ABSENT.
*
* Unlike `droppedEvents` (whose absence would make the envelope's timeline
* claim unverifiable, so a bare Recording is REFUSED there), the offset is
* an alignment aid: an envelope without it is still honest — it says
* "window start unproven" by omission — so a bare Recording resolves to
* `undefined` rather than a refusal.
*/
function resolveFirstRetained(source, stated) {
if (stated !== undefined) {
if (!Number.isInteger(stated) || stated < 0) {
throw new IndeterminateRunFactError('firstRetainedEventIndex', `${JSON.stringify(stated)} is not a stream position. Pass a non-negative integer.`);
}
return stated;
}
const recorder = asRunRecorder(source);
if (recorder !== undefined)
return recorder.firstRetainedEventIndex;
return undefined;
}
/** How many events the cap discarded — proven, or asked for. */
function resolveDroppedEvents(source, stated) {
if (stated !== undefined) {
if (!Number.isInteger(stated) || stated < 0) {
throw new IndeterminateRunFactError('droppedEvents', `${JSON.stringify(stated)} is not a count. Pass a non-negative integer.`);
}
return stated;
}
const recorder = asRunRecorder(source);
if (recorder !== undefined)
return recorder.droppedEvents;
throw new IndeterminateRunFactError('droppedEvents', `a bare Recording carries no drop count — only the live recordRun handle counts what the ` +
`maxEvents cap discarded, and that handle is gone by the time a recording is a plain ` +
`object. Reporting 0 here would turn "we did not look" into "none were dropped", which ` +
`is the difference between a timeline that starts at the run's beginning and one that ` +
`merely appears to. Pass the handle from recordRun(agent) instead of its recording, or ` +
`state run.droppedEvents.`);
}
/**
* 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 function buildRecordingEnvelope(source, options) {
if (!isRecord(source)) {
throw new TypeError(`[recording-envelope] expected a Recording ({ snapshot, events, structure }) or the ` +
`handle from recordRun(agent); received ${typeof source}.`);
}
const facts = options.run;
if (!isRecord(facts) || typeof facts.complete !== 'boolean') {
throw new TypeError(`[recording-envelope] run.complete must be stated as true or false. Nothing in a frozen ` +
`recording says whether it reached the run's end — a crash-handler snapshot and a ` +
`finished run look identical — so this is asked for rather than guessed. Defaulting it ` +
`would make every partial recording claim to be whole.`);
}
// Resolve privacy FIRST: an unsupported mode must refuse before any work is
// done that a caller could mistake for progress toward a stored file.
const privacy = resolvePrivacy(options.privacy);
const recorder = asRunRecorder(source);
const recording = recorder ? recorder.toRecording() : source;
const events = Array.isArray(recording.events)
? recording.events
: [];
const droppedEvents = resolveDroppedEvents(source, facts.droppedEvents);
const firstRetainedEventIndex = resolveFirstRetained(source, facts.firstRetainedEventIndex);
// ── runId ──
let runId;
if (facts.runId !== undefined) {
runId = facts.runId;
}
else {
const found = distinctMetaValues(events, 'runId');
if (found.length === 1) {
runId = found[0];
}
else if (found.length === 0) {
throw new IndeterminateRunFactError('runId', `no event in this recording carries meta.runId${events.length === 0 ? ' (the recording holds no events at all)' : ''}. The snapshot's own runId is deliberately NOT used as a fallback: it is the ` +
`footprintjs engine's id for the run, a different namespace from the ` +
`agentfootprint run id the events carry, so substituting it would make two ` +
`envelopes look comparable when they are not. Record with recordRun(agent) BEFORE ` +
`run(), or state run.runId.`);
}
else {
throw new IndeterminateRunFactError('runId', `this recording spans ${String(found.length)} runs (${found
.map((v) => JSON.stringify(v))
.join(', ')}). A fresh run id is minted per run() AND per resume(), so a recorder ` +
`left attached across both sees two. An envelope names ONE run — split the ` +
`recording, or state which run.runId this archive is filed under.`);
}
}
const sessionId = soleIdentity(events, 'sessionId', facts.sessionId);
const principal = soleIdentity(events, 'principal', facts.principal);
const tenant = soleIdentity(events, 'tenant', facts.tenant);
// ── startedAt ──
let startedAt;
if (facts.startedAt !== undefined) {
startedAt = toIso(facts.startedAt, 'startedAt');
}
else if (droppedEvents > 0) {
// The cap drops the OLDEST events, so the earliest event still held is not
// the one the run started with. Reading a start time off it would be off by
// however much of the run was discarded, silently.
throw new IndeterminateRunFactError('startedAt', `${String(droppedEvents)} event(s) were dropped from the head of this stream, so the ` +
`earliest retained event is NOT the run's first — deriving a start time from it would ` +
`report the moment recording overflowed, not the moment the run began. State ` +
`run.startedAt, or record with a larger maxEvents.`);
}
else {
const first = edgeWallClock(events, 'first');
if (first === undefined) {
throw new IndeterminateRunFactError('startedAt', `no event in this recording carries a wall clock to read a start time from. State ` +
`run.startedAt.`);
}
startedAt = new Date(first).toISOString();
}
// ── endedAt ──
// Only a recording the caller calls COMPLETE gets an end time derived for it.
// In an incomplete recording the last retained event is just where watching
// stopped; the run has no end yet, and absent is the honest answer.
let endedAt;
if (facts.endedAt !== undefined) {
endedAt = toIso(facts.endedAt, 'endedAt');
}
else if (facts.complete) {
const last = edgeWallClock(events, 'last');
if (last !== undefined)
endedAt = new Date(last).toISOString();
}
// ── configuration ──
const manifest = options.configuration?.manifest ??
manifestFromEvents(events);
const agentId = options.configuration?.agentId ?? manifest?.agentId;
const configuration = agentId === undefined && manifest === undefined
? undefined
: { ...(agentId !== undefined && { agentId }), ...(manifest !== undefined && { manifest }) };
// Conditional spreads, not `undefined` values: an absent optional must be an
// absent KEY, or JSON.stringify drops it and the round trip stops being exact.
return {
format: RECORDING_ENVELOPE_FORMAT,
producer: {
agentfootprintVersion: libraryVersion(),
footprintjsVersion: engineVersion(),
},
run: {
runId,
...(sessionId !== undefined && { sessionId }),
...(principal !== undefined && { principal }),
...(tenant !== undefined && { tenant }),
startedAt,
...(endedAt !== undefined && { endedAt }),
complete: facts.complete,
droppedEvents,
...(firstRetainedEventIndex !== undefined && { firstRetainedEventIndex }),
},
...(configuration !== undefined && { configuration }),
privacy,
recording,
};
}
/**
* 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 async function persistRecording(source, options) {
const sink = options.sink;
if (!isRecord(sink) || !isFn(sink.write)) {
throw new TypeError(`[recording-envelope] persistRecording needs a sink with a write(envelope) method — ` +
`e.g. fileRecordingSink({ directory: './run-archive' }).`);
}
return sink.write(buildRecordingEnvelope(source, options));
}
//# sourceMappingURL=recordingEnvelope.js.map