UNPKG

agentfootprint

Version:

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

322 lines • 14.2 kB
/** One explicit JSON-answer validation; the runner owns when to invoke it. */ import { applyOutputSchema } from './outputSchema.js'; import { copyJson as jsonCopy, ownData } from '../lib/boundedJson.js'; import { computeArtifactDigest } from '../artifacts/payload.js'; import { createAnswerEvidenceResolver } from './evidence.js'; import { toolCallsView } from './toolLedger.js'; import { AGENT_CHECK_PREFIX } from './agentChecks.js'; const DEFAULT_LIMITS = Object.freeze({ maxReads: 16, maxBytes: 4 * 1024 * 1024, maxChecks: 100, maxCandidateBytes: 1024 * 1024, timeoutMs: 5000, maxToolCalls: 256, }); /** The ceiling `limits.maxToolCalls` may be raised to. */ const MAX_TOOL_CALLS = 10_000; const encoder = new TextEncoder(); const dispositions = new Set(['checked-pass', 'checked-fail', 'not-applicable', 'unreachable']); const AGENT_CHECK_NAMES = new Set([ 'claims', 'names-and-numbers', 'negative-claims', ]); function text(value, max) { if (typeof value !== 'string' || value.trim() === '' || value.length > max) throw new Error('Expected bounded non-empty text.'); return value; } /** * `agentChecks`: a plain, non-empty list of known names, each once, every slot * a data property (no hole, no getter) — or absent, which is the empty list. */ function readAgentChecks(value) { if (value === undefined) return Object.freeze([]); const refuse = () => { throw new Error("answerValidation.agentChecks must list 'claims', 'names-and-numbers' and/or 'negative-claims', each once " + '(omit it to validate with your checks alone).'); }; if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return refuse(); if (value.length === 0 || value.length > AGENT_CHECK_NAMES.size) return refuse(); const names = []; for (let index = 0; index < value.length; index++) { const slot = Object.getOwnPropertyDescriptor(value, index); const name = slot !== undefined && 'value' in slot ? slot.value : undefined; if (typeof name !== 'string' || !AGENT_CHECK_NAMES.has(name)) return refuse(); if (names.includes(name)) return refuse(); names.push(name); } return Object.freeze(names); } /** Validate and snapshot an authored contract once, before running an agent. */ export function resolveAnswerValidation(options) { const source = ownData(options, ['id', 'version', 'mode', 'limits', 'agentChecks', 'validate']); const id = text(source.id, 128); const version = text(source.version, 128); const mode = source.mode === undefined ? 'enforce' : source.mode; if (mode !== 'enforce' && mode !== 'observe') throw new Error('answerValidation.mode must be enforce or observe.'); if (typeof source.validate !== 'function') throw new Error('answerValidation.validate must be a function.'); const validate = source.validate; const agentChecks = readAgentChecks(source.agentChecks); const supplied = source.limits === undefined ? {} : ownData(source.limits, Object.keys(DEFAULT_LIMITS)); const limits = { ...DEFAULT_LIMITS }; for (const key of Object.keys(DEFAULT_LIMITS)) { if (Object.hasOwn(supplied, key)) { const value = supplied[key]; if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0 || (key === 'timeoutMs' && value > 2147483647) || // Each landing rewrites the ledger, so its cost grows with the square. (key === 'maxToolCalls' && value > MAX_TOOL_CALLS)) throw new Error(`answerValidation.limits.${key} must be a positive bounded integer.`); limits[key] = value; } } return Object.freeze({ id, version, mode, limits: Object.freeze(limits), agentChecks, validate: (candidate, context) => validate(candidate, context), }); } function readChecks(value, config, resolved) { // Bound before copying. The copy rejects getters, class instances, cycles, // decorated arrays and values JSON would otherwise silently discard. const source = ownData(value, ['checks']); if (!Array.isArray(source.checks) || source.checks.length > config.limits.maxChecks) throw new Error('Invalid checks.'); const detached = jsonCopy(source.checks, config.limits.maxChecks * 4096, false); const ids = new Set(); const refs = new Set(resolved); return detached.map((raw) => { const check = ownData(raw, ['id', 'disposition', 'reason', 'path', 'evidenceRefs']); const id = text(check.id, 128); if (ids.has(id) || // Refused by domain, never renamed: an agent row owns this prefix. (config.agentChecks.length > 0 && id.startsWith(AGENT_CHECK_PREFIX)) || typeof check.disposition !== 'string' || !dispositions.has(check.disposition)) throw new Error('Invalid check identity or disposition.'); ids.add(id); const reason = check.reason === undefined ? undefined : text(check.reason, 1024); const path = check.path; if (path !== undefined && (typeof path !== 'string' || path.length > 256)) throw new Error('Invalid check path.'); let evidenceRefs; if (check.evidenceRefs !== undefined) { if (!Array.isArray(check.evidenceRefs) || check.evidenceRefs.length > config.limits.maxReads) throw new Error('Invalid evidence references.'); evidenceRefs = check.evidenceRefs.map((ref) => { if (typeof ref !== 'string' || !refs.has(ref)) throw new Error('Unresolved evidence reference.'); return ref; }); if (new Set(evidenceRefs).size !== evidenceRefs.length) throw new Error('Duplicate evidence reference.'); } return Object.freeze({ id, disposition: check.disposition, ...(reason !== undefined && { reason }), ...(path !== undefined && { path }), ...(evidenceRefs !== undefined && { evidenceRefs: Object.freeze(evidenceRefs) }), }); }); } /** * Validate once. Content is present only when delivery is permitted by this * contract. It is the exact JSON string whose detached value was checked; * callers must not reparse through a transforming schema or rewrite afterward. */ export async function executeAnswerValidation(raw, parser, config, store, scope, signal, toolCalls, /** * Builds the rows of the agent's own checks that `config.agentChecks` lists * (`agentChecks.ts`) from the candidate this validation delivers: the same * frozen value the validator receives, and its canonical JSON — the exact * text that would be delivered. Synchronous library code: a throw, a * listed check with no builder, or a row that claims `checked-pass` (an * agent row never verifies) makes the report `unverified` — never a pass. */ buildAgentRows) { const controller = new AbortController(); // Detached and frozen before the callback exists: the check reads a copy of // this run's ledger, never the run's state. const calls = toolCallsView(toolCalls, config.limits.maxToolCalls); // A check that READ an incomplete list cannot pass on it: the verdict is // unverified whatever the check returned (fail closed). A check that never // looks at the list is not held to it. let toolCallsRead = false; const evidence = createAnswerEvidenceResolver(store, scope, config.limits, controller.signal); let reason; let schemaAccepted = false; let canonical; let candidateDigest; let checks = []; let agentRows = []; let active = true; let stop; const abort = () => { reason = 'aborted'; controller.abort(); stop?.(); }; const timedOut = () => { reason = 'timeout'; controller.abort(); stop?.(); }; const deadline = Date.now() + config.limits.timeoutMs; const timer = setTimeout(timedOut, config.limits.timeoutMs); signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort(); try { const interrupted = new Promise((resolve) => { stop = resolve; if (controller.signal.aborted) resolve(); }); const work = async () => { if (controller.signal.aborted) return; let candidate; try { if (raw.length > config.limits.maxCandidateBytes || encoder.encode(raw).byteLength > config.limits.maxCandidateBytes) { reason = 'candidate-limit'; return; } candidate = applyOutputSchema(raw, parser); } catch { reason = 'schema-rejected'; return; } try { candidate = jsonCopy(candidate, config.limits.maxCandidateBytes, true); canonical = JSON.stringify(candidate); schemaAccepted = true; } catch { reason = 'non-json-candidate'; return; } // The agent's own rows judge the very candidate delivered — and fail // closed: a listed check that cannot produce its rows passes nothing. if (config.agentChecks.length > 0) { try { if (buildAgentRows === undefined) throw new Error('No builder for the listed checks.'); const rows = Object.freeze([...buildAgentRows(candidate, canonical)]); // An agent row can withhold an answer and never verify one: only // your checks make a report pass. A row claiming to is refused. if (rows.some((row) => row.disposition === 'checked-pass')) throw new Error('An agent check never passes an answer.'); agentRows = rows; } catch { reason = 'agent-check-error'; return; } } if (Date.now() >= deadline) { timedOut(); return; } try { const digest = await computeArtifactDigest(canonical); if (!active || controller.signal.aborted) return; candidateDigest = digest; const returned = await config.validate(candidate, Object.freeze({ signal: controller.signal, artifacts: evidence.resolver, get toolCalls() { toolCallsRead = true; return calls; }, })); if (!active || controller.signal.aborted) return; if (Date.now() >= deadline) { timedOut(); return; } try { checks = readChecks(returned, config, evidence.resolvedRefs()); } catch { reason = 'invalid-validator-result'; } } catch { if (active && !controller.signal.aborted) reason = 'validator-error'; } }; await Promise.race([work(), interrupted]); } finally { active = false; clearTimeout(timer); signal?.removeEventListener('abort', abort); evidence.close(); controller.abort(); } // Your checks first, then the agent's: one list, one count, one status rule. checks = [...checks, ...agentRows]; const failed = checks.filter((check) => check.disposition === 'checked-fail').length; const checked = failed + checks.filter((check) => check.disposition === 'checked-pass').length; const unreachable = checks.filter((check) => check.disposition === 'unreachable').length; const notApplicable = checks.filter((check) => check.disposition === 'not-applicable').length; if (!reason && evidence.failureReason()) reason = `evidence-${evidence.failureReason()}`; if (!reason && toolCallsRead && !calls.complete) reason = 'tool-calls-incomplete'; if (!reason && checked === 0) reason = 'no-comparable-checks'; if (!reason && unreachable > 0) reason = 'unreachable-checks'; // What could not be reached never hides what was found. An agent row is // computed by the library on the delivered candidate, before and apart from // your validator, so its contradiction makes the report `failed` whatever // the validator did — the reason still says that (`validator-error`, a // timeout, …). Your own failed check is a verdict beside unreachable rows; // under any other reason your check did not complete, so nothing it // returned is one. const agentFailed = agentRows.some((row) => row.disposition === 'checked-fail'); const status = agentFailed || (failed > 0 && (reason === undefined || reason === 'unreachable-checks')) ? 'failed' : reason !== undefined ? 'unverified' : 'passed'; const report = Object.freeze({ validatorId: config.id, validatorVersion: config.version, mode: config.mode, status, checked, failed, unreachable, notApplicable, checks: Object.freeze(checks), schemaAccepted, ...(reason !== undefined && { reason }), ...(candidateDigest !== undefined && { candidateDigest }), resolvedRefs: Object.freeze(evidence.resolvedRefs()), }); const content = status === 'passed' || config.mode === 'observe' ? canonical ?? raw : undefined; return { report, ...(content !== undefined && { content }) }; } //# sourceMappingURL=validate.js.map