UNPKG

@tanstack/ai-sandbox

Version:

Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.

186 lines (176 loc) 7.79 kB
/** * Deterministic chunk identity — the prerequisite that makes journal replay * exact. * * The design premise is that re-translating a journal prefix reproduces the * chunks a previous host already delivered, so a successor can recognize and * skip them (see `align.ts`, a later task). Two things in the default path * break that premise: * * 1. `ChatAdapter.generateId()` — `packages/ai/src/activities/chat/adapter.ts:227`, * read directly for this task — is: * * ```ts * protected generateId(): string { * return `${this.name}-${Date.now()}-${Math.random().toString(36).substring(7)}` * } * ``` * * and every harness translator mints message ids through it (wired as * `genId` in the Grok Build, Claude Code, and Codex text adapters). Both * `Date.now()` and `Math.random()` are non-reproducible: replaying the same * journal bytes through a second `generateId()` call produces a different * id every time, so "same bytes ⇒ same chunks" is false on the journaled * path today. {@link createRunScopedIdGen} replaces it with a run-scoped * counter that has neither a clock nor randomness, so two generators built * from the same `runId` always produce the same sequence. * 2. Chunks also carry `timestamp: Date.now()`, which cannot be reproduced at * all, deterministic id or not. {@link chunkFingerprint} therefore excludes * exactly that field — nothing downstream keys on a chunk's timestamp, so * leaving it wall-clock is safe, but every other field must participate in * the comparison or a real divergence would go undetected. * 3. Adapter yields still carry leftover TanStack extras (`content`, `args`, * `finishReason`). The durability log stores spec chunks. Fingerprints keep * only AG-UI spec keys and drop `metadata.tanstack`, so a live adapter yield * matches the stored spec chunk. */ import type { StreamChunk } from '@tanstack/ai' import { isSpecTopLevelKey, tanstackMetadata, } from '@tanstack/ai/adapter-internals' /** * A deterministic id generator scoped to one run. * * Passed as the harness translators' `genId`, so translating the same journal * prefix twice mints the same message ids. The counter is per-generator, so a * replay must create a fresh one and start from the journal's first byte — * which is exactly what the alignment step (a later task) assumes. * * No clock, no `Math.random`, no crypto: `next` is the only state, and it is * seeded fresh for every call to this factory. */ export function createRunScopedIdGen(runId: string): () => string { let next = 0 return () => { const id = `${runId}-${next}` next += 1 return id } } /** * Fields excluded from a fingerprint because they are wall-clock and therefore * unreproducible. Kept as an explicit set so adding one is a deliberate, * reviewable act rather than a silent loosening of the comparison. */ const VOLATILE_FIELDS: ReadonlySet<string> = new Set(['timestamp']) /** * The conversation id every chunk carries. Excluded from * {@link chunkFingerprintIgnoringThreadId} — and ONLY from that variant — so * alignment can tell an id-only mismatch from a real content divergence. */ const THREAD_ID_FIELD = 'threadId' const VOLATILE_AND_THREAD_ID: ReadonlySet<string> = new Set([ ...VOLATILE_FIELDS, THREAD_ID_FIELD, ]) /** * `dropped` applies at the TOP LEVEL only (nested calls pass `undefined`). * A `threadId` nested inside, say, a tool call's arguments is real content and * must keep participating in the comparison. */ function stableStringify( value: unknown, dropped: ReadonlySet<string> | undefined, ): string { if (value === null) return 'null' if (Array.isArray(value)) { return `[${value.map((item) => stableStringify(item, undefined)).join(',')}]` } if (typeof value === 'object') { const record: Record<string, unknown> = value as Record<string, unknown> const keys = Object.keys(record) .filter((key) => dropped === undefined || !dropped.has(key)) .sort() const parts = keys.map((key) => { const entry = record[key] const encoded = entry === undefined ? '"__undefined__"' : stableStringify(entry, undefined) return `${JSON.stringify(key)}:${encoded}` }) return `{${parts.join(',')}}` } const encoded = JSON.stringify(value) return encoded === undefined ? 'null' : encoded } /** * A stable, order-independent identity for a chunk, excluding wall-clock * fields. Used to recognize the chunks a previous host already appended. * * - **Key-order independent**: object keys are sorted before stringifying, so * a JSON round trip through the journal (which does not preserve key order) * cannot spuriously diverge. * - **Recurses into nested arrays and objects**: tool-call arguments are * nested, and a shallow fingerprint would miss a changed argument. * - **Excludes `timestamp` and leftover adapter extras.** Spec keys * participate, including `undefined` values. `metadata.tanstack` is dropped * so stored spec chunks match live adapter yields. * - **Distinguishes present-but-`undefined` from absent**: `undefined` is * encoded as the sentinel string `"__undefined__"` rather than dropped, so * `{a: undefined}` and `{}` do not collide. A translator emitting an * explicit `undefined` is a different chunk shape and must fingerprint * differently. */ function fingerprintableChunk(chunk: StreamChunk): Record<string, unknown> { const out: Record<string, unknown> = {} for (const [key, value] of Object.entries(chunk)) { if (key === 'timestamp') continue if (!isSpecTopLevelKey(chunk.type, key)) continue if (key === 'metadata' && value != null && typeof value === 'object') { const rest: Record<string, unknown> = {} for (const [metaKey, metaValue] of Object.entries(value)) { if (metaKey === 'tanstack') continue rest[metaKey] = metaValue } if (Object.keys(rest).length === 0) continue out.metadata = rest continue } out[key] = value } return out } export function chunkFingerprint(chunk: StreamChunk): string { return stableStringify(fingerprintableChunk(chunk), VOLATILE_FIELDS) } /** * {@link chunkFingerprint} with the chunk's own `threadId` also excluded. * * NOT an alternative identity — never use it to decide that two chunks are the * same. Its single purpose is DIAGNOSIS: when a replay diverges from the stored * log, comparing both fingerprints answers "did the agent behave differently, or * did only the conversation id move?". Two chunks that match here but not under * {@link chunkFingerprint} differ in `threadId` and nothing else, which is a * misconfigured attach route rather than a determinism regression (see * `JournalReplayThreadIdMismatchError` in `align.ts`). */ export function chunkFingerprintIgnoringThreadId(chunk: StreamChunk): string { return stableStringify(fingerprintableChunk(chunk), VOLATILE_AND_THREAD_ID) } /** * A chunk's own `threadId`, or `undefined` when it carries none. * * Reads the field structurally rather than narrowing on `chunk.type`: nearly * every member of the `StreamChunk` union declares `threadId?: string`, and an * exhaustive switch would have to be revisited for each new member while adding * nothing — a chunk with no `threadId` is exactly the `undefined` case. */ export function chunkThreadId(chunk: StreamChunk): string | undefined { const record: Record<string, unknown> = chunk as Record<string, unknown> const value = record[THREAD_ID_FIELD] if (typeof value === 'string') return value const nested = tanstackMetadata(chunk)?.threadId return typeof nested === 'string' ? nested : undefined }