agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
121 lines • 5.37 kB
JavaScript
;
/**
* artifacts/retention — the one owner of the eviction law.
*
* Three adapters, one rule for what leaves a scope and why. Each adapter
* supplies its rows; this module decides — a pure plan over plain facts, so
* the law is testable without a store and cannot fork into three dialects.
*
* The policy is three independent dials, all per scope:
* • `ttlMs` — every artifact gets `expiresAt = createdAt + ttl`
* stamped AT MINT (expiry is stated, never sprung),
* and expired rows are swept when the scope is next
* touched by a put.
* • `maxBytesPerScope`— a byte budget; least-recently-ACCESSED leaves first
* (an artifact under active render must not be the
* next one evicted — the innerRunRecords recency law).
* • `maxCountPerScope`— a row budget, same eviction order.
*
* Every eviction is a FACT the plan returns ({@link SweptArtifact}), because
* the put that triggered it reports them upward and the capability layer puts
* each one on the record. A store that made room silently would be lying by
* omission.
*
* A payload bigger than the whole byte budget is REFUSED at the door (the
* plan says so; the adapter throws by name) — evicting an entire scope to
* admit one oversized object would trade everything for something that may
* itself not fit.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveExpiresAt = exports.planRetention = exports.assertRetention = void 0;
/**
* Validate a retention policy at construction — a dial that cannot bind
* (zero, negative, NaN) is refused where it was written, not discovered as
* an always-empty store at the first put.
*/
function assertRetention(adapter, retention) {
if (retention === undefined)
return;
const dials = [
['ttlMs', retention.ttlMs],
['maxBytesPerScope', retention.maxBytesPerScope],
['maxCountPerScope', retention.maxCountPerScope],
];
for (const [name, value] of dials) {
if (value === undefined)
continue;
if (!Number.isFinite(value) || value <= 0) {
throw new TypeError(`[artifacts] ${adapter}: retention.${name} = ${String(value)} cannot bind. ` +
`Each dial is a positive number, or absent — omitting it is how "no limit" is said.`);
}
}
}
exports.assertRetention = assertRetention;
/**
* Plan what must leave a scope so that `incomingBytes` may enter.
*
* Pure: reads rows, returns facts, mutates nothing. Rows may arrive in any
* order; eviction order here is least-recently-accessed first.
*/
function planRetention(rows, incomingBytes, retention, now) {
const swept = [];
const sweep = (row, reason) => {
swept.push({ ref: row.ref, reason, kind: row.kind, bytes: row.bytes });
};
// 1 — the calendar: expired rows leave regardless of budgets.
const live = [];
for (const row of rows) {
if (row.expiresAt !== undefined && row.expiresAt <= now)
sweep(row, 'ttl');
else
live.push(row);
}
if (retention === undefined)
return { swept };
const { maxBytesPerScope, maxCountPerScope } = retention;
if (maxBytesPerScope !== undefined && incomingBytes > maxBytesPerScope) {
return {
swept,
refusal: `the payload is ${incomingBytes} bytes and this store's byte budget per scope is ` +
`${maxBytesPerScope}. Evicting everything else would not make it fit, so it is ` +
`refused instead of admitted by force. Store something smaller, or build the ` +
`store with a larger maxBytesPerScope.`,
};
}
// Least-recently-accessed first — ties broken by ref for determinism.
live.sort((a, b) => a.lastAccessedAt - b.lastAccessedAt || (a.ref < b.ref ? -1 : 1));
// 2 — the row budget (the incoming row occupies one slot).
if (maxCountPerScope !== undefined) {
let count = live.length + 1;
while (count > maxCountPerScope && live.length > 0) {
sweep(live.shift(), 'max-count');
count--;
}
}
// 3 — the byte budget (the incoming bytes already proved they can fit).
if (maxBytesPerScope !== undefined) {
let total = live.reduce((sum, row) => sum + row.bytes, 0) + incomingBytes;
while (total > maxBytesPerScope && live.length > 0) {
const evicted = live.shift();
sweep(evicted, 'max-bytes');
total -= evicted.bytes;
}
}
return { swept };
}
exports.planRetention = planRetention;
/**
* The `expiresAt` a mint gets: the caller's own statement and the store's
* ttl, whichever comes SOONER. A store may tighten a promise, never extend
* one — extending would overrule the only party who knows the data.
*/
function resolveExpiresAt(callerExpiresAt, retention, now) {
const fromTtl = retention?.ttlMs !== undefined ? now + retention.ttlMs : undefined;
if (callerExpiresAt === undefined)
return fromTtl;
if (fromTtl === undefined)
return callerExpiresAt;
return Math.min(callerExpiresAt, fromTtl);
}
exports.resolveExpiresAt = resolveExpiresAt;
//# sourceMappingURL=retention.js.map