agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
411 lines • 17.8 kB
JavaScript
;
/**
* auditBounding — HOW the audit chain's bounded mode bounds a value: the
* markers, the sanitizer, the leaves and the combinators every shape is built
* from (`auditShapes.ts` says WHAT each event keeps; `auditKeptByContract.ts`
* names what a record keeps although it may carry a run's value).
*
* Pattern: small pure rules composed into shapes; every rule records what it is
* (`ruleKind`), so a test can walk a shape beside the type it bounds
* and hand every rule what its kind forbids.
* Role: the bounding layer `auditExport({ payloadMode: 'bounded' })` runs
* every payload and every record's meta through (`audit.ts`). No I/O,
* no clock.
* Emits: N/A.
*
* THE RULE, the one `otel.ts` maps spans by: **structure is kept, content is
* a marker**, bounding is CLOSED by default, and every kept value is CHECKED:
*
* - Every registered event has a shape that names every field of its
* payload type (`shapeOf` is exhaustive by type: a field added to a
* payload does not compile until it is classified).
* - A field a shape does not name, a value that is not the declared shape,
* and every field of an event type the registry does not declare, is a
* marker.
* - A type says what a field holds; it cannot say what ARRIVES. So nothing is
* kept on a type's word: a kept leaf checks at runtime the kind its type
* promises — `num` a number, `bool` a boolean, `str` a string, `oneOf` one
* of the words it lists — and anything else is a marker. No container is
* kept whole: an object is a `shapeOf` (or a `recordOf`), a list a
* `listOf`, every child ruled, so a child the type does not declare is a
* marker however it got there (structural typing lets an object carry one).
* The type walker (`test/type-regressions/AuditBoundingDepth.test.ts`)
* holds every rule to the type beneath it; the runtime sweep
* (`test/observability-providers/audit-bounded-sweep.test.ts`) hands every
* rule what its kind forbids.
* - Names are data. A key is kept as an own key of the record, whatever it is
* (a name `Object.prototype` holds included: an assignment would drop
* `__proto__`, or re-prototype the record), and no rule table is ever asked
* for a name it merely inherits.
*
* Content becomes `'[N chars]'` (a string), `'[type: …]'` (anything else),
* `'[keys: …]'` (a free-form record: its key names, never its values), or a
* count (`'[N messages]'`, `'[N blocks]'`, `'[N summaries]'`). A kept string is
* capped at 256 characters, a list at 32 items.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.createToolRunTracker = exports.unknownEventShape = exports.byContract = exports.when = exports.either = exports.nullOr = exports.orList = exports.recordOf = exports.listOf = exports.shapeOf = exports.oneOf = exports.str = exports.bool = exports.num = exports.summariesMarker = exports.blocksMarker = exports.messagesMarker = exports.typeMarker = exports.keysMarker = exports.charsMarker = exports.typeofOf = exports.acceptedKinds = exports.ruleKind = exports.valueKindOf = exports.sanitizeJson = void 0;
const wireJson_js_1 = require("../../lib/wireJson.js");
// ─── JSON sanitization (verbatim mode, and the genesis record) ───────
/** Caps mirror otel.ts's bounding discipline (defense-in-depth — the
* registry payloads are mostly bounded upstream). */
const MAX_STRING_CHARS = 256;
const MAX_LIST_ITEMS = 32;
const MAX_DEPTH = 8;
/** A string capped like every string the chain keeps. */
function capped(text) {
return text.length > MAX_STRING_CHARS ? `${text.slice(0, MAX_STRING_CHARS - 1)}…` : text;
}
/**
* A record whose every key is an OWN data property — `__proto__` included.
* `out[key] = value` would drop a `__proto__` key whose value is not an object
* and re-prototype the record when it is; either way the key is lost.
*/
function ownRecord(entries) {
return Object.fromEntries(entries);
}
/**
* Convert any value to a JSON-safe, size-bounded tree:
* strings capped, arrays capped with an overflow marker, cycles
* broken, depth capped, Date → ISO, bigint → string, non-finite →
* null, functions/symbols/undefined dropped (objects) or null
* (arrays). Every key an own key. Output always canonicalizes without
* throwing.
*/
function sanitizeJson(value, depth = 0, seen = new Set()) {
if (value === null)
return null;
switch (typeof value) {
case 'string':
return capped(value);
case 'number':
return Number.isFinite(value) ? value : null;
case 'boolean':
return value;
case 'bigint':
return String(value);
case 'undefined':
case 'function':
case 'symbol':
return undefined;
default:
break;
}
const obj = value;
if (obj instanceof Date)
return obj.toISOString();
// The wire rule (`lib/wireJson.ts`): name, message, code, cause — never an
// Error's custom properties (an axios error's request headers).
if ((0, wireJson_js_1.isErrorValue)(obj))
return sanitizeJson((0, wireJson_js_1.wireError)(obj), depth, seen);
if (depth >= MAX_DEPTH)
return '[truncated: depth]';
if (seen.has(obj))
return '[circular]';
seen.add(obj);
try {
if (Array.isArray(obj)) {
const list = obj.slice(0, MAX_LIST_ITEMS).map((item) => {
const sanitized = sanitizeJson(item, depth + 1, seen);
return sanitized === undefined ? null : sanitized;
});
if (obj.length > MAX_LIST_ITEMS)
list.push(`…+${obj.length - MAX_LIST_ITEMS} more`);
return list;
}
const entries = [];
for (const [key, entry] of Object.entries(obj)) {
const sanitized = sanitizeJson(entry, depth + 1, seen);
if (sanitized !== undefined)
entries.push([key, sanitized]);
}
return ownRecord(entries);
}
finally {
seen.delete(obj);
}
}
exports.sanitizeJson = sanitizeJson;
/** A value's kind — `undefined` for one JSON has no word for (a function, a symbol, a bigint). */
function valueKindOf(value) {
if (value === null)
return 'null';
if (Array.isArray(value))
return 'array';
switch (typeof value) {
case 'string':
return 'string';
case 'number':
return 'number';
case 'boolean':
return 'boolean';
case 'object':
return 'object';
default:
return undefined;
}
}
exports.valueKindOf = valueKindOf;
const KINDS = new WeakMap();
const described = (rule, kind) => {
KINDS.set(rule, kind);
return rule;
};
/** What a rule is — `undefined` only for a function this module did not build. */
function ruleKind(rule) {
return KINDS.get(rule);
}
exports.ruleKind = ruleKind;
/**
* The value kinds a rule KEEPS something of at its own position — what
* `either` dispatches on. A marker accepts none: it keeps nothing of any value.
*
* @internal
*/
function acceptedKinds(rule) {
const k = KINDS.get(rule);
switch (k?.kind) {
case 'leaf':
return new Set([k.accepts]);
case 'oneOf':
return new Set([...k.values].map((value) => valueKindOf(value)));
case 'shape':
case 'record':
return new Set(['object']);
case 'list':
return new Set(['array']);
case 'orList':
return new Set(['array', ...acceptedKinds(k.item)]);
case 'nullOr':
return new Set(['null', ...acceptedKinds(k.inner)]);
case 'either':
return new Set(k.alternatives.flatMap((alternative) => [...acceptedKinds(alternative)]));
case 'when':
return new Set([...acceptedKinds(k.then), ...acceptedKinds(k.otherwise)]);
case 'byContract':
return acceptedKinds(k.inner);
default:
return new Set();
}
}
exports.acceptedKinds = acceptedKinds;
// ─── Markers — keep nothing of the value ─────────────────────────────
function typeofOf(value) {
return value === null ? 'null' : typeof value;
}
exports.typeofOf = typeofOf;
/** `'[23 chars]'` — length evidence without content. */
function charsMarker(value) {
return typeof value === 'string' ? `[${value.length} chars]` : `[type: ${typeofOf(value)}]`;
}
exports.charsMarker = charsMarker;
/** `'[keys: a, b, c]'` — shape evidence for free-form Records. */
function keysMarker(value) {
if (value === null || typeof value !== 'object')
return `[type: ${typeofOf(value)}]`;
const keys = Object.keys(value).slice(0, MAX_LIST_ITEMS);
return `[keys: ${keys.join(', ')}]`;
}
exports.keysMarker = keysMarker;
/** `'[type: object]'` — what kind of value, nothing of it (a tool's result). */
const typeMarker = (value) => `[type: ${typeofOf(value)}]`;
exports.typeMarker = typeMarker;
/** A list's length, in the words of what it holds; anything else by `charsMarker`. */
const countMarker = (noun) => described((value) => (Array.isArray(value) ? `[${value.length} ${noun}]` : charsMarker(value)), {
kind: 'marker',
marks: 'count',
});
described(charsMarker, { kind: 'marker', marks: 'chars' });
described(keysMarker, { kind: 'marker', marks: 'keys' });
described(exports.typeMarker, { kind: 'marker', marks: 'type' });
/** `'[3 messages]'` — a conversation's history. */
exports.messagesMarker = countMarker('messages');
/** `'[2 blocks]'` — the model's thinking blocks. */
exports.blocksMarker = countMarker('blocks');
/** `'[4 summaries]'` — the previews of what a slot dropped. */
exports.summariesMarker = countMarker('summaries');
// ─── Leaves — a value of the declared kind, checked ──────────────────
/** A count, a duration, a score, an index: kept when it IS a number (non-finite as `null`). */
exports.num = described((value) => typeof value === 'number' ? (Number.isFinite(value) ? value : null) : charsMarker(value), { kind: 'leaf', accepts: 'number' });
/** A flag or a verdict: kept when it IS a boolean. */
exports.bool = described((value) => (typeof value === 'boolean' ? value : charsMarker(value)), { kind: 'leaf', accepts: 'boolean' });
/**
* The record's own word — an id, a name, a code, a label the app declared: kept
* when it IS a string, capped. Where the type is a closed union of words, use
* `oneOf`, which keeps only those words.
*/
exports.str = described((value) => (typeof value === 'string' ? capped(value) : charsMarker(value)), { kind: 'leaf', accepts: 'string' });
/**
* One of the listed words (or numbers, or booleans): kept when the value IS one
* of them — so what it keeps is always a word this table wrote. Anything else
* is a marker.
*/
function oneOf(...values) {
const words = new Set(values);
return described((value) => (words.has(value) ? value : charsMarker(value)), {
kind: 'oneOf',
values: new Set(words),
});
}
exports.oneOf = oneOf;
/**
* An object of a known shape, every field ruled (exhaustive by type, so a field
* added to `T` does not compile until it is). A field the rules do not name is
* a marker: whatever the value carries at runtime is bounded until someone
* decides it is the record's own words. Anything but an object is a marker too.
*/
function shapeOf(rules) {
const byField = new Map(Object.entries(rules));
return described((value) => {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return charsMarker(value);
}
const entries = [];
for (const [key, entry] of Object.entries(value)) {
if (entry === undefined)
continue; // absent, as JSON has it
const bounded = (byField.get(key) ?? charsMarker)(entry);
if (bounded !== undefined)
entries.push([key, bounded]);
}
return ownRecord(entries);
}, { kind: 'shape', fields: new Map(byField) });
}
exports.shapeOf = shapeOf;
/** A list: each entry by `rule`, capped at 32 like every list. Anything else is a marker. */
function listOf(rule) {
return described((value) => {
if (!Array.isArray(value))
return charsMarker(value);
const out = [];
for (let i = 0; i < Math.min(value.length, MAX_LIST_ITEMS); i++) {
out.push(rule(value[i]) ?? null);
}
if (value.length > MAX_LIST_ITEMS)
out.push(`…+${value.length - MAX_LIST_ITEMS} more`);
return out;
}, { kind: 'list', item: rule });
}
exports.listOf = listOf;
/** A map with open keys: every key kept (a key is a name), every value by `rule`. Anything else is a marker. */
function recordOf(rule) {
return described((value) => {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return charsMarker(value);
}
const entries = [];
for (const [key, entry] of Object.entries(value)) {
if (entry === undefined)
continue;
const bounded = rule(entry);
if (bounded !== undefined)
entries.push([key, bounded]);
}
return ownRecord(entries);
}, { kind: 'record', value: rule });
}
exports.recordOf = recordOf;
/** One value, or a list of them, each by `rule` — a guard's declared value is either. */
function orList(rule) {
const list = listOf(rule);
return described((value) => (Array.isArray(value) ? list(value) : rule(value)), {
kind: 'orList',
item: rule,
});
}
exports.orList = orList;
/** `null` stays `null` (a declared "none"); anything else by `rule`. */
function nullOr(rule) {
return described((value) => (value === null ? null : rule(value)), {
kind: 'nullOr',
inner: rule,
});
}
exports.nullOr = nullOr;
/**
* A value of one of several kinds — a library word or a window, a facet word or
* a filter: the first alternative that accepts the value's kind bounds it. A
* value no alternative accepts is a marker.
*/
function either(...alternatives) {
const accepting = alternatives.map((alternative) => ({
rule: alternative,
kinds: acceptedKinds(alternative),
}));
return described((value) => {
const kind = valueKindOf(value);
const chosen = kind === undefined ? undefined : accepting.find((alt) => alt.kinds.has(kind))?.rule;
return (chosen ?? charsMarker)(value);
}, { kind: 'either', alternatives: [...alternatives] });
}
exports.either = either;
/**
* One rule or another, by a test of the value itself, the reason written down —
* the few places a field is structure only for some values (a name this
* strategy has seen run, the engine's own decision evidence).
*/
function when(why, test, then, otherwise) {
return described((value) => (test(value) ? then : otherwise)(value), {
kind: 'when',
why,
then,
otherwise,
});
}
exports.when = when;
/**
* A position a bounded record keeps although its value may come from the run —
* one of the exceptions `auditKeptByContract.ts` names, in one place. The rule
* inside still checks the value like every other: an exception keeps a value of
* its declared kind, never whatever arrives.
*/
function byContract(exception, rule) {
return described((value) => rule(value), { kind: 'byContract', exception, inner: rule });
}
exports.byContract = byContract;
/** An event type the registry does not declare (a consumer's own `emit`): every field a marker. */
exports.unknownEventShape = shapeOf({});
// ─── The tools this strategy has seen run ────────────────────────────
/** Names kept, and calls awaiting their end — each bounded, names and ids only. */
const MAX_TOOL_NAMES = 1024;
const MAX_OPEN_CALLS = 1024;
/**
* Tool names this strategy has seen RUN — a `stream.tool_end` without `error`,
* `notExecuted` or `notDispatched`, matched to its `tool_start` by
* `toolCallId` — so a tool this agent holds, by construction. What a
* `tryInsteadTool` name is recognized against (`otel.ts` keeps the same set).
*/
function createToolRunTracker() {
const open = new Map();
const ran = new Set();
return {
observe(type, payload) {
if (payload === null || typeof payload !== 'object')
return;
const p = payload;
if (typeof p.toolCallId !== 'string' || p.notDispatched !== undefined)
return;
if (type === 'agentfootprint.stream.tool_start' && typeof p.toolName === 'string') {
if (open.size >= MAX_OPEN_CALLS) {
const [oldest] = open.keys();
if (oldest !== undefined)
open.delete(oldest);
}
open.set(p.toolCallId, p.toolName);
return;
}
if (type !== 'agentfootprint.stream.tool_end')
return;
const name = open.get(p.toolCallId);
open.delete(p.toolCallId);
const failed = p.error !== undefined && p.error !== false;
if (name === undefined || failed || p.notExecuted === true)
return;
if (ran.size < MAX_TOOL_NAMES || ran.has(name))
ran.add(name);
},
ran: (name) => ran.has(name),
};
}
exports.createToolRunTracker = createToolRunTracker;
//# sourceMappingURL=auditBounding.js.map