UNPKG

agentfootprint

Version:

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

235 lines • 9.61 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.measureRequest = void 0; const boundary = { boundary: 'initial-prepared-request', format: 'json-utf8-v1', }; const CYCLIC = Symbol('cyclic'); const UNSUPPORTED = Symbol('unsupported'); const LIMIT = Symbol('limit'); const MAX_VALUES = 100_000; const MAX_CHARS = 4_000_000; const MAX_DEPTH = 64; /** Internal receipt helper. Read plain data through descriptors so telemetry * does not invoke getters or custom toJSON. Unsupported values fail open with * a reason code, never exception text. Transport adapters remain authoritative * for what they actually serialize; this helper does not validate requests. * * INCREMENTAL. The request's `messages` are measured one element at a time and * the sizes composed (`[` + elements joined by `,` + `]`, spliced into the rest * of the request) — JSON is compositional, so the sum IS the size of the whole. * With a `cache`, a message object already measured this run adds its kept * counts and size instead of being walked again: an agent's request at call k * is call k−1's plus what the loop appended, and measuring it whole every call * grew with the square of the iteration count. Every limit is checked against * the same running counts as a whole walk, so the result — `measured` or the * reason it is `unavailable` — is the one the whole walk gives; pinned by * test/core/agent/receipt/receipt-incremental.test.ts. A cache hit assumes what * the receipt memo assumes: the message's nested values were not edited in * place (`receiptDigests.ts`). */ function measureRequest(request, cache) { const active = new Set(); let visited = 0; let chars = 0; // The highest value-budget demand any check has made — what a kept message // replays as `need` (every LIMIT check on values is `demand > MAX_VALUES`). let peak = 0; const demand = (amount) => { if (amount > peak) peak = amount; if (amount > MAX_VALUES) throw LIMIT; }; const countChars = (length) => { chars += length; if (chars > MAX_CHARS) throw LIMIT; }; const copy = (value, depth = 0) => { demand(++visited); if (value === null || typeof value !== 'object') { if (['bigint', 'function', 'symbol'].includes(typeof value)) throw UNSUPPORTED; if (typeof value === 'string') countChars(value.length); return value; } if (active.has(value)) throw CYCLIC; if (depth >= MAX_DEPTH) throw LIMIT; const array = Array.isArray(value); const prototype = Object.getPrototypeOf(value); if (prototype !== null && prototype !== (array ? Array.prototype : Object.prototype)) { throw UNSUPPORTED; } if (Object.hasOwn(value, 'toJSON') || Object.hasOwn(Object.prototype, 'toJSON') || (array && Object.hasOwn(Array.prototype, 'toJSON'))) throw UNSUPPORTED; active.add(value); let result; if (array) { const items = []; eachItem(value, (item) => items.push(copy(item, depth + 1))); result = items; } else { const keys = Object.getOwnPropertyNames(value); demand(visited + keys.length); const object = Object.create(null); for (const key of keys) { if (depth === 0 && key === 'signal') continue; const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor) throw UNSUPPORTED; if (!descriptor.enumerable) continue; if (!('value' in descriptor)) throw UNSUPPORTED; countChars(key.length); object[key] = depth === 0 && key === 'messages' && Array.isArray(descriptor.value) ? copyMessages(descriptor.value) : copy(descriptor.value, depth + 1); } result = object; } active.delete(value); return result; }; /** Visit an array's elements as JSON reads them — length and holes checked * first, each index's descriptor checked just before its element is. */ const eachItem = (value, visit) => { const length = Object.getOwnPropertyDescriptor(value, 'length')?.value; if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 0) throw UNSUPPORTED; demand(visited + length); // JSON reads inherited indexed values at holes; we decline them rather // than invoking accessors or silently measuring a different array. if ([Array.prototype, Object.prototype].some((parent) => Object.getOwnPropertyNames(parent).some((key) => /^(0|[1-9]\d*)$/.test(key)))) throw UNSUPPORTED; for (let i = 0; i < length; i++) { const descriptor = Object.getOwnPropertyDescriptor(value, String(i)); if (descriptor && !('value' in descriptor)) throw UNSUPPORTED; visit(descriptor?.value); } return length; }; /** The request's `messages` (depth 1): walked like any array, but sized per * element and returned as their composed JSON size — see the header. */ const copyMessages = (value) => { // `copy`'s own preamble for an array at depth 1. demand(++visited); if (active.has(value)) throw CYCLIC; const prototype = Object.getPrototypeOf(value); if (prototype !== null && prototype !== Array.prototype) throw UNSUPPORTED; if (Object.hasOwn(value, 'toJSON') || Object.hasOwn(Object.prototype, 'toJSON') || Object.hasOwn(Array.prototype, 'toJSON')) throw UNSUPPORTED; active.add(value); let jsonChars = 0; let jsonBytes = 0; const length = eachItem(value, (item) => { const size = messageSize(item); jsonChars += size.jsonChars; jsonBytes += size.jsonBytes; }); // `[`, `]` and the commas between elements — one byte each. const punctuation = 2 + Math.max(0, length - 1); active.delete(value); return new Composed({ jsonChars: jsonChars + punctuation, jsonBytes: jsonBytes + punctuation, }); }; /** One element of `messages` (depth 2): replayed from the cache, or walked. */ const messageSize = (item) => { const cacheable = cache !== undefined && item !== null && typeof item === 'object'; const kept = cacheable ? cache.measureOf(item) : undefined; if (kept !== undefined) { demand(visited + kept.need); visited += kept.visited; countChars(kept.chars); return kept.json; } const startVisited = visited; const startChars = chars; const outerPeak = peak; peak = visited; const json = jsonSizeOf(JSON.stringify(copy(item, 2)) ?? 'null'); const measure = { visited: visited - startVisited, chars: chars - startChars, need: peak - startVisited, json, }; peak = Math.max(outerPeak, peak); if (cacheable) cache.keep(item, measure); return measure.json; }; try { if (request === null || typeof request !== 'object' || Array.isArray(request)) throw UNSUPPORTED; const snapshot = copy(request); const size = (value) => { if (value instanceof Composed) return limited(value.size); return limited(jsonSizeOf(JSON.stringify(value))); }; const slots = {}; for (const key of ['systemPrompt', 'messages', 'tools']) { if (snapshot[key] !== undefined) slots[key] = size(snapshot[key]); } return { ...boundary, status: 'measured', total: size(totalOf(snapshot)), slots }; } catch (error) { return { ...boundary, status: 'unavailable', reason: error === CYCLIC ? 'cyclic-value' : error === LIMIT ? 'measurement-limit' : 'unsupported-value', }; } } exports.measureRequest = measureRequest; /** The `messages` slot once measured: its JSON size, never its JSON. */ class Composed { size; constructor(size) { this.size = size; } } /** The whole request's JSON size, with a composed `messages` spliced in: the * rest is serialized with `[]` in its place, and the two characters (and two * bytes) of `[]` are swapped for the slot's own size. */ function totalOf(snapshot) { const messages = snapshot.messages; if (!(messages instanceof Composed)) return snapshot; const rest = jsonSizeOf(JSON.stringify({ ...snapshot, messages: [] })); return new Composed({ jsonChars: rest.jsonChars - 2 + messages.size.jsonChars, jsonBytes: rest.jsonBytes - 2 + messages.size.jsonBytes, }); } function jsonSizeOf(json) { return { jsonChars: json.length, jsonBytes: new TextEncoder().encode(json).byteLength }; } function limited(size) { if (size.jsonChars > MAX_CHARS) throw LIMIT; return size; } //# sourceMappingURL=requestMeasurement.js.map