UNPKG

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