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