agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
242 lines • 12.2 kB
JavaScript
/** Every write option, in the order refusals name them. */
export const MEMORY_WRITE_OPTION_NAMES = [
'extraction',
'namespaceScope',
];
/**
* The reason a store with NO server-side extraction gives — the shared
* sentence, so every such store refuses in the same words, remedy included.
*/
export const NO_SERVER_EXTRACTION = 'it keeps exactly the entries it is given and derives no long-term records from ' +
'them — there is no extraction to skip and no derived namespace to scope (its ' +
'tenant boundary is the identity tuple). Leave the option off for this store, or ' +
'use a store that declares it in `writeOptions`';
/**
* Thrown when a write asks for an option the store does not honour.
*
* Carries the store and the option by name; never a value — a namespace scope
* names a tenant, and an error message is the one place a tenant id must not
* travel. The remedy is in the store's own reason.
*/
export class UnsupportedMemoryWriteOptionError extends Error {
code = 'ERR_UNSUPPORTED_MEMORY_WRITE_OPTION';
/** The store that refused, e.g. `'InMemoryStore'`. */
store;
/** The option it cannot honour. */
option;
constructor(store, option, why) {
super(`[memory] ${store} cannot honour the write option \`${option}\`: ${why}. It is ` +
'refused rather than ignored — a write that dropped it would look exactly like one ' +
'that honoured it.');
this.name = 'UnsupportedMemoryWriteOptionError';
this.store = store;
this.option = option;
}
}
/**
* The options this write actually asks for, its values checked.
*
* A field that is `undefined` is not asked for; an empty `namespaceScope` asks
* for nothing either. A value of the wrong shape is a programming error, so it
* throws a `TypeError` naming the option — and never echoes the value.
*/
export function requestedWriteOptions(options) {
if (options === undefined || options === null)
return [];
const asked = [];
if (options.extraction !== undefined) {
assertExtraction(options.extraction);
asked.push('extraction');
}
if (options.namespaceScope !== undefined) {
assertScopeShape(options.namespaceScope);
if (Object.keys(options.namespaceScope).length > 0)
asked.push('namespaceScope');
}
return asked;
}
/**
* Refuse, by name, every write option `options` asks for that `honoured` does
* not contain. Call it first thing in `put`, `putMany` and `putIfVersion` —
* before anything is written.
*
* @param store the store's name, as the refusal should say it
* @param honoured the store's own declaration (`MemoryStore.writeOptions`)
* @param options what this write asked for
* @param why why this store cannot honour what it does not declare, and
* what to do instead
* @throws {UnsupportedMemoryWriteOptionError} for the first option not honoured
*/
export function refuseUnhonouredWriteOptions(store, honoured, options, why) {
for (const option of requestedWriteOptions(options)) {
if (!(honoured ?? []).includes(option)) {
throw new UnsupportedMemoryWriteOptionError(store, option, why);
}
}
}
/**
* Refuse a memory's write options or scope at DEFINITION time when its store
* does not declare them, or when a fixed one is malformed — the first turn
* should not be where a configuration that can never work is discovered.
*
* @throws {UnsupportedMemoryWriteOptionError} naming the store and the option
* @throws {TypeError} for a malformed option
*/
export function assertStoreHonours(store, config, caller) {
const asked = [];
if (config.extraction !== undefined) {
assertExtraction(config.extraction);
asked.push('extraction');
}
if (config.namespaceScope !== undefined) {
if (typeof config.namespaceScope !== 'function') {
assertScopeShape(config.namespaceScope);
assertNonEmptyScope(config.namespaceScope, caller);
}
asked.push('namespaceScope');
}
for (const option of asked) {
if (!(store.writeOptions ?? []).includes(option)) {
throw new UnsupportedMemoryWriteOptionError(storeName(store), option, `it does not declare \`${option}\` in \`writeOptions\` (checked by ${caller}, before ` +
'any turn ran). A store that declares it can take it; otherwise leave the option off');
}
}
}
/**
* The store with the memory's write options applied to every write it makes —
* and its namespace scope applied to every read.
*
* Writes (`put`, `putMany`, `putIfVersion`) carry the options. A call's own
* `extraction` overrides the bound one only where it asks for something (an
* `undefined` field never erases it). A bound `namespaceScope` is NEVER
* replaced per call: it comes from the run's identity through the configured
* resolver and nowhere else, so a call that brings its own is refused.
*
* Reads follow the same rule as writes: `search` hands a store that declares
* `'namespaceScope'` the scope resolved for that identity
* (`SearchOptions.namespaceScope`), so a store that files derived records by it
* reads within it. Every other member passes straight through.
*
* @throws (as a rejected promise, at a write or a search) a `TypeError` when
* the scope function returns an empty or malformed scope — refused rather
* than sent outside every tenant's namespace — or when a call brings its
* own `namespaceScope` to a memory that binds one.
*/
export function bindWriteOptions(store, config) {
const scopeIsBound = config.namespaceScope !== undefined;
const refuseOwnScope = (own) => {
if (scopeIsBound && own !== undefined && Object.keys(own).length > 0) {
throw new TypeError('defineMemory: this memory binds `namespaceScope` to the run’s identity, and a call ' +
'cannot replace it — a scope chosen per ' +
'call could file or read one tenant’s records under another’s. Change the resolver, ' +
'not the call.');
}
};
const resolve = (identity, own) => {
refuseOwnScope(own?.namespaceScope);
const scope = boundScope(config, identity);
const ownScope = own?.namespaceScope;
return {
...(config.extraction !== undefined && { extraction: config.extraction }),
...(scope !== undefined && { namespaceScope: scope }),
...(own?.extraction !== undefined && { extraction: own.extraction }),
// Only reachable when nothing is bound: the store checks it like any write.
...(ownScope !== undefined &&
Object.keys(ownScope).length > 0 && { namespaceScope: ownScope }),
};
};
// Reads carry the scope only to a store that files by it — the same store
// `assertStoreHonours` let the scope be bound to.
const readsByScope = scopeIsBound && (store.writeOptions ?? []).includes('namespaceScope');
const readOptions = (identity, options) => {
refuseOwnScope(options?.namespaceScope);
if (!readsByScope)
return options;
const scope = boundScope(config, identity, 'this search');
return scope === undefined ? options : { ...options, namespaceScope: scope };
};
const bound = {
...(store.supportsVectorSearch !== undefined && {
supportsVectorSearch: store.supportsVectorSearch,
}),
...(store.ranksBy !== undefined && { ranksBy: store.ranksBy }),
...(store.writeOptions !== undefined && { writeOptions: store.writeOptions }),
get: (identity, id) => store.get(identity, id),
// `async`, so a refused scope is a rejected promise, as the port promises —
// never a synchronous throw from a method the caller awaits.
put: async (identity, entry, own) => store.put(identity, entry, resolve(identity, own)),
putMany: async (identity, entries, own) =>
// The port's empty-batch law: no write, so nothing to resolve or refuse.
entries.length === 0
? store.putMany(identity, entries)
: store.putMany(identity, entries, resolve(identity, own)),
putIfVersion: async (identity, entry, expectedVersion, own) => store.putIfVersion(identity, entry, expectedVersion, resolve(identity, own)),
list: (identity, options) => store.list(identity, options),
delete: (identity, id) => store.delete(identity, id),
seen: (identity, signature) => store.seen(identity, signature),
recordSignature: (identity, signature) => store.recordSignature(identity, signature),
feedback: (identity, id, usefulness) => store.feedback(identity, id, usefulness),
getFeedback: (identity, id) => store.getFeedback(identity, id),
forget: (identity) => store.forget(identity),
// Present exactly when the store's is: `if (store.search)` is how callers
// feature-detect it, so the bound store must not grow or lose one.
...(store.search !== undefined && {
search: async (identity, query, options) => store.search(identity, query, readOptions(identity, options)),
}),
};
return bound;
}
// ── Checks ─────────────────────────────────────────────────────────────
/** The configured scope for this identity — present, plain, non-empty, or refused. */
function boundScope(config, identity, what = 'this write') {
if (config.namespaceScope === undefined)
return undefined;
const scope = typeof config.namespaceScope === 'function'
? config.namespaceScope(identity)
: config.namespaceScope;
assertScopeShape(scope);
// Copy FIRST, then check the copy: the values checked are the values every
// store sees — whatever the resolver's object does afterwards, or does on a
// second read (a getter, a Proxy that reports one value and returns another).
const copy = Object.freeze({ ...scope });
assertScopeShape(copy);
assertNonEmptyScope(copy, what);
return copy;
}
function assertExtraction(value) {
if (value !== 'skip') {
throw new TypeError("[memory] the write option `extraction` takes one value, 'skip' — got " +
`${typeof value === 'string' ? JSON.stringify(value) : typeof value}.`);
}
}
function assertScopeShape(value) {
if (!isStringRecord(value)) {
throw new TypeError('[memory] the write option `namespaceScope` must be a plain object of string values ' +
'(variable name → value). The value given is not quoted here: it names a tenant.');
}
}
function assertNonEmptyScope(scope, where) {
if (Object.keys(scope).length === 0) {
throw new TypeError(`[memory] the memory's \`namespaceScope\` came back EMPTY for ${where}. It is refused ` +
'rather than run without one: a write with no scope files its derived records outside ' +
'every tenant’s namespace, and a search with none is confined to no tenant. Return the ' +
'scope for every identity, or refuse the identity yourself.');
}
}
/** The store's own name for a refusal — its class, or what the caller holds. */
function storeName(store) {
const ctor = store.constructor?.name;
return ctor !== undefined && ctor !== 'Object' && ctor !== '' ? ctor : 'the store you passed';
}
/** A PLAIN object (no class, no Map, no Promise) whose own values are all strings. */
function isStringRecord(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value))
return false;
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null)
return false;
// Data properties only: a getter could answer the check one value and the
// store another.
return Object.values(Object.getOwnPropertyDescriptors(value)).every((d) => 'value' in d && typeof d.value === 'string');
}
//# sourceMappingURL=writeOptions.js.map