agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
391 lines • 14.8 kB
JavaScript
/**
* requestEvidence — the door a tool call's REQUEST attempts enter its run by.
*
* Pattern: a per-dispatch port (the `LLMCallHooks` precedent) + one allow-list.
* Role: The seam between a retry series that runs BELOW a tool — inside
* `mcpClient`'s transport, built before any run existed — and the run
* that dispatched the call. The dispatcher builds the port for each
* call (`requestEvidenceFor`) and puts it on the call's context under
* an internal symbol; the MCP tool hands it down to the series that
* sends its request (`mcp/requestLedger.ts`); the series files ONE
* record when the request ends. Nothing here retries anything.
* Emits: agentfootprint.tools.request_attempts
*
* Why a port and not a scope: a decorator constructed by the consumer before
* any run exists can never reach a scope (`resilienceHooks` says the same of
* the provider decorators). The call is the one thing both sides share, so the
* port rides the call — and the identity on the event (`toolCallId`,
* `toolName`, `iteration`, the run's ids in the meta) is stamped HERE, from the
* dispatch, never by whatever files: a filing cannot claim another call.
*
* Why an internal symbol and not a public `ctx` field: this is the evidence
* path for the library's own retry series, not a retry API. A public member
* would invite every tool to file attempts the library cannot vouch for. The
* symbol is enumerable, so a wrapper that copies the context (`{ ...ctx }`)
* keeps it; a filing from anyone else still passes the allow-list below.
*
* The port only FILES. What cancels a call is the call's own context signal
* (`ctx.signal`), read by the MCP tool whether or not a port rides along — a
* cancellation must never depend on evidence being attributable.
*
* THE ALLOW-LIST is the whole record: numbers, the library's own words, the
* endpoint label the app declared or else the endpoint's ORIGIN. A filing with
* anything else in it — a field nobody listed, a word outside its set, a URL
* with a path, an array that is not a plain one — is refused whole, never
* trimmed into something it did not say. Values are read as DATA, through own
* property descriptors, so no getter, `toJSON` or array subclass a filing
* carries ever runs or reaches the record.
*/
import { isDevMode } from 'footprintjs';
import { typedEmit } from '../recorders/core/typedEmit.js';
/** At most this many attempts are listed per request; the rest are counted (`omitted`). */
export const MAX_REQUEST_ATTEMPT_ROWS = 100;
/** @internal The context key the dispatcher puts a call's sink under. */
export const REQUEST_EVIDENCE = Symbol('agentfootprint.request-evidence');
/**
* The port for one dispatch: files `agentfootprint.tools.request_attempts`
* mid-stage through `typedEmit` (so the run's redaction rule serves it, and the
* meta carries the real `runtimeStageId`), stamped with `call`.
*/
export function requestEvidenceFor(scope, call) {
const sink = {
file: (evidence) => {
const served = servedRequestEvidence(evidence);
if (served === undefined) {
warnOnce(call.toolName, 'a filing outside the allow-list was refused');
return;
}
try {
typedEmit(scope, 'agentfootprint.tools.request_attempts', {
toolCallId: call.toolCallId,
toolName: call.toolName,
iteration: call.iteration,
...served,
});
}
catch (err) {
// Evidence is not the work: a sink that dies must not fail the call.
warnOnce(call.toolName, err instanceof Error ? err.message : String(err));
}
},
};
return { [REQUEST_EVIDENCE]: sink };
}
/**
* The sink a call's context carries, or `undefined` — a context from a door with
* no run (`mcpServe`, a direct `execute`), or one a wrapper rebuilt without it.
* Never throws, whatever `ctx` is.
*/
export function requestEvidenceOf(ctx) {
if (ctx === null || typeof ctx !== 'object')
return undefined;
let port;
try {
port = ctx[REQUEST_EVIDENCE];
}
catch {
return undefined;
}
if (port === null || typeof port !== 'object')
return undefined;
let file;
try {
file = port.file;
}
catch {
return undefined;
}
if (typeof file !== 'function')
return undefined;
return port;
}
const ATTEMPT_OUTCOMES = {
answered: true,
refused: true,
failed: true,
'timed-out': true,
cut: true,
};
/** An end may also be `cancelled`: no request was in flight. A row never is — a sent request is `cut`. */
const END_OUTCOMES = { ...ATTEMPT_OUTCOMES, cancelled: true };
const REMOTE = { unknown: true };
const REASONS = {
retryable: true,
answered: true,
exhausted: true,
'wait-limit': true,
'no-time-left': true,
'not-replayable': true,
'not-retryable': true,
'not-permitted': true,
'changes-without-key': true,
cancelled: true,
};
const EFFECTS = {
none: true,
unknown: true,
applied: true,
};
const PERMITTED_BY = {
declaration: true,
adapter: true,
compatibility: true,
};
const REPLAY_KINDS = {
throttle: true,
network: true,
};
const ASSUMPTIONS = {
'refused-before-execution': true,
};
const KINDS = {
throttle: true,
'session-busy': true,
};
const CATEGORIES = {
timeout: true,
'rate-limited': true,
server: true,
network: true,
auth: true,
permanent: true,
unknown: true,
};
const SENTENCES = {
'The request failed before an answer arrived. Its error is not kept.': true,
'The call ran out of time before an answer arrived.': true,
};
const DURING = { request: true, wait: true };
const ATTEMPT_FIELDS = new Set([
'attempt',
'outcome',
'reason',
'status',
'kind',
'failure',
'waitMs',
'retryAfterMs',
'error',
'remote',
'effect',
'permission',
'durationMs',
]);
const END_FIELDS = new Set([
'outcome',
'reason',
'status',
'kind',
'failure',
'during',
'error',
'remote',
'attempts',
'effect',
'mayHaveRun',
]);
const PERMISSION_FIELDS = new Set(['by', 'on', 'assumes', 'deduplicated']);
const EVIDENCE_FIELDS = new Set(['endpoint', 'origin', 'attempts', 'omitted', 'end']);
/** Thrown inside the walk when a filing leaves the allow-list; caught at its root. */
class Refused extends Error {
name = 'Refused';
}
/**
* `raw` rebuilt from allow-listed fields — a fresh plain object — or
* `undefined` when anything in it is outside the list.
*/
export function servedRequestEvidence(raw) {
try {
const evidence = record(raw, EVIDENCE_FIELDS);
const { endpoint, origin } = evidence;
if (endpoint !== undefined && origin !== undefined)
throw new Refused();
return {
...(endpoint !== undefined && { endpoint: label(endpoint) }),
...(origin !== undefined && { origin: originOnly(origin) }),
attempts: plainArray(evidence.attempts, MAX_REQUEST_ATTEMPT_ROWS).map(servedAttempt),
omitted: count(evidence.omitted),
end: servedEnd(evidence.end),
};
}
catch {
// `Refused`, or anything a hostile value threw while it was read: this door
// never throws, whatever it is handed.
return undefined;
}
}
function servedAttempt(raw) {
const row = record(raw, ATTEMPT_FIELDS);
const attempt = count(row.attempt);
if (attempt < 1)
throw new Refused();
return {
attempt,
outcome: word(ATTEMPT_OUTCOMES, row.outcome),
reason: word(REASONS, row.reason),
...(row.status !== undefined && { status: status(row.status) }),
...(row.kind !== undefined && { kind: word(KINDS, row.kind) }),
...(row.failure !== undefined && { failure: word(CATEGORIES, row.failure) }),
...(row.waitMs !== undefined && { waitMs: duration(row.waitMs) }),
...(row.retryAfterMs !== undefined && { retryAfterMs: duration(row.retryAfterMs) }),
...(row.error !== undefined && { error: servedError(row.error) }),
...(row.remote !== undefined && { remote: word(REMOTE, row.remote) }),
...(row.effect !== undefined && { effect: word(EFFECTS, row.effect) }),
...(row.permission !== undefined && { permission: servedPermission(row.permission) }),
...(row.durationMs !== undefined && { durationMs: duration(row.durationMs) }),
};
}
function servedEnd(raw) {
const end = record(raw, END_FIELDS);
return {
outcome: word(END_OUTCOMES, end.outcome),
reason: word(REASONS, end.reason),
...(end.status !== undefined && { status: status(end.status) }),
...(end.kind !== undefined && { kind: word(KINDS, end.kind) }),
...(end.failure !== undefined && { failure: word(CATEGORIES, end.failure) }),
...(end.during !== undefined && { during: word(DURING, end.during) }),
...(end.error !== undefined && { error: servedError(end.error) }),
...(end.remote !== undefined && { remote: word(REMOTE, end.remote) }),
...(end.attempts !== undefined && { attempts: count(end.attempts) }),
...(end.effect !== undefined && { effect: word(EFFECTS, end.effect) }),
...(end.mayHaveRun !== undefined && { mayHaveRun: exactlyTrue(end.mayHaveRun) }),
};
}
/**
* What permitted a repeat: who, and the one detail that belongs to who — the
* declared kind for `declaration`, the assumption for `adapter` and
* `compatibility` (a policy states what it assumes). A detail beside the wrong
* `by`, or a policy without its assumption, is refused, never dropped.
*/
function servedPermission(raw) {
const permission = record(raw, PERMISSION_FIELDS);
const by = word(PERMITTED_BY, permission.by);
if ((by === 'declaration') !== (permission.on !== undefined))
throw new Refused();
if ((by !== 'declaration') !== (permission.assumes !== undefined))
throw new Refused();
return {
by,
...(permission.on !== undefined && { on: word(REPLAY_KINDS, permission.on) }),
...(permission.assumes !== undefined && { assumes: word(ASSUMPTIONS, permission.assumes) }),
...(permission.deduplicated !== undefined && {
deduplicated: exactlyTrue(permission.deduplicated),
}),
};
}
/** The literal `true` — a flag that is either set or absent. */
function exactlyTrue(value) {
if (value === true)
return true;
throw new Refused();
}
function servedError(raw) {
const error = record(raw, new Set(['message', 'code']));
return { message: word(SENTENCES, error.message), code: word(CATEGORIES, error.code) };
}
/**
* A PLAIN array's elements, copied by index into a fresh array: its prototype
* exactly `Array.prototype` (no subclass, so no `toJSON` or species of its own),
* every index from 0 to `length - 1` an own data property (no hole, no getter),
* and no other own key — a property riding on the array is refused, not
* dropped. At most `max` elements.
*/
function plainArray(raw, max) {
if (!Array.isArray(raw) || Object.getPrototypeOf(raw) !== Array.prototype)
throw new Refused();
const length = Object.getOwnPropertyDescriptor(raw, 'length')?.value;
if (typeof length !== 'number' || !Number.isSafeInteger(length) || length > max) {
throw new Refused();
}
const keys = Reflect.ownKeys(raw);
if (keys.length !== length + 1)
throw new Refused();
const out = [];
for (let i = 0; i < length; i++) {
const descriptor = Object.getOwnPropertyDescriptor(raw, String(i));
if (descriptor === undefined || !('value' in descriptor))
throw new Refused();
out.push(descriptor.value);
}
return out;
}
/** A plain object whose every own key is listed; read once, by key. */
function record(raw, fields) {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
throw new Refused();
const proto = Object.getPrototypeOf(raw);
if (proto !== Object.prototype && proto !== null)
throw new Refused();
const out = {};
for (const key of Reflect.ownKeys(raw)) {
if (typeof key !== 'string' || !fields.has(key))
throw new Refused();
const descriptor = Object.getOwnPropertyDescriptor(raw, key);
if (descriptor === undefined || !('value' in descriptor))
throw new Refused();
out[key] = descriptor.value;
}
return out;
}
function word(words, value) {
if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(words, value)) {
return value;
}
throw new Refused();
}
/** A whole number ≥ 0. */
function count(value) {
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
return value;
throw new Refused();
}
/** An HTTP status. */
function status(value) {
if (typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599) {
return value;
}
throw new Refused();
}
/** A finite length of time in ms, ≥ 0. */
function duration(value) {
if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
return value;
throw new Refused();
}
/** An app-declared label: 1–128 characters, none of them control characters. */
function label(value) {
if (typeof value !== 'string' || value.length < 1 || value.length > 128)
throw new Refused();
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (code < 0x20 || (code >= 0x7f && code <= 0x9f))
throw new Refused();
}
return value;
}
/** An origin and nothing more: what `new URL(it)` reads back as scheme, host and port. */
function originOnly(value) {
if (typeof value !== 'string' || value.length > 2048)
throw new Refused();
let url;
try {
url = new URL(value);
}
catch {
throw new Refused();
}
if (url.host === '' || `${url.protocol}//${url.host}` !== value)
throw new Refused();
return value;
}
const warned = new Set();
/** Named in dev mode, once per tool — evidence is telemetry, never the call's failure. */
function warnOnce(toolName, why) {
if (!isDevMode() || warned.has(toolName))
return;
warned.add(toolName);
// eslint-disable-next-line no-console
console.warn(`[agentfootprint] tool '${toolName}': its request attempts were not recorded (${why}). ` +
'The call itself is unaffected.');
}
//# sourceMappingURL=requestEvidence.js.map