UNPKG

agentfootprint

Version:

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

130 lines (129 loc) • 6.65 kB
/** * 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. */ import type { ArtifactScope } from '../artifacts/types.js'; import type { AnswerAccountDeclarations } from '../answer-account/types.js'; import type { AnswerAccountWireBody } from './artifactWire.js'; /** * Opt in to the `answer-account` wire op — `standingAgent({ answerAccounts })`. * * `true` opts in with every default. */ export interface AnswerAccountsOptions { /** * What the APP declares about its skills, tools and routing (labels, a * result's `rowsAt`, "the app decides entries"). Validated once, when * `standingAgent` is called; every line filled from it is vouched `app`. * Never taken from a request. */ readonly declarations?: AnswerAccountDeclarations; /** * The largest recording this host will explain, in bytes (the ticket's * `bytes`). Default {@link DEFAULT_ANSWER_ACCOUNT_MAX_RECORDING_BYTES} * (16 MiB). A recording over it is refused with * `RecordingTooLargeForAccountError` before any of its payload is read — it * bounds the worst single parse on the event loop every session shares. A * PACKED recording is held to it twice: its stored bytes, and the plain * recording it stands for (measured over the packed form, before anything is * expanded) — a few KB packed can stand for gigabytes. */ readonly maxRecordingBytes?: number; /** * How many accounts to keep, least recently used out first. Default * {@link DEFAULT_ANSWER_ACCOUNT_CACHE_ENTRIES}. `0` caches nothing (the * single-flight still joins concurrent requests). */ readonly cacheEntries?: number; } /** The default recording ceiling: 16 MiB — above the field median (≈7.5 MB). */ export declare const DEFAULT_ANSWER_ACCOUNT_MAX_RECORDING_BYTES: number; /** The default number of cached accounts. */ export declare const DEFAULT_ANSWER_ACCOUNT_CACHE_ENTRIES = 32; /** The cache's total bound, by the serialized size of what it holds: 8 MiB. */ export declare const ANSWER_ACCOUNT_CACHE_MAX_BYTES: number; /** A stored recording as the branch loaded it: the payload and the run it is OF. */ export interface LoadedRecording { readonly data: unknown; /** The ticket's `meta.origin.runId` — preferred over the recording's own. */ readonly runId?: string; } /** * One host's account service. * * @internal Built by `standingAgent` from {@link AnswerAccountsOptions}. */ export interface AnswerAccounts { readonly maxRecordingBytes: number; /** The cache key for a ref redeemed under `scope`. */ keyFor(scope: ArtifactScope, ref: string): string; /** A cached answer, marked most recently used — or `undefined`. */ cached(key: string): AnswerAccountWireBody | undefined; /** * Compute (or join the computation of) the answer for `key`. `load` runs at * most once per concurrent key and returns `null` when the record is not * available. Resolves `null` for anything that cannot be explained, and * rejects with `RecordingTooLargeForAccountError` when the payload in hand is * over the ceiling whatever its ticket said — neither is ever cached. */ compute(key: string, load: () => Promise<LoadedRecording | null>): Promise<AnswerAccountWireBody | null>; } /** * Build one host's service, validating the options. Throws — at boot — naming * the first thing wrong. */ export declare function answerAccounts(options: AnswerAccountsOptions | true): 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`. */ export declare function explainRecording(loaded: LoadedRecording, declarations: AnswerAccountDeclarations, maxRecordingBytes?: number): AnswerAccountWireBody | 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. */ export declare function deepFreeze<T>(value: T, seen?: WeakSet<object>): T;