UNPKG

agentfootprint

Version:

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

508 lines • 21.4 kB
/** * Runtime evidence for typed negatives. Only dispatch and native tool returns write here; * assistant text, summaries and after-tool rewrites never become observations. * Role: core/agent, opt-in. The checker and coverage rules remain their single owners. */ import { isDevMode } from 'footprintjs'; import { readResultIdentity } from '../../../integrity/claim-binding/capture.js'; import { sameName } from '../../../lib/normalize.js'; import { isJsonFaithful } from '../../../lib/jsonFaithful.js'; import { stableJson } from '../../../lib/stableJson.js'; import { runKeepsOut } from '../../../redaction/runRedaction.js'; import { readSemantics } from '../../../semantics/envelope.js'; import { clockOf } from '../../../time/rows.js'; import { refusalIssues } from '../../../semantics/overclaim.js'; import { readToolResultEnvelope } from '../../../semantics/toolEffects.js'; import { namesFacetOf } from '../../../semantics/coverage/among.js'; import { readAbsence, readDeclaredAbsence, readCoverageLedger, readDeclaredLedger, } from '../../../semantics/coverage/recognize.js'; import { composeChecked } from '../../../semantics/coverage/search/facets.js'; import { readArgument } from '../../../semantics/coverage/search/inEffect.js'; import { hostFillsOf } from '../../../tools/hostFilled.js'; import { searchOf } from '../../../tools/searchOf.js'; import { readOnlyByDeclaration } from '../../../tools/tools.js'; import { readEmptiness } from '../emptiness.js'; import { createVerificationState, reserveResultIdentity } from '../findings/verificationState.js'; const MAX_CALLS = 1_000; const MAX_BYTES = 2_000_000; const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); const stateOf = (scope) => scope.$getValue('negativeClaimState'); const sameIdentity = (a, b) => { try { return stableJson(a ?? null) === stableJson(b ?? null); } catch { return false; } }; export function createNegativeRuntimeState(turn, identity, prior, anonymousOwner = false) { // A scope-backed object can be a Proxy. Copy this closed tuple through its // fields instead of passing a live scope proxy into structuredClone. This // is ownership bookkeeping; only actual host fills can prove trusted context. const ownerIdentity = identity === undefined ? undefined : { conversationId: identity.conversationId, ...(identity.tenant !== undefined && { tenant: identity.tenant }), ...(identity.principal !== undefined && { principal: identity.principal }), }; const inherited = prior !== undefined && sameIdentity(prior.ownerIdentity, ownerIdentity); // Only an explicitly continued anonymous conversation may carry these stale // addresses between its per-run namespaces. No earlier payload or scope is // transferred, and no named owner can enter this path. const anonymousPast = !inherited && anonymousOwner && prior?.anonymousOwner === true && ownerIdentity?.tenant === undefined && ownerIdentity?.principal === undefined && prior.ownerIdentity?.tenant === undefined && prior.ownerIdentity?.principal === undefined && turn > prior.turn && prior.calls.every((call) => !call.trustedContext); const kept = inherited || anonymousPast; return { version: 1, turn, complete: kept ? prior.complete : true, calls: inherited ? prior.calls : anonymousPast ? prior.calls.map(staleMetadataOf) : [], tools: inherited ? prior.tools : {}, pending: kept ? prior.pending : [], overclaims: inherited ? prior.overclaims ?? [] : [], ...(inherited && prior.pausedResultId !== undefined && { pausedResultId: prior.pausedResultId }), ...(ownerIdentity !== undefined && { ownerIdentity }), ...(anonymousOwner && { anonymousOwner: true }), }; } /** A citation address and date, never an observation that can establish a claim. */ function staleMetadataOf(call) { return { resultId: call.resultId, toolName: call.toolName, turn: call.turn, batch: call.batch, dispatched: call.dispatched, outcome: call.outcome, effect: call.effect, arguments: {}, trustedContext: false, keptOut: true, ...(call.readAt !== undefined && { readAt: call.readAt }), ...(call.row !== undefined && { row: { kind: call.row.kind, toolName: call.toolName, iteration: call.batch, composed: call.row.composed, resultId: call.resultId, handle: call.row.handle, checked: [], notChecked: [], cannotCover: [], complete: false, gaps: [], }, }), }; } function publish(scope, next) { try { if (JSON.stringify(next).length > MAX_BYTES) { const prior = stateOf(scope); if (prior !== undefined) scope.negativeClaimState = { ...prior, complete: false }; return; } scope.negativeClaimState = next; } catch { const prior = stateOf(scope); if (prior !== undefined) scope.negativeClaimState = { ...prior, complete: false }; } } function replace(scope, id, update) { const state = stateOf(scope); if (state === undefined) return; publish(scope, { ...state, calls: state.calls.map((call) => (call.resultId === id ? update(call) : call)), }); } /** Publish the attempt before the tool's code can await, throw or pause. */ export function startNegativeCall(scope, input) { const { tool, context } = input; const runId = input.runId || 'unidentified-run'; let dispatch = input.dispatch; if (dispatch === undefined) { const reserved = reserveResultIdentity(scope.resultIdentities ?? createVerificationState(runId), runId); scope.resultIdentities = reserved.state; dispatch = { resultId: reserved.id, handle: `r${reserved.identity.ordinal}`, dispatchedAt: context.time?.dispatchedAt ?? new Date().toISOString(), }; } const identity = scope.$getValue('runIdentity'); const state = stateOf(scope) ?? createNegativeRuntimeState(scope.turnNumber, identity); let args = {}; let keptOut = false; try { if (!isJsonFaithful(input.args)) keptOut = true; args = structuredClone(input.args); } catch { keptOut = true; } const remote = tool.source !== undefined || input.party?.channel === 'provider'; const search = searchOf(tool).search; const fills = hostFillsOf(tool); const bindings = Object.entries(fills?.identity ?? {}); const actualIdentity = context.identity; const expectedIdentity = identity; const trustedContext = !remote && bindings.length > 0 && bindings.every(([argument, key]) => !Object.hasOwn(args, argument) && typeof actualIdentity?.[key] === 'string' && actualIdentity[key] !== '' && actualIdentity[key] === expectedIdentity?.[key]); // A privacy rule must never be bypassed by moving evidence into a new carrier. keptOut ||= [ 'negativeClaimState', 'coverageDeclared', 'toolResults', 'history', 'args', 'result', ...Object.keys(args), ].some((key) => runKeepsOut(scope, key)); let composedScope; if (search !== undefined) { composedScope = composeChecked({ search, readings: search.arguments.map((arg) => [arg, readArgument(arg, args, fills)]), period: tool.period, dispatchedAt: dispatch.dispatchedAt, }); } const call = { resultId: dispatch.resultId, toolCallId: input.toolCallId, toolName: tool.schema.name, turn: scope.turnNumber, batch: input.iteration, dispatched: true, outcome: 'error', effect: !remote && (search !== undefined || readOnlyByDeclaration(tool)) ? 'read' : 'not-read', arguments: keptOut ? {} : args, trustedContext, ...(composedScope !== undefined && !keptOut && { scope: composedScope }), readAt: dispatch.dispatchedAt, ...(keptOut && { keptOut: true }), }; if (state.calls.length >= MAX_CALLS) publish(scope, { ...state, complete: false }); else publish(scope, { ...state, turn: scope.turnNumber, calls: [...state.calls, structuredClone(call)], pending: [...state.pending, dispatch.resultId], tools: { ...state.tools, [tool.schema.name]: { name: tool.schema.name, ...(search !== undefined && !keptOut && { search: structuredClone(search) }), }, }, }); return { ...dispatch, args, local: !remote }; } /** Bind a standalone among miss to the actual argument, never its looked-for prose. */ function amongRow(call, value, handle, tool) { const absence = readAbsence(value); if (absence === undefined) return undefined; const names = namesFacetOf(absence); if (names === undefined) return undefined; const sent = call.arguments[names.argument]; if ((typeof sent !== 'string' && typeof sent !== 'number') || !sameName(String(sent), absence.looked_for)) return undefined; const fills = hostFillsOf(tool); const host = Object.entries(fills?.identity ?? {}).map(([argument, identity]) => ({ what: argument, facet: 'filter', argument, value: { identity }, from: 'host', })); const gaps = [...(absence.not_checked ?? []), ...(absence.cannot_cover ?? [])].map((gap) => ({ kind: gap.kind === 'scope' ? 'scope' : 'prose', what: gap.what, ...(gap.why !== undefined && { why: gap.why }), ...(gap.bears !== undefined && { bears: gap.bears }), })); return { kind: 'absence', toolName: call.toolName, toolCallId: call.toolCallId, iteration: call.batch, composed: tool.source === undefined ? 'local' : 'remote', resultId: call.resultId, handle, lookedFor: absence.looked_for, checked: [ { what: names.source, facet: 'source', from: 'result' }, { what: names.argument, facet: 'population', argument: names.argument, value: sent, from: 'call', quote: true, }, { ...names, measuredAt: absence.provenance?.measured_at }, ...host, ], notChecked: absence.not_checked ?? [], cannotCover: absence.cannot_cover ?? [], complete: false, gaps, }; } /** All described data slots, in a stable order shared by capture and display admission. */ function describedRowsOf(value) { if (!record(value)) return undefined; const collections = ['facts', 'series', 'edges'].flatMap((key) => value[key] === undefined ? [] : [value[key]]); if (collections.length === 0 || !collections.every((rows) => Array.isArray(rows) && rows.every(record))) return undefined; return collections.flat(); } /** The same recognized row shapes at native capture and final display admission. * Final model text is used only to check that native rows survived; never as evidence. */ function rowsOf(value) { const envelope = readToolResultEnvelope(value); const raw = envelope === undefined ? value : envelope.content; const sem = readSemantics(raw); const ledger = readCoverageLedger(raw) ?? readDeclaredLedger(raw); if (sem !== undefined) return describedRowsOf(sem); let rows = ledger?.result ?? raw; if (!Array.isArray(rows) && record(rows) && Array.isArray(rows.rows)) rows = rows.rows; return Array.isArray(rows) && rows.every(record) ? rows : undefined; } /** Snapshot immediately on native return, before teardown, observers and middleware. */ export function captureNegativeResult(scope, dispatch, value, tool) { try { if (!isJsonFaithful(value)) { keepNegativeResultOut(scope, dispatch.resultId); return value; } const copy = structuredClone(value); const keys = []; const visit = (part) => { if (part === null || typeof part !== 'object') return; for (const [key, item] of Object.entries(part)) { keys.push(key); visit(item); } }; visit(copy); if (keys.some((key) => runKeepsOut(scope, key))) { keepNegativeResultOut(scope, dispatch.resultId); return copy; } const envelope = readToolResultEnvelope(copy); const raw = envelope === undefined ? copy : envelope.content; const sem = readSemantics(raw); const absent = readAbsence(raw) ?? readDeclaredAbsence(raw); const ledger = readCoverageLedger(raw) ?? readDeclaredLedger(raw); const read = readEmptiness(raw, { door: { absent: absent !== undefined, bounded: ledger !== undefined, ...(sem !== undefined && { described: sem }), }, }); const rowsRead = rowsOf(raw); const countedRows = read.rows ?? (sem !== undefined ? rowsRead?.length : undefined); const notRead = envelope?.status !== undefined && ['invalid', 'denied', 'failure', 'pending'].includes(envelope.status); if (notRead) captureNegativeRefusal(scope, tool.schema.name, raw, dispatch.args); replace(scope, dispatch.resultId, (call) => { if (call.keptOut) return call; const among = amongRow(call, raw, dispatch.handle, tool); return { ...call, outcome: notRead ? 'error' : 'ok', emptiness: notRead ? 'unknown' : read.emptiness, ...(countedRows !== undefined && { rows: countedRows }), ...(rowsRead !== undefined && { rowsRead }), ...(among !== undefined && { row: { ...among, composed: dispatch.local ? 'local' : 'remote' }, // A returned absence says what was looked up, not whether this call // also wrote. Only the app's dispatch-time declaration exempts it // from write invalidation. effect: call.effect, }), }; }); return copy; } catch { replace(scope, dispatch.resultId, (call) => ({ ...call, keptOut: true })); return value; } } export function captureNegativeCoverage(scope, row) { replace(scope, row.resultId, (call) => call.keptOut ? call : { ...call, row: structuredClone(row) }); } /** Park the native occurrence itself; provider call ids are never a selector. */ export function pauseNegativeCall(scope, resultId) { const state = stateOf(scope); if (state !== undefined) publish(scope, { ...state, pausedResultId: resultId }); } export function pausedNegativeResultId(scope) { return stateOf(scope)?.pausedResultId; } /** The terminal bracket settles an attempt; a human answer is not a native observation. */ export function settleNegativeCall(scope, resultId, outcome) { const state = stateOf(scope); if (state === undefined || resultId === undefined) return; const { pausedResultId, ...rest } = state; publish(scope, { ...rest, ...(pausedResultId !== undefined && pausedResultId !== resultId && { pausedResultId }), pending: state.pending.filter((id) => id !== resultId), calls: state.calls.map((c) => c.resultId === resultId ? { ...c, outcome: outcome === 'ok' && c.emptiness !== undefined ? c.outcome : outcome === 'ok' ? 'error' : outcome, } : c), }); } /** Existing semantic recognition, filed as codes only; a refusal never manufactures a read. */ export function captureNegativeRefusal(scope, tool, value, args) { const state = stateOf(scope); if (state === undefined) return; try { const direct = refusalIssues(value, 'result'); const text = typeof value === 'string' ? value : JSON.stringify(value); const contextual = Object.entries(args ?? {}).some(([argument, sent]) => refusalIssues({ text, argument, ...(typeof sent === 'string' && { value: sent }) }, 'result').length > 0); if (direct.length === 0 && !contextual) return; if (state.overclaims.some((row) => row.turn === scope.turnNumber && row.tool === tool)) return; if (state.overclaims.length >= MAX_CALLS) { publish(scope, { ...state, complete: false }); return; } if (isDevMode() && !state.overclaims.some((row) => row.tool === tool)) { // eslint-disable-next-line no-console console.warn(`agentfootprint negative-claims: tool '${tool}' — refusal-asserts-absence; a refusal cannot establish absence.`); } publish(scope, { ...state, overclaims: [ ...state.overclaims, { tool, code: 'refusal-asserts-absence', turn: scope.turnNumber }, ], }); } catch { publish(scope, { ...state, complete: false }); } } /** The transform cannot supply evidence; unreadable delivered output also withholds its handle. */ export function keepNegativeResultOut(scope, resultId) { if (resultId !== undefined) replace(scope, resultId, (call) => ({ ...call, keptOut: true })); } /** A framework suffix, after transforms/caps. Gap notes and periods survive; inventory names never do. */ export function serveNegativeHandle(scope, resultId, text) { const call = stateOf(scope)?.calls.find((c) => c.resultId === resultId); // This boundary receives the serialized result AFTER middleware, artifact // placement and truncation. A projection may hide native row values; it cannot // introduce display evidence. Older checkpoints carry no display permission. if (call?.rowsRead !== undefined && !call.keptOut && call.outcome === 'ok') { let rowsMayDisplay = false; try { if (text.length <= MAX_BYTES) { const delivered = JSON.parse(text); // semanticsForModel removes the private marker but retains these data // slots. A slot can only permit display by matching the native rowset. const candidates = [rowsOf(delivered), describedRowsOf(delivered)]; const native = stableJson(call.rowsRead); rowsMayDisplay = native !== undefined && candidates.some((candidate) => Array.isArray(candidate) && candidate.every(record) && stableJson(candidate) === native); } } catch { /* A summary or truncation marker is not an unchanged rowset. */ } replace(scope, call.resultId, (current) => { const next = { ...current }; delete next.rowsMayDisplay; return { ...next, ...(rowsMayDisplay && { rowsMayDisplay: true }) }; }); } if (call?.row === undefined || call.keptOut || call.outcome !== 'ok') return text; const row = call.row; return `${text}\n${JSON.stringify({ af_search: { handle: row.handle, gaps: row.gaps, notChecked: row.notChecked, cannotCover: row.cannotCover, ...(row.period !== undefined && { period: row.period }), ...(row.inProgress !== undefined && { inProgress: row.inProgress }), }, })}`; } export function negativeRecordOf(scope, runId) { const state = stateOf(scope); const identity = scope.$getValue('runIdentity'); const ids = scope.resultIdentities; let namespace = ids?.evidence.namespace ?? ''; if (namespace === '' && state?.calls[0] !== undefined) { try { namespace = readResultIdentity(state.calls[0].resultId).namespace; } catch { /* unknown */ } } const clock = clockOf(scope.findingsLedger); const owned = state !== undefined && sameIdentity(state.ownerIdentity, identity); return { leg: { namespace, runId: runId ?? ids?.evidence.runIds.at(-1) ?? '' }, turn: scope.turnNumber, clock: (clock?.turn === scope.turnNumber ? clock.now : undefined) ?? (Number.isFinite(scope.turnStartMs) ? new Date(scope.turnStartMs).toISOString() : ''), calls: owned ? state.calls : [], tools: owned ? state.tools : {}, ledgerComplete: owned && state.complete && state.pending.length === 0, }; } //# sourceMappingURL=capture.js.map