Build agentrewind: a TypeScript library + CLI that records an LLM agent run, replays it deterministically offline, and forks it from any step. It works by intercepting three boundary types — model calls, tool calls, and entropy draws (time/random/uuid) — recording their inputs/outputs, and on replay serving the recorded outputs instead of making live calls.
Invariant that defines correctness: a replayed run MUST produce an identical boundary-event trajectory (same calls, in the same per-lane order, with the same request fingerprints) as the recording, given the same harness code, with zero live model/tool calls.
- Language: TypeScript 5.x,
strict: true. Noanyin any exported signature. - Modules: ESM only. Named exports. No default exports.
- Runtime: Node ≥ 20.
@agentrewind/coreruntime dependencies: NONE. Use Node built-ins only (node:crypto,node:async_hooks,node:fs/promises,node:path,node:zlib). This is a hard requirement.- CLI: MAY use one small argument parser dependency.
- Codec/tests:
@anthropic-ai/sdkas a dev/peer dependency for the first codec and its tests. - Build:
tsup(ortsc+tsc --build). Test runner:vitest. - Versions: Install current stable versions at implementation time. Do NOT hardcode version numbers from this document.
- Determinism: core logic MUST be free of ambient nondeterminism. Any clock/random/uuid the library itself needs MUST be injectable for tests.
pnpm monorepo. Create exactly:
agentrewind/
package.json # workspace root
pnpm-workspace.yaml
tsconfig.base.json
vitest.config.ts
packages/
sdk/ # umbrella: re-exports @agentrewind/core public API
package.json # name "@agentrewind/sdk"; depends on core; bin -> cli
core/ # @agentrewind/core
src/
index.ts # public API surface (§6)
events.ts # event types (§5)
session-store.ts # read/write JSONL + blobs + meta + vault (§5, §11)
fingerprint.ts # canonicalization + hashing (§7)
matcher.ts # PendingStore + resolve() (§8)
record.ts # Session + record-mode ctx (§9)
replay.ts # Replay + replay-mode ctx (§10)
fork.ts # fork engine (§10.4)
context.ts # AgentContext construction for all 3 modes (§9.1)
entropy.ts # clock/random/uuid virtualization (§9.2)
redaction.ts # deterministic redactor + vault (§11)
migrate.ts # schemaVersion migration registry (§5.4)
tokens.ts # tiered token counting (§10.3)
errors.ts # typed error classes (§12)
codec.ts # ProviderCodec interface (§9.5)
test/ # vitest specs (§13 acceptance tests)
codec-anthropic/ # @agentrewind/codec-anthropic
src/index.ts # implements ProviderCodec by reading the installed SDK types
cli/ # @agentrewind/cli
src/index.ts # record/replay/inspect/diff/pack (§12)
- Boundary: an intercepted external interaction — a model call, a tool call, or an entropy draw.
- Lane: an async-execution context id from
AsyncLocalStorage. Root lane ="0". EachPromise.allbranch etc. gets a child lane id. seq: per-lane monotonic integer, assigned at the moment a boundary is initiated (not when it resolves).callSite: a stable string identifying where in the harness a call originated (§8.2).- Fingerprint: SHA-256 hex of the canonicalized, redacted request (§7).
- Session: a recorded run on disk (§5.5).
- Step: an index into the linearized boundary order (root-lane view).
stepTo(n), forkatStep, drift reports all use this index.
export type Hash = string; // 64-char lowercase sha256 hex
export type EventKind =
| "session_start" | "model_call" | "tool_call" | "entropy" | "note" | "session_end";
export interface BaseEvent {
seq: number; // per-lane, assigned at initiation
step: number; // global linearized index (root-lane view)
ts: number; // wall-clock ms; informational only; MUST NOT affect matching
lane: string;
callSite: string;
kind: EventKind;
schemaVersion: number;
}
export interface ModelCallEvent extends BaseEvent {
kind: "model_call";
provider: string;
request: NormalizedRequest; // §9.5
requestHash: Hash;
response?: NormalizedResponse; // present unless `error`
error?: SerializedError;
stream?: ChunkRecord[]; // present iff the call streamed
usage?: Usage;
latencyMs: number;
blobs: Record<string, Hash>; // path-in-event -> blob hash for externalized large fields
}
export interface ToolCallEvent extends BaseEvent {
kind: "tool_call";
name: string;
args: unknown; // serializable per §9.4
argsHash: Hash;
result?: unknown;
error?: SerializedError;
stream?: ChunkRecord[]; // present iff the tool streamed
latencyMs: number;
blobs: Record<string, Hash>;
}
export interface EntropyEvent extends BaseEvent {
kind: "entropy";
source: "clock" | "random" | "uuid";
value: number | string; // exact value to replay
}
export interface NoteEvent extends BaseEvent { kind: "note"; text: string; }
export interface SessionStartEvent extends BaseEvent { kind: "session_start"; meta: SessionMeta; }
export interface SessionEndEvent extends BaseEvent { kind: "session_end"; ok: boolean; }
export type RewindEvent =
| ModelCallEvent | ToolCallEvent | EntropyEvent | NoteEvent
| SessionStartEvent | SessionEndEvent;
export interface ChunkRecord { offsetMs: number; data: unknown; } // data is codec-shaped
export interface Usage { inputTokens: number; outputTokens: number; costUsd?: number; }
export interface SerializedError { name: string; message: string; stack?: string; data?: unknown; }Provider-neutral. Codecs map to/from real SDK shapes. Keep minimal; extend per codec needs.
export interface NormalizedMessage { role: "system" | "user" | "assistant" | "tool"; content: unknown; }
export interface NormalizedRequest {
model: string;
system?: string;
messages: NormalizedMessage[];
params: Record<string, unknown>; // temperature, max_tokens, tools spec, etc.
}
export interface NormalizedResponse {
content: unknown; // codec-defined content blocks
stopReason?: string;
usage?: Usage;
raw?: unknown; // optional original for debugging; NOT used in matching
}export interface SessionMeta {
id: string;
parent?: string; // set on forked sessions
forkedAtStep?: number;
createdAt: number;
agentRewindVersion: string;
schemaVersion: number;
provider: string;
fingerprintMode: "strict" | "structural";
redaction: { enabled: boolean; patterns: string[] };
eventsHash: Hash; // hash of events.jsonl, written on close
}CURRENT_SCHEMA_VERSIONis a constant incore.- On read, if
event.schemaVersion < CURRENT, run registered forward-migrations in order until current. Migrations are pure functions(event) => event. - A breaking format change increments the major version of
coreandCURRENT_SCHEMA_VERSION. Read-migrations MUST be retained for ≥ 1 major version back.
<store>/<session-id>/
meta.json # SessionMeta
events.jsonl # one RewindEvent per line, in record (initiation) order
blobs/<hash> # content-addressed payloads (sha256 of stored bytes)
vault.enc # encrypted secret<->token map (§11); NEVER included in pack output
- A packed session is a single gzip tarball
<name>.rewindcontainingmeta.json,events.jsonl,blobs/— and explicitly NOTvault.enc. - Blobs are addressed by SHA-256 of their stored (post-redaction) bytes. Any event field whose serialized size exceeds
BLOB_THRESHOLD_BYTES(default 16384) MUST be externalized to a blob and referenced inevent.blobs.
export const AgentRewind: {
record(opts: RecordOptions): Session;
replay(sessionPath: string, opts?: ReplayOptions): Promise<Replay>;
};
export interface RecordOptions {
id?: string; // default: generated
store: string; // directory
model: unknown; // live provider client to wrap
tools?: ToolHandlers;
codec: ProviderCodec;
redaction?: RedactionConfig;
fingerprintMode?: "strict" | "structural"; // default "strict"
purityLint?: boolean; // default false
}
export interface ReplayOptions {
model?: unknown; // live client; REQUIRED for fork/passthrough, else omit
tools?: ToolHandlers;
codec: ProviderCodec;
driftPolicy?: "strict" | "warn" | "passthrough"; // default "strict"
}
export interface Session {
readonly id: string;
run<T>(harness: Harness<T>): Promise<T>;
wrapModel(client: unknown): WrappedModel; // low-level alternative to run()
wrapTools(handlers: ToolHandlers): ToolHandlers;
note(text: string): void;
close(): Promise<void>; // flush events/blobs/meta/vault
pack(outPath: string): Promise<void>; // vault excluded
}
export interface Replay {
run<T>(harness: Harness<T>): Promise<T>;
events(): RewindEvent[];
stepTo(n: number): void;
contextAt(n: number): NormalizedMessage[]; // prompt context at step n
diffContext(a: number, b: number): ContextDiff;
fork(opts: ForkOptions): Promise<ForkResult>;
}
export type Harness<T = unknown> = (ctx: AgentContext) => Promise<T>;export interface AgentContext {
model: WrappedModel; // record: live+capture · replay: serve recorded · fork: per-mode
tools: ToolHandlers;
clock(): number; // entropy virtualized HERE
random(): number;
uuid(): string;
env(key: string): string | undefined;
note(text: string): void;
}
export type ToolHandlers = Record<string, (args: any) => Promise<unknown> | AsyncIterable<unknown>>;
export interface WrappedModel {
create(req: unknown, opts?: { site?: string }): Promise<unknown>;
stream(req: unknown, opts?: { site?: string }): AsyncIterable<unknown>;
}- The harness MUST obtain time/randomness/uuids from
ctx, never from globals. (The purity lint and the entropy tests enforce this.) ctx.model/ctx.toolsare the only sanctioned I/O surfaces; any other I/O re-executes on replay and is the user's responsibility (document this).
export interface ForkOptions {
atStep: number;
overrides?: ForkOverrides;
tools?: { onMatch?: "serve-recorded" | "live"; onMiss?: "error" | "stub" | "simulate" | "live" };
goal?: (trace: Trace) => boolean;
sandbox?: Sandbox; // REQUIRED if any policy resolves to "live"
}
export interface ForkOverrides {
system?: string;
model?: string;
transformRequest?: (req: NormalizedRequest, step: number) => NormalizedRequest;
}
export interface ForkResult {
sessionId: string;
reachedGoal?: boolean; // undefined if no goal given
tokensSpent: Usage;
divergedAtStep?: number; // set when onMiss="error" halted the fork
trace: Trace;
}
export interface Trace {
events(): RewindEvent[];
reached(toolName: string, argsMatch?: Record<string, unknown>): boolean;
}
export interface ContextDiff {
added: NormalizedMessage[];
removed: NormalizedMessage[];
tokenDelta: number;
tokenDeltaIsEstimate: boolean;
}
export interface Sandbox { /* opaque in MVP; presence gates "live" policies. */ }fingerprint(req: NormalizedRequest, codec, redactor, mode): Hash — deterministic. Steps, in order:
req = codec.stripVolatile(req)— remove provider-volatile fields (returns a copy; does not mutate).- If
mode === "structural", replace the leaf values (not keys) of fields flagged volatile by the codec with the constant"\u0000". - Apply
redactor.redactDeep(req)— replace secret substrings in all string leaves with deterministic tokens (§11). Same secret → same token, always. - Canonicalize: produce canonical JSON:
- object keys sorted lexicographically (recursively);
- numbers serialized via shortest round-trip (
String(Number(x))),-0→0, rejectNaN/Infinity(throwFingerprintError); - strings NFC-normalized;
- no insignificant whitespace.
return sha256hex(utf8Bytes(canonicalJson)).
Tool calls use the same algorithm over args to produce argsHash.
MUST hold: fingerprint(R) === fingerprint(R') whenever R and R' are logically identical, on both record and replay, regardless of key order, cosmetic number formatting, or secret values.
type ResolveCtx = { kind: "model_call" | "tool_call"; callSite: string; lane: string };
type Resolution = { hit: RewindEvent } | { miss: { fp: Hash; callSite: string } };
class PendingStore {
// Built from a loaded session: events grouped into FIFO buckets keyed by
// primary: `${kind}\u0000${callSite}\u0000${fp}`
// fallback: `${kind}\u0000${fp}`
// Each event is inserted into BOTH its primary and fallback bucket (same object reference).
resolve(fp: Hash, ctx: ResolveCtx): Resolution; // pops from primary bucket if present, else fallback
expectedNextSeq(lane: string): number; // next unconsumed seq within a lane
}resolveMUST pop the matched event from all buckets it appears in (use aconsumedflag to avoid double-serving).- On a hit, if a per-lane sequential check is enabled and
ctx.lane === ev.laneandev.seq !== expectedNextSeq(ev.lane), emit areorderdiagnostic (non-fatal). - On a miss, return
{ miss }; the caller applies the drift policy (replay) or fork-miss policy (fork).
- Explicit
opts.sitepassed toctx.model.create/stream. - A normalized stack-trace fingerprint captured at call time (strip line/column noise; keep function + file basenames).
- Per-
(kind, lane)ordinal counter.
Matching with the primary key (incl. callSite) is a strengthening; the fallback key keeps matching working when callSite is absent or shifts.
Session.run(harness) builds an AgentContext whose:
modelwraps the live client via the codec's intercept points; each call: enter/create a lane (viaAsyncLocalStorage), assign per-laneseqand globalstepat initiation, normalize the request, compute fingerprint, call live, capture the response (assemble stream chunks if streaming), record amodel_callevent, return the live response unchanged to the harness.toolswraps each handler analogously (tool_callevents).clock/random/uuidare recorded via §9.2.env(k)returnsprocess.env[k]and (SHOULD) records anotefor auditing; values that match redaction patterns are tokenized in storage.
close() writes events.jsonl, blobs, vault.enc, then meta.json (with eventsHash).
- Record mode:
clock()calls realDate.now(), records anEntropyEvent{source:"clock", value}, returns it. Same pattern forrandom()(→Math.random()),uuid()(→crypto.randomUUID()). - Replay/fork prefix: each accessor pops the next unconsumed
EntropyEventof the matchingsourcewithin the current lane (FIFO) and returns itsvalue. On miss → drift policy. - This makes any request built from these values reproduce identically (so fingerprints match).
- The codec converts a provider stream into
{ final: NormalizedResponse, chunks: ChunkRecord[] }.chunks[i].offsetMsis relative to stream start. - Replay returns a codec-rebuilt async iterable yielding recorded chunks: instantly by default; if
preserveTimingis set, await eachoffsetMs. The assembledfinalis also available.
- Tool
args/resultMUST be JSON-serializable, or a user-registered (de)serializer MUST handle them. - Streaming tools (async iterables) are recorded as
ChunkRecord[]and re-emitted on replay. - Non-serializable returns (file handles, sockets) → throw
SerializationErrorat record time. Do NOT silently record a broken value.
export interface ProviderCodec {
name: string;
interceptPoints: string[]; // SDK method paths to wrap, e.g. ["messages.create","messages.stream"]
normalizeRequest(raw: unknown): NormalizedRequest;
denormalizeRequest(req: NormalizedRequest): unknown; // rebuild SDK args for live calls (fork/passthrough)
normalizeResponse(raw: unknown): NormalizedResponse;
normalizeStream(rawChunks: unknown[]): { final: NormalizedResponse; chunks: ChunkRecord[] };
rebuildStream(chunks: ChunkRecord[]): AsyncIterable<unknown>;
stripVolatile(req: NormalizedRequest): NormalizedRequest;
volatileLeafPaths(): string[]; // for "structural" mode
extractUsage(resp: NormalizedResponse): Usage | undefined;
applyOverrides(req: NormalizedRequest, o: ForkOverrides, step: number): NormalizedRequest;
}Implementing the Anthropic codec: read the installed
@anthropic-ai/sdktypes and map them. Do NOT infer request/response/stream shapes from this document or from training data — verify against the SDK source/types in the repo.
AgentRewind.replay(path)loads the session, runs migrations, builds aPendingStore.Replay.run(harness)builds a replay-mode ctx:model/tools/entropy accessors callmatcher.resolve. On hit → return the recorded value (responses/tool-results are de-tokenized via the vault first, §11). On miss → drift policy:strict: throwDriftErrorwith{ step, expectedCallSite?, actualFingerprint }.warn: log the drift, attempt fallback-key match, else throw.passthrough: make a live call (REQUIRESopts.model); record nothing.
- Replay MUST make zero live calls under
strict. Un-wrapped harness I/O still executes (documented limitation).
events()returns the migrated event list.stepTo(n)sets an internal cursor;contextAt(n)returnsrequest.messagesof the model call at stepn(throwRangeErrorif stepnis not a model call).diffContext(a,b)returns added/removed messages (structural diff by deep-equal identity of messages) andtokenDelta.
Tiered: (1) exact from event.usage when present; (2) a registered per-model tokenizer if available; (3) Math.ceil(chars/4) fallback. Set ContextDiff.tokenDeltaIsEstimate = true whenever tier 3 is used for either endpoint.
replay.fork(opts):
- Create a child session (
parent = this.id,forkedAtStep = atStep). - Prefix phase (
step < atStep): run in replay-serve mode (entropy replayed, model/tools served from log). This reconstructs harness state. - Tail phase (
step >= atStep):- Model calls: go live via
opts.model(REQUIRED). Applyoverridesthroughcodec.applyOverridesbefore the live call. Record each into the child session; sumusageintotokensSpent. - Tool calls: resolve against the recording.
- On hit: behavior per
tools.onMatch(defaultserve-recorded;liverequires sandbox). - On miss: behavior per
tools.onMiss(defaulterror):error: stop, setdivergedAtStep, return.stub: return a{ __agentrewind_unavailable: true }sentinel.simulate: produce an LLM-generated result, taggedsynthetic: trueon the event. (v1; not MVP.)live: execute for real; requiressandbox. (v1; not MVP.)
- On hit: behavior per
- Tag every tail event with provenance:
recorded|live|stub|synthetic.
- Model calls: go live via
- If
goalgiven, evaluategoal(trace)after the run →reachedGoal. - Return
ForkResult.
Step indexing for atStep uses the same root-lane linearization as stepTo. Behavior of atStep landing inside a concurrent region is best-effort; document it.
export interface RedactionConfig { enabled: boolean; patterns?: RegExp[]; }- Built-in patterns: common API-key/bearer-token shapes, plus values of
process.envthat look secret. redactDeep(obj)walks all string leaves; each matched secret → a deterministic tokenarw:redacted:<sha256(secret).slice(0,12)>. Same secret → same token (so fingerprints are stable).- Vault: an in-session map
token -> secret, persisted tovault.enc(AES-256-GCM; key derived from a per-session random key stored in the OS keychain or a local key file — MVP MAY use a local key file with 0600 perms). - On replay, before a recorded response/tool-result is handed to the harness, run
vault.restore(value)to swap tokens back to real secrets, preserving fidelity locally. pack()MUST excludevault.enc. After packing, fingerprints still match because they are computed over the tokenized form.- "No network egress" applies to the library itself: AgentRewind MUST NOT make any network calls of its own.
Binary agentrewind (alias arw). Commands:
agentrewind inspect <session>— print the event timeline: step, kind, lane, callSite, fingerprint (short), tokens.agentrewind context <session> --step <n>— print the prompt context at step n.agentrewind diff <session> --from <a> --to <b>— print a context diff.agentrewind pack <session> <out.rewind>— produce a vault-excluded bundle; print a redaction summary (counts by pattern) before writing.agentrewind unpack <out.rewind> <dir>— restore a bundle.
Record/replay/fork are driven programmatically (the CLI does not run the user's harness). Exit non-zero on DriftError.
RewindError (base) → DriftError, FingerprintError, SerializationError, MigrationError, CodecError, VaultError. Each carries structured data.
Build in order. A milestone is complete only when its listed vitest tests pass. Tests use a synthetic in-memory codec and a fake model client (deterministic), except where the Anthropic codec is named.
Files: events.ts, session-store.ts, fingerprint.ts, entropy.ts, context.ts, record.ts, codec.ts, errors.ts, migrate.ts; codec-anthropic.
Tests:
fingerprint is stable across key reorder, number formatting, and secret value— two logically identical requests hash equal.record writes events.jsonl + blobs + meta with eventsHash.large payload (> threshold) is externalized to a blob and referenced.entropy: clock/random/uuid are recorded as events in initiation order.non-serializable tool return throws SerializationError at record time.anthropic codec round-trips request/response/stream against real SDK-shaped fixtures.
Files: matcher.ts, replay.ts, tokens.ts, cli.
Tests:
replay of a pure harness reproduces an identical boundary-event trajectory with zero live calls.replay N times is identical every time(determinism property test).in-request entropy: a uuid drawn from ctx and embedded in the prompt yields byte-identical requests on record and replay.duplicate request with two different recorded responses is disambiguated by callSite.concurrent tool calls with shuffled completion order resolve correctly (content+lane keyed).harness control-flow change is reported as DriftError at the correct step.stepTo/contextAt returns the exact messages at step n; diffContext reports added/removed + tokenDelta.cli inspect/context/diff produce correct output on a fixture session.
Files: fork.ts, redaction.ts.
Tests:
tool calls replay from the log (no handler execution).fork(atStep=k, onMiss="error") reaches goal when the tail coincides with the recording.fork halts with divergedAtStep when the forked model issues an unrecorded tool call (onMiss="error").fork model calls go live with overrides applied; tokensSpent counts only the tail.tail events are provenance-tagged (recorded/live/stub).redaction: secret in a model response is tokenized in storage; local replay restores it via vault; packed bundle contains only the token.replay/fork require a live client only when needed; pure strict replay needs none.
Files: @agentrewind/test helper; finalize migrate.ts, session-store.ts pack/unpack.
Tests:
pack excludes vault.enc; unpack restores a replayable session; fingerprints still match.cost accounting: per-step and per-run token/cost totals are correct.@agentrewind/test fromSession() asserts semantic trajectory and surfaces drift as an explained test failure.a vN fixture replays unchanged on vN+1 via the migration registry.
Files: purity-lint module; examples/ sample agent; READMEs.
Tests:
purity lint flags an un-wrapped fs.writeFile during recording with the correct call site.examples/sample-agent: record → step to a bad decision → fork a one-line prompt fix → assert reachedGoal.
- Transport-level (
fetch/node:http) interception — v1. - Fork
onMisspoliciessimulateandlive, andonMatch: "live"/ sandbox execution — v1. - More than one provider codec (ship Anthropic only) — v1.
- Web viewer UI, workspace/filesystem snapshotting — v2.
- Any network feature, telemetry, or account system — never.
All M1–M4 tests pass; examples/sample-agent runs end-to-end; @agentrewind/sdk (umbrella) installs and exposes the §6 API; the CLI binary works; @agentrewind/core has zero runtime dependencies. M5 is desirable but not blocking.