UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

63 lines (62 loc) • 5.28 kB
/** * design/80 D-1 §2 (slice 1a.2) — the engine-only **vetted canonical serializer** used to mint a stable, * opaque {@link boundInputHashOf | boundInputHash} over a pending tool call's arguments. * * ## Why a bespoke serializer (and why engine-ONLY) * The package's only runtime deps are typebox + the MCP SDK — there is no canonical/stable JSON serializer, * and `JSON.stringify` is **not** canonical (object key order is insertion order, `-0`/`NaN`/`Infinity` are * mishandled, and it offers no type-tagging). The review r3 ruling (design/80 §7) is the load-bearing * constraint: **never let two runtimes each compute a canonical form and compare** — a `JSON.stringify` * divergence (or a serializer-version drift across a worker upgrade) would produce a false mismatch and * fail-closed a *legitimate* approval. So the boundInputHash is **server-minted opaque**: the engine computes * it ONCE at suspend-mint with THIS serializer, persists it on the checkpoint, and a resume verifies by * **opaque string equality** against the persisted value (it never re-serializes the resume-side args). This * serializer therefore only needs to be *internally deterministic* (same value → same bytes within one * runtime); it is deliberately NOT exported from the package index, so no cross-wire consumer (the SDK / * service) can be tempted to recompute it — they treat the hash as opaque and echo it verbatim. * * ## Canonical form * A total function (never throws) that emits a **type-tagged, length-prefixed** string so distinct values of * distinct types can never collide (`number 1` ≠ `string "1"` ≠ `boolean`, and `{"a":"b"}` ≠ `{"ab":""}`): * - `null` → `N`, `undefined` → `U` (encoded distinctly — `{a:undefined}` must not collide with `{}`) * - boolean → `b1` / `b0` * - number → `n:<canonical>`; finite via `String(x)` (shortest round-trippable, deterministic per value); * `-0` normalized to `0`; `NaN`/`±Infinity` get explicit distinct tokens (never collapsed to `null`) * - bigint → `i:<value>` * - string → `s<byteLen>:<raw>` — **length-prefixed, NOT normalized**: equal inputs map to equal output and * distinct inputs (e.g. a non-NFC string vs its NFC form) map to distinct output. The serializer never * silently Unicode-normalizes (that would let two different argument payloads share one hash). * - array → `a<len>:[<el>…]` — order preserved (semantic) * - object → `o<count>:{<keyLen>:<key>=<val>…}` — **own enumerable keys sorted** by UTF-16 code unit, so * `{a,b}` and `{b,a}` canonicalize identically. (Duplicate keys cannot exist on a live JS object — * last-wins already happened at parse/construction — so they need no special handling here.) * - function / symbol → tagged tokens (`fn` / `y:<desc>`) so the serializer stays total; these never appear * in design/37 post-hook tool args (which are JSON values), but encoding rather than throwing keeps a * weird arg from silently degrading a durable suspend to a non-durable fallback. * - non-JSON OBJECT brands (Date/Map/Set/RegExp/typed arrays/boxed primitives/…) → a **type-discriminator * token carrying their state** (see {@link encodeBranded}). Built-ins keep their state in internal slots, * so encoding them by own enumerable keys alone folded every one of them onto the SAME empty-object form: * `new Date(0)`, `new Date(9e12)`, a `Map`, a `Set` and `{}` all minted one hash, and an equality check * over that hash accepted a substituted payload (false-accept — the direction a binding cannot afford, * since a mismatch is visible and a match is not). Reachable only when a DEPLOYMENT supplies non-JSON * values (model-produced args are JSON; a hook / policy rewrite is the way in). * * ## Compatibility boundary of the brand tokens * JSON values (null/boolean/number/string/array/plain object) hash **byte-identically** to before the * discriminator existed — the brand is read from `Object.prototype.toString`, and everything that reports * `[object Object]` (including a class instance with no `Symbol.toStringTag`, and a cross-realm plain object * whose `Object.prototype` differs) keeps the own-enumerable-keys encoding. Only NEWLY minted hashes over * non-JSON values move. That is safe by construction for the persisted ones: the hash is opaque and * server-minted, a resume compares the persisted string and never re-serializes. */ export declare function canonicalize(value: unknown): string; /** What {@link canonicalize} returns when even its top level cannot be encoded — a reader that must never accept a * constant (the checkpoint row digest) refuses on it. */ export declare const UNCANONICALIZABLE = "<uncanonicalizable>"; /** * design/80 D-1 §2 — the server-minted **opaque** boundInputHash: a SHA-256 (hex) over {@link canonicalize}. * Bound at suspend-mint to the pending tool call's input (`pendingAction.args`) and persisted on the * checkpoint; a resume verifies the echoed value by string equality (never re-hashing the resume-side args). * The hex digest is fixed-length and opaque — exactly what the operator sees / the D-G signer signs. */ export declare function boundInputHashOf(value: unknown): string;