Agent trajectory
An agent trajectory is the complete, ordered record of what an AI agent run did: every model call, tool proposal, approval, and result, in sequence. Caisson's agent-trajectory package makes that record append-only and engine-neutral, eleven strict event kinds folding into one deterministic projection, with prompt and tool bodies carried only as sha256 digest references, never inlined.
In code
async append(event: TrajectoryEvent): Promise<void> {
const parsed = parseStrict(TrajectoryEvent, event);
const log = runs.get(parsed.runId) ?? [];
const expected = log.length; // the next free (0-based) seq slot for this run
if (parsed.seq === expected) {
log.push(parsed);
runs.set(parsed.runId, log);
return;
}
if (parsed.seq < expected) {
// Already-recorded slot: idempotent iff byte-identical (both sides are schema-parsed, so key
// order is schema-determined and JSON.stringify is a canonical equality), else a rewrite.
const existing = log[parsed.seq];
if (existing !== undefined && stableEqual(existing, parsed)) return;
throw new ConflictError(
"append-only: seq already recorded with different content",
{ runId: parsed.runId, seq: parsed.seq },
);
}
// parsed.seq > expected — a gap; append-only forbids skipping a slot.
throw new ConflictError("append-only: seq gap", {
runId: parsed.runId,
seq: parsed.seq,
expected,
});
}How it holds
Eleven event kinds, one strict schema
TrajectoryEvent is a Zod discriminatedUnion over run.started, run.finished, step.started/finished, model.call, model.usage, tool.proposed/approved/denied/result, and checkpoint, each payload .strict() so an unknown field is rejected at the boundary, not silently carried.
Sensitive bodies never inline, only referenced
Prompt text, tool arguments, tool results, and checkpoint state each carry only a DigestRef ({ digest: sha256-hex, byteLength, encRef? }); the trajectory log itself is safe to persist, replay, and anchor without ever holding the bodies it points at.
Append rejects rewrites and gaps, not just duplicates
append() treats seq as a monotonic 0-based per-run sequence: a repeat of an already-recorded slot with byte-identical content is a no-op (safe retry), a different event at that slot throws ConflictError, and a seq past the next free slot throws as a gap, three distinct outcomes, not one generic reject.
Replay folds to a byte-identical projection regardless of arrival order
project() sorts events by seq before folding, so a shuffled batch (out-of-order stream delivery) resolves to the same canonical RunProjection every time, the step tree, per-billing-status usage totals, and checkpoint marks are a pure function of the log, never of wall-clock or map-iteration order.