UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

108 lines (107 loc) • 5.97 kB
/** * wireJson — the ONE rule for how an `Error` inside an event reaches a wire. * * An event payload typed `unknown` (a tool's `result`, a hand-built emit) can * carry an `Error` — often one a client library decorated: an axios error * carries `config.headers.authorization`, a fetch wrapper the request it made. * `JSON.stringify` writes an Error's OWN ENUMERABLE properties and nothing * else, so on the synchronous delivery path every sink that serialized an * event (the NDJSON file, CloudWatch / AgentCore, the audit export, X-Ray * metadata, OTel attributes, the console) wrote that bearer token — while the * detached path's `structuredClone` dropped the same properties and wrote * `{}`. Two delivery paths, two byte shapes, and the leaky one was the default. * * The rule, for every Error at any depth: **`{ name, message, code? }` plus a * bounded `cause` chain — nothing else.** `code` is kept only when it is a * string or a number (the classifier every error vocabulary branches on); * `cause` is kept when it is itself an Error (rendered by this rule, at most * {@link MAX_CAUSE_DEPTH} deep, a cycle cut) or a primitive; an object cause is * dropped for the same reason custom properties are. `stack` is dropped — it * was never written (non-enumerable) and it names file paths. * * Both paths use it, so both write the same bytes: * - SYNCHRONOUS: every serializer of the record writes {@link toWireJson} * instead of `JSON.stringify` — the sinks, the browser stream, the * recording artifact and file sink, the bug-report bundle, the tool-result * text (`test/architecture/wireJsonOnly.test.ts` keeps it that way). A * replacer that reads the HOLDER's raw value, so an Error's own `toJSON` * (a real `AxiosError`'s returns its config and stack) never pre-empts the * rule, and every non-Error value serializes exactly as before. * - DETACHED: `strategies/attach.ts · snapshotEvent` renders Errors with * {@link withWireErrors} BEFORE it clones, so what a detached sink * serializes is already the rendered shape — and a detached sink that does * not serialize receives no Error it could read a custom property off. * * @example * ```ts * const err = Object.assign(new Error('upstream 401'), { * code: 'ERR_BAD_REQUEST', * config: { headers: { authorization: 'Bearer …' } }, * }); * toWireJson({ result: err }); * // → '{"result":{"name":"Error","message":"upstream 401","code":"ERR_BAD_REQUEST"}}' * ``` */ /** How many `cause` links are rendered before the chain is cut. */ export declare const MAX_CAUSE_DEPTH = 4; /** What an Error becomes on a wire. */ export interface WireError { readonly name: string; readonly message: string; readonly code?: string | number; readonly cause?: WireError | string | number | boolean; } /** * Is `value` an Error? True when ANY of three checks says so: * - `Error.isError` (Node ≥ 24, browsers): the internal slot — unspoofable, * across realms; false, by spec, for a Proxy around an Error; * - `instanceof Error`: catches that Proxy (its prototype passes through); * - Node's `util.types.isNativeError`: the internal slot again, so an Error * from another realm (`vm`) is caught on Node 20/22 where `Error.isError` * does not exist. Reached through `process.getBuiltinModule` at call time * (no import: this module ships to browsers, where it is simply absent). * The OR makes the answer the same on every runtime the package supports. * Never `Object.prototype.toString`: a plain object with * `[Symbol.toStringTag]: 'Error'` would pass that brand. Never throws — a * revoked Proxy answers `false`. */ export declare function isErrorValue(value: unknown): value is Error; /** * Render one Error by the rule. Never throws: a throwing getter, a `message` * whose `toString` throws, a revoked Proxy — each reads as absent. */ export declare function wireError(error: Error): WireError; /** * The `JSON.stringify` replacer that applies the rule — on the HOLDER's raw * value (`this[key]`), not on `value`. `JSON.stringify` calls `toJSON()` before * the replacer sees anything, and a real `AxiosError`'s `toJSON` returns its * `config` (request headers included) and `stack` as a plain object; a * replacer that read `value` would never see the Error at all. The root holder * is `{ '': value }`, so a root Error is covered too. */ export declare function wireReplacer(this: unknown, key: string, value: unknown): unknown; /** * `JSON.stringify(value)` with every Error rendered by the rule. Same contract * otherwise, to the byte: it returns `undefined` for an unserializable root and * throws where `JSON.stringify` throws (a cycle, a BigInt) — so a sink keeps * its own handling of those. * * One stated difference in READS, not bytes: to see an Error behind a `toJSON`, * an ACCESSOR property (a getter, a Proxy's `get` trap) whose value is an * object is read twice, and a getter that answers differently on its second * read decides whether an Error is rendered. Data properties and primitives are * read once, as `JSON.stringify` reads them. */ export declare function toWireJson(value: unknown, space?: number): string; /** * `value` with every Error it holds (in arrays and objects, at any depth up to * the walk bound) replaced by its {@link WireError} rendering. A value that * holds no Error is returned as the SAME reference — one read-only walk, no * allocation, which is every event but the rare one. A value that holds one is * copied whole (arrays and objects; everything else by reference), so a cycle * lands on the copy, never back on the original that still holds the Error. * Arrays, plain and class objects, Maps and Sets are entered; Dates, RegExps * and binary data are not. Never throws: a value it cannot read (a revoked * Proxy) is left as it is. */ export declare function withWireErrors<T>(value: T): T;