blob: 20b556b8ea703fb3edfcc44329f7db2c7e4f1b33 [file]
import { sha256Hex } from './local-memory.js';
/**
* Returns the identity of the exact provider-visible tool name and arguments.
*
* `t1_after_preflight_v1` already persists the mainline identity bytes for
* stableHash({ toolName, args }). Keep that wire identity stable while using
* strict JSON canonicalization to reject values that mainline would otherwise
* coerce ambiguously. A different hash domain requires a versioned dispatch
* protocol rather than an in-place change to this function.
*
* Only JSON values are accepted. Silently coercing `undefined`, bigint, Date,
* non-finite numbers, accessors, or custom prototypes would create collisions
* between arguments that the provider/runtime did not actually agree on.
*/
export function canonicalToolArgsHash(toolName: string, args: unknown): `sha256:${string}` {
if (toolName.length === 0) throw new Error('Tool argument identity requires a tool name');
// Validation and identity serialization are deliberately separate. Runtime
// events need the strict, lossless serializer below; the persisted v1 T1
// protocol must retain mainline's historical stableHash byte semantics.
stableJsonStringify(args);
const body = stringifyMainlineV1ToolArgsIdentity(toolName, args);
return `sha256:${sha256Hex(body)}`;
}
export function stableJsonStringify(value: unknown): string {
return JSON.stringify(canonicalizeStrictJson(value));
}
/** Compares values with JSON structure while preserving `Object.is` number semantics. */
export function strictJsonEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (typeof left !== typeof right || left === null || right === null) return false;
if (typeof left !== 'object') return false;
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
return left.every((value, index) => strictJsonEqual(value, right[index]));
}
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord).sort();
const rightKeys = Object.keys(rightRecord).sort();
return (
leftKeys.length === rightKeys.length &&
leftKeys.every(
(key, index) =>
key === rightKeys[index] && strictJsonEqual(leftRecord[key], rightRecord[key]),
)
);
}
function canonicalizeStrictJson(value: unknown): unknown {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new Error('Tool arguments must be strict JSON values');
return value;
}
if (Array.isArray(value)) {
if (Object.getPrototypeOf(value) !== Array.prototype) {
throw new Error('Tool arguments must be strict JSON values');
}
const ownKeys = Reflect.ownKeys(value);
if (
ownKeys.length !== value.length + 1 ||
ownKeys.some(
(key) =>
typeof key !== 'string' ||
(key !== 'length' && !isCanonicalArrayIndex(key, value.length)),
)
) {
throw new Error('Tool arguments must be strict JSON values');
}
const result: unknown[] = [];
for (let index = 0; index < value.length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) {
throw new Error('Tool arguments must be strict JSON values');
}
result.push(canonicalizeStrictJson(descriptor.value));
}
return result;
}
if (typeof value !== 'object') throw new Error('Tool arguments must be strict JSON values');
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new Error('Tool arguments must be strict JSON values');
}
const record = value as Record<string, unknown>;
// A null prototype keeps JSON property names such as "__proto__" as data.
// Assigning that key to a normal object would invoke Object.prototype's
// legacy setter and collapse distinct provider arguments to the same hash.
const result = Object.create(null) as Record<string, unknown>;
const keys = Object.keys(record);
if (
Reflect.ownKeys(record).some(
(key) => typeof key !== 'string' || !Object.getOwnPropertyDescriptor(record, key)?.enumerable,
)
) {
throw new Error('Tool arguments must be strict JSON values');
}
for (const key of keys.sort()) {
const descriptor = Object.getOwnPropertyDescriptor(record, key);
if (!descriptor || !Object.hasOwn(descriptor, 'value')) {
throw new Error('Tool arguments must be strict JSON values');
}
result[key] = canonicalizeStrictJson(descriptor.value);
}
return result;
}
function isCanonicalArrayIndex(key: string, length: number): boolean {
if (!/^(0|[1-9]\d*)$/.test(key)) return false;
const index = Number(key);
return Number.isSafeInteger(index) && index >= 0 && index < length && String(index) === key;
}
function stringifyMainlineV1ToolArgsIdentity(toolName: string, args: unknown): string {
return JSON.stringify(canonicalizeMainlineV1({ toolName, args }));
}
function canonicalizeMainlineV1(value: unknown, parentKey?: string): unknown {
if (
value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value;
}
if (Array.isArray(value)) {
const items = value.map((item) => canonicalizeMainlineV1(item));
return parentKey === 'required' || parentKey === 'enum'
? items
.slice()
.sort((a, b) =>
JSON.stringify(canonicalizeMainlineV1(a)).localeCompare(
JSON.stringify(canonicalizeMainlineV1(b)),
),
)
: items;
}
const record = value as Record<string, unknown>;
const result: Record<string, unknown> = {};
for (const key of Object.keys(record).sort()) {
// Mainline v1 assigned into a normal object, so this key invoked the
// legacy Object.prototype setter and was absent from the serialized bytes.
// Skip it explicitly to freeze those bytes without mutating a prototype.
if (key === '__proto__') continue;
result[key] = canonicalizeMainlineV1(record[key], key);
}
return result;
}
/**
* The same value with every `undefined`-valued property removed.
*
* Provider metadata is handed to Maka as the SDK parsed it, and a field the
* response did not carry arrives as an explicit `undefined` — Anthropic's
* `caller` object comes through as `{ type: 'direct', toolId: undefined }` when
* there is no tool id. JSON drops such a property, so the value no longer
* round-trips, and `encodeCanonicalRuntimeEvent` refuses it. That refusal is
* correct: an immutable event must mean the same thing after it is read back.
*
* The cost of not doing this was total. The refused write marked the runtime
* event store unavailable, the turn's terminal write then threw, and every turn
* that called any tool died a tenth of a second after the tool returned —
* `load_tools` succeeded, reported the group loaded, and the turn ended there.
*
* Dropping the key is lossless in the only sense that matters: JSON cannot tell
* an absent property from one set to `undefined`, so this writes down what
* would have been persisted anyway. The same reasoning gives an array hole a
* `null` rather than a removal — JSON writes one there regardless, and removing
* the entry would shift everything after it.
*
* What it does not do: it never rebuilds a value that needed no change, so
* symbol keys and object identity survive untouched on that path; a value it
* does rebuild is a plain-object spread, which keeps symbol keys and loses
* nothing JSON could have seen. An accessor survives that path too, and that
* is not a benefit to lean on: `canonicalizeStrictJson` throws on any getter it
* reaches, so at the call site a surviving getter kills the same write a
* surviving `undefined` would have. Nothing produces one today — provider
* metadata arrives as parsed plain objects — and widening this function to
* rebuild accessors would change what it means for callers that hand it
* anything else. Read this line as "getters are out of scope, and out of
* scope is fatal downstream", not as a shape this makes safe to pass.
*
* It descends into exactly the two shapes the encoder accepts — a plain object
* and a null-prototype one, the shape prototype-safe JSON parsing produces —
* and a null prototype is put back on the rebuilt value, because that prototype
* is what keeps a `__proto__` property as data. Anything with any other
* prototype is returned as-is rather than flattened.
*/
export function stripUndefinedDeep<T>(value: T): T {
if (Array.isArray(value)) {
// An array hole is not a property that can be dropped: JSON writes it as
// `null`, so leaving the position empty produces a value that does not
// round-trip and the encoder refuses it just the same. Writing `null` is
// not inventing a value — it is writing down what would be persisted.
// `map` would not do: it skips holes and leaves them exactly as they were.
let changedEntry = false;
const mapped = Array.from({ length: value.length }, (_unused, index) => {
if (!Object.hasOwn(value, index) || value[index] === undefined) {
changedEntry = true;
return null;
}
const entry = value[index];
const next = stripUndefinedDeep(entry);
if (next !== entry) changedEntry = true;
return next;
});
return (changedEntry ? mapped : value) as unknown as T;
}
if (value === null || typeof value !== 'object') return value;
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) return value;
const record = value as Record<string, unknown>;
const keys = Object.keys(record);
let changed = false;
// Spread rather than assignment, and the prototype restored afterwards: both
// keep a `__proto__` property as the data the encoder will read back.
const out: Record<string, unknown> = { ...record };
if (prototype === null) Object.setPrototypeOf(out, null);
for (const key of keys) {
const entry = record[key];
if (entry === undefined) {
delete out[key];
changed = true;
continue;
}
const next = stripUndefinedDeep(entry);
if (next !== entry) {
out[key] = next;
changed = true;
}
}
// Unchanged values are returned as they came, so the common case — nothing
// needed removing — keeps object identity. The spread above has already read
// any accessor once; returning the original leaves the accessor itself live,
// which the encoder refuses exactly as it refuses a surviving `undefined`.
return (changed ? out : value) as unknown as T;
}