UNPKG

agentfootprint

Version:

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

297 lines • 13.6 kB
"use strict"; /** * hosting/answerAccounts — the machinery behind the `answer-account` wire op: * one host's cache, its single-flight, and the pure step that turns a stored * recording into `{ account, shown }`. * * The op itself — its order, its refusals, its one door fact — lives inside * `standingAgent.ts · answerArtifact`, beside `head` and `get`, so its * ownership path cannot drift from theirs. This file owns only what that * branch must not re-derive per request: * * • **The options, validated at boot.** `declarations` are the HOST's (a bad * one throws when `standingAgent` is called, never per request), the * ceiling and the cache size are numbers that must mean something. * • **The cache key** — `(scope, ref, template-set version, declarations * digest)`. The account is a pure function of the recording and the * declarations, and the template-set version names the words, so a key * that carries all four can never serve stale sentences: a library upgrade * or a declarations change is a miss by construction. * • **Single-flight** — ten concurrent clicks on one answer cause ONE store * read and ONE parse. Joined waiters get the leader's outcome, failure * included. * • **An LRU bounded twice** — by entries (default 32) and by total * serialized size ({@link ANSWER_ACCOUNT_CACHE_MAX_BYTES}). Only an * account is ever cached; a failure is never an entry, so the next request * tries again. * * What it deliberately does NOT own: whether the recording still exists. The * branch asks the store (a SILENT head) before it asks this cache — so a * swept or expired recording is "not available" even while its account sits * here (review R3-S1). A cache that answered first would serve derived record * data past the store's own retention. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.deepFreeze = exports.explainRecording = exports.answerAccounts = exports.ANSWER_ACCOUNT_CACHE_MAX_BYTES = exports.DEFAULT_ANSWER_ACCOUNT_CACHE_ENTRIES = exports.DEFAULT_ANSWER_ACCOUNT_MAX_RECORDING_BYTES = void 0; const node_crypto_1 = require("node:crypto"); const account_js_1 = require("../answer-account/account.js"); const declarations_js_1 = require("../answer-account/declarations.js"); const shown_js_1 = require("../answer-account/shown.js"); const templates_js_1 = require("../answer-account/templates.js"); const recordingPack_js_1 = require("../recorders/observability/recordingPack.js"); const errors_js_1 = require("./errors.js"); /** The default recording ceiling: 16 MiB — above the field median (≈7.5 MB). */ exports.DEFAULT_ANSWER_ACCOUNT_MAX_RECORDING_BYTES = 16 * 1024 * 1024; /** The default number of cached accounts. */ exports.DEFAULT_ANSWER_ACCOUNT_CACHE_ENTRIES = 32; /** The cache's total bound, by the serialized size of what it holds: 8 MiB. */ exports.ANSWER_ACCOUNT_CACHE_MAX_BYTES = 8 * 1024 * 1024; /** * Build one host's service, validating the options. Throws — at boot — naming * the first thing wrong. */ function answerAccounts(options) { const given = readOptions(options); const declarations = validateAtBoot(given.declarations); const maxRecordingBytes = given.maxRecordingBytes ?? exports.DEFAULT_ANSWER_ACCOUNT_MAX_RECORDING_BYTES; const cacheEntries = given.cacheEntries ?? exports.DEFAULT_ANSWER_ACCOUNT_CACHE_ENTRIES; checkCeiling(maxRecordingBytes); checkCacheEntries(cacheEntries); const suffix = `@${templates_js_1.ANSWER_ACCOUNT_TEMPLATE_SET_VERSION}#${declarationsDigest(declarations)}`; /** Insertion order IS recency: a hit re-inserts. */ const cache = new Map(); let cachedBytes = 0; const inFlight = new Map(); function remember(key, body) { if (cacheEntries === 0) return; const bytes = JSON.stringify(body).length; if (bytes > exports.ANSWER_ACCOUNT_CACHE_MAX_BYTES) return; forget(key); cache.set(key, { body, bytes }); cachedBytes += bytes; for (const [oldest, entry] of cache) { if (cache.size <= cacheEntries && cachedBytes <= exports.ANSWER_ACCOUNT_CACHE_MAX_BYTES) break; cache.delete(oldest); cachedBytes -= entry.bytes; } } function forget(key) { const held = cache.get(key); if (held === undefined) return; cache.delete(key); cachedBytes -= held.bytes; } return { maxRecordingBytes, keyFor(scope, ref) { // JSON of a fixed-order tuple: no field value can forge a separator. return `${JSON.stringify([ scope.tenant ?? null, scope.principal ?? null, scope.conversationId, ref, ])}${suffix}`; }, cached(key) { const held = cache.get(key); if (held === undefined) return undefined; cache.delete(key); cache.set(key, held); return held.body; }, compute(key, load) { const joined = inFlight.get(key); if (joined !== undefined) return joined; const leader = (async () => { const loaded = await load(); if (loaded === null) return null; // The ceiling again, on the bytes actually in hand (the branch checked // the ticket's `bytes` before the read; a store may under-report). const bytes = payloadBytes(loaded.data); if (bytes !== undefined && bytes > maxRecordingBytes) { throw new errors_js_1.RecordingTooLargeForAccountError(maxRecordingBytes); } // …and once more on what the payload STANDS FOR: a packed recording is // refused when its plain form is over the ceiling (`readRecording`). const body = explainRecording(loaded, declarations, maxRecordingBytes); if (body !== null) remember(key, body); return body; })(); inFlight.set(key, leader); const settle = () => { inFlight.delete(key); }; leader.then(settle, settle); return leader; }, }; } exports.answerAccounts = answerAccounts; /** * The pure step: a stored recording → `{ account, shown }`, frozen — or `null` * when the payload is not a recording this can read. * * Synchronous on purpose, and the reason for the ceiling: parse + fold + * show-me run on the event loop. Measured on the field recording in the af-3 * worklog. * * The ceiling holds for a PACKED recording too, on the recording it stands for: * the fold and show-me walk the expanded recording as a tree, and a few KB * packed can stand for gigabytes (a value referred to ten times by values each * referred to ten times, …). So a packed payload whose plain form is over * `maxRecordingBytes` is refused exactly as that plain payload would be — * measured over the packed form, before anything is expanded. * * @throws RecordingTooLargeForAccountError for a packed recording whose plain * form is over `maxRecordingBytes`. */ function explainRecording(loaded, declarations, maxRecordingBytes = exports.DEFAULT_ANSWER_ACCOUNT_MAX_RECORDING_BYTES) { const recording = readRecording(loaded.data, maxRecordingBytes); if (recording === null) return null; try { const account = (0, account_js_1.accountForAnswer)(recording, declarations, loaded.runId !== undefined ? { runId: loaded.runId } : undefined); const shown = (0, shown_js_1.showLeaves)(account, recording, declarations); return deepFreeze({ account, shown }); } catch { // `accountForAnswer` throws only on a caller error (a non-object // recording): content it cannot read is counted, never thrown — pinned // over every leaf of real recordings, damaged one at a time // (`test/answer-account/damaged-records.test.ts`). A throw here means this // record cannot be explained, which is the one not-found. return null; } } exports.explainRecording = explainRecording; /** The payload as a recording object — text (how recordings are minted), bytes, or an object; * plain or packed (`recordings: { packed: true }`), which is expanded here only when * its plain form is within `maxBytes`. */ function readRecording(data, maxBytes) { let value = data; try { if (typeof data === 'string') value = JSON.parse(data); else if (data instanceof Uint8Array) value = JSON.parse(new TextDecoder().decode(data)); } catch { return null; } try { value = (0, recordingPack_js_1.unpackRecording)(value, { maxBytes }); } catch (err) { // The same refusal an oversized plain recording gets — never a not-found. if (err instanceof recordingPack_js_1.PackedRecordingTooLargeError) { throw new errors_js_1.RecordingTooLargeForAccountError(maxBytes); } return null; } return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null; } /** * @internal Exported for its test. * * Freeze what the cache hands out. The same object is served to every reader * of that answer; a host that edited one reply would otherwise edit the next. */ function deepFreeze(value, seen = new WeakSet()) { if (typeof value !== 'object' || value === null || seen.has(value)) return value; seen.add(value); // Recurse even into an object that is ALREADY frozen: a shallow freeze // (a library value, a future constant) would otherwise leave mutable // children in the shared cache. The `seen` set ends a cycle. for (const child of Object.values(value)) deepFreeze(child, seen); return Object.isFrozen(value) ? value : Object.freeze(value); } exports.deepFreeze = deepFreeze; /** * The payload's REAL size in bytes, never the ticket's word for it — a custom * store that under-reports `meta.bytes` must not walk a huge payload into * `JSON.parse`. `undefined` for an already-parsed object (nothing left to * parse; the store paid that cost). */ function payloadBytes(data) { if (typeof data === 'string') return Buffer.byteLength(data, 'utf8'); if (data instanceof Uint8Array) return data.byteLength; return undefined; } /** The declarations' CONTENT, independent of key order — the cache key's last part. */ function declarationsDigest(declarations) { return (0, node_crypto_1.createHash)('sha256').update(canonical(declarations)).digest('hex').slice(0, 16); } function canonical(value) { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; if (typeof value === 'object' && value !== null) { const entries = Object.keys(value) .sort() .filter((key) => value[key] !== undefined) .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`); return `{${entries.join(',')}}`; } return JSON.stringify(value) ?? 'null'; } const OPTION_KEYS = new Set([ 'declarations', 'maxRecordingBytes', 'cacheEntries', ]); function readOptions(options) { if (options === true) return {}; if (typeof options !== 'object' || options === null || Array.isArray(options)) { throw new Error(`[hosting] standingAgent was given answerAccounts: ${String(options)}. Pass true to ` + `serve the 'answer-account' op with every default, or an object — ` + `{ declarations?, maxRecordingBytes?, cacheEntries? } — or drop the option.`); } for (const key of Object.keys(options)) { if (!OPTION_KEYS.has(key)) { throw new Error(`[hosting] standingAgent's answerAccounts has an unknown key "${key}". It takes ` + `declarations, maxRecordingBytes and cacheEntries.`); } } return options; } function validateAtBoot(declarations) { try { return (0, declarations_js_1.validateDeclarations)(declarations); } catch (err) { throw new Error(`[hosting] standingAgent's answerAccounts.declarations were refused — ` + `${err instanceof Error ? err.message : String(err)}. They are the host's, checked ` + `once here so no request ever meets a bad one.`); } } function checkCeiling(bytes) { if (!Number.isInteger(bytes) || bytes <= 0) { throw new Error(`[hosting] standingAgent was given answerAccounts.maxRecordingBytes: ${String(bytes)}. ` + `It is the largest recording this host will explain, so it has to be a positive ` + `whole number of bytes — a ceiling of zero refuses everything, and an infinite ` + `one bounds nothing. Drop it for the default ` + `(${exports.DEFAULT_ANSWER_ACCOUNT_MAX_RECORDING_BYTES} bytes).`); } } function checkCacheEntries(entries) { if (!Number.isInteger(entries) || entries < 0) { throw new Error(`[hosting] standingAgent was given answerAccounts.cacheEntries: ${String(entries)}. ` + `It is how many accounts to keep, so it has to be a whole number, 0 or more ` + `(0 keeps none). Drop it for the default (${exports.DEFAULT_ANSWER_ACCOUNT_CACHE_ENTRIES}).`); } } //# sourceMappingURL=answerAccounts.js.map