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
TypeScript
/**
* 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;