agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
522 lines • 23 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.negativeRecordOf = exports.serveNegativeHandle = exports.keepNegativeResultOut = exports.captureNegativeRefusal = exports.settleNegativeCall = exports.pausedNegativeResultId = exports.pauseNegativeCall = exports.captureNegativeCoverage = exports.captureNegativeResult = exports.startNegativeCall = exports.createNegativeRuntimeState = void 0;
/**
* 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.
*/
const footprintjs_1 = require("footprintjs");
const capture_js_1 = require("../../../integrity/claim-binding/capture.js");
const normalize_js_1 = require("../../../lib/normalize.js");
const jsonFaithful_js_1 = require("../../../lib/jsonFaithful.js");
const stableJson_js_1 = require("../../../lib/stableJson.js");
const runRedaction_js_1 = require("../../../redaction/runRedaction.js");
const envelope_js_1 = require("../../../semantics/envelope.js");
const rows_js_1 = require("../../../time/rows.js");
const overclaim_js_1 = require("../../../semantics/overclaim.js");
const toolEffects_js_1 = require("../../../semantics/toolEffects.js");
const among_js_1 = require("../../../semantics/coverage/among.js");
const recognize_js_1 = require("../../../semantics/coverage/recognize.js");
const facets_js_1 = require("../../../semantics/coverage/search/facets.js");
const inEffect_js_1 = require("../../../semantics/coverage/search/inEffect.js");
const hostFilled_js_1 = require("../../../tools/hostFilled.js");
const searchOf_js_1 = require("../../../tools/searchOf.js");
const tools_js_1 = require("../../../tools/tools.js");
const emptiness_js_1 = require("../emptiness.js");
const verificationState_js_1 = require("../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 (0, stableJson_js_1.stableJson)(a ?? null) === (0, stableJson_js_1.stableJson)(b ?? null);
}
catch {
return false;
}
};
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 }),
};
}
exports.createNegativeRuntimeState = createNegativeRuntimeState;
/** 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. */
function startNegativeCall(scope, input) {
const { tool, context } = input;
const runId = input.runId || 'unidentified-run';
let dispatch = input.dispatch;
if (dispatch === undefined) {
const reserved = (0, verificationState_js_1.reserveResultIdentity)(scope.resultIdentities ?? (0, verificationState_js_1.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 (!(0, jsonFaithful_js_1.isJsonFaithful)(input.args))
keptOut = true;
args = structuredClone(input.args);
}
catch {
keptOut = true;
}
const remote = tool.source !== undefined || input.party?.channel === 'provider';
const search = (0, searchOf_js_1.searchOf)(tool).search;
const fills = (0, hostFilled_js_1.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) => (0, runRedaction_js_1.runKeepsOut)(scope, key));
let composedScope;
if (search !== undefined) {
composedScope = (0, facets_js_1.composeChecked)({
search,
readings: search.arguments.map((arg) => [arg, (0, inEffect_js_1.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 || (0, tools_js_1.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 };
}
exports.startNegativeCall = startNegativeCall;
/** Bind a standalone among miss to the actual argument, never its looked-for prose. */
function amongRow(call, value, handle, tool) {
const absence = (0, recognize_js_1.readAbsence)(value);
if (absence === undefined)
return undefined;
const names = (0, among_js_1.namesFacetOf)(absence);
if (names === undefined)
return undefined;
const sent = call.arguments[names.argument];
if ((typeof sent !== 'string' && typeof sent !== 'number') ||
!(0, normalize_js_1.sameName)(String(sent), absence.looked_for))
return undefined;
const fills = (0, hostFilled_js_1.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 = (0, toolEffects_js_1.readToolResultEnvelope)(value);
const raw = envelope === undefined ? value : envelope.content;
const sem = (0, envelope_js_1.readSemantics)(raw);
const ledger = (0, recognize_js_1.readCoverageLedger)(raw) ?? (0, recognize_js_1.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. */
function captureNegativeResult(scope, dispatch, value, tool) {
try {
if (!(0, jsonFaithful_js_1.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) => (0, runRedaction_js_1.runKeepsOut)(scope, key))) {
keepNegativeResultOut(scope, dispatch.resultId);
return copy;
}
const envelope = (0, toolEffects_js_1.readToolResultEnvelope)(copy);
const raw = envelope === undefined ? copy : envelope.content;
const sem = (0, envelope_js_1.readSemantics)(raw);
const absent = (0, recognize_js_1.readAbsence)(raw) ?? (0, recognize_js_1.readDeclaredAbsence)(raw);
const ledger = (0, recognize_js_1.readCoverageLedger)(raw) ?? (0, recognize_js_1.readDeclaredLedger)(raw);
const read = (0, emptiness_js_1.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;
}
}
exports.captureNegativeResult = captureNegativeResult;
function captureNegativeCoverage(scope, row) {
replace(scope, row.resultId, (call) => call.keptOut ? call : { ...call, row: structuredClone(row) });
}
exports.captureNegativeCoverage = captureNegativeCoverage;
/** Park the native occurrence itself; provider call ids are never a selector. */
function pauseNegativeCall(scope, resultId) {
const state = stateOf(scope);
if (state !== undefined)
publish(scope, { ...state, pausedResultId: resultId });
}
exports.pauseNegativeCall = pauseNegativeCall;
function pausedNegativeResultId(scope) {
return stateOf(scope)?.pausedResultId;
}
exports.pausedNegativeResultId = pausedNegativeResultId;
/** The terminal bracket settles an attempt; a human answer is not a native observation. */
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),
});
}
exports.settleNegativeCall = settleNegativeCall;
/** Existing semantic recognition, filed as codes only; a refusal never manufactures a read. */
function captureNegativeRefusal(scope, tool, value, args) {
const state = stateOf(scope);
if (state === undefined)
return;
try {
const direct = (0, overclaim_js_1.refusalIssues)(value, 'result');
const text = typeof value === 'string' ? value : JSON.stringify(value);
const contextual = Object.entries(args ?? {}).some(([argument, sent]) => (0, overclaim_js_1.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 ((0, footprintjs_1.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 });
}
}
exports.captureNegativeRefusal = captureNegativeRefusal;
/** The transform cannot supply evidence; unreadable delivered output also withholds its handle. */
function keepNegativeResultOut(scope, resultId) {
if (resultId !== undefined)
replace(scope, resultId, (call) => ({ ...call, keptOut: true }));
}
exports.keepNegativeResultOut = keepNegativeResultOut;
/** A framework suffix, after transforms/caps. Gap notes and periods survive; inventory names never do. */
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 = (0, stableJson_js_1.stableJson)(call.rowsRead);
rowsMayDisplay =
native !== undefined &&
candidates.some((candidate) => Array.isArray(candidate) &&
candidate.every(record) &&
(0, stableJson_js_1.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 }),
},
})}`;
}
exports.serveNegativeHandle = serveNegativeHandle;
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 = (0, capture_js_1.readResultIdentity)(state.calls[0].resultId).namespace;
}
catch {
/* unknown */
}
}
const clock = (0, rows_js_1.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,
};
}
exports.negativeRecordOf = negativeRecordOf;
//# sourceMappingURL=capture.js.map