agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
394 lines • 19.1 kB
JavaScript
/**
* The tool calls of THIS run — one `ToolCallFact` each, in call order.
*
* Outcome, from typed fields only (never a `why` prose):
* - `not-dispatched` — `tool_end.notDispatched` (a batch settled on a pause);
* - `declined` — a person was asked and declined: a before-tool deny on
* the call a RESUMED leg answers (its start is in the
* earlier leg), or a `checkin.decision` with
* `approved: false`;
* - `refused` — BEFORE-tool only: `tool_end.notExecuted`, a deny
* `middleware.decision` with `moment: 'before-tool'`, or a
* `permission.check` for this call that did not allow;
* - `failed` — `tool_end.error`;
* - `unknown` — a start with no recorded end;
* - `ran` — otherwise. An AFTER-tool deny keeps `ran` and sets
* `withheldBy`: the tool ran and the model read the
* rule's refusal (`notExecuted` covers before-tool rules
* only, and its absence proves nothing).
*
* Emptiness is read from what the MODEL read — `modelResult ?? result` — and
* never judged on a withheld result. The reader is the ONE emptiness reader
* (`core/agent/emptiness.ts` · `readEmptiness`), handed the door this
* record holds for the call: its absence (the delivered status, `tools.absent`),
* its boundary (`tools.coverage_declared`) and its described envelope
* (`tools.semantics_declared`).
*/
import { strip } from '../../semantics/coverage/read.js';
import { v } from '../render.js';
import { isKeptOut, isRecord, keptOut, str } from '../view.js';
import { at, countedFields, derived, FACT_TEXT_CHARS, historyAt, historyOf, readEmptiness, rowsAtOf, stateAt, } from './common.js';
/**
* At most this many calls are LISTED in `facts.calls`; the rest are counted.
* A cap on what is listed, never on what is judged: the checks, the error
* counts and the withheld count read every call (`CallsRead.all`).
*/
export const MAX_CALLS = 50;
const SECTIONS = ['checked', 'notChecked', 'cannotCover'];
function deepEqual(a, b) {
if (a === b)
return true;
if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object')
return false;
if (Array.isArray(a) !== Array.isArray(b))
return false;
const ka = Object.keys(a);
const kb = Object.keys(b);
if (ka.length !== kb.length)
return false;
return ka.every((k) => deepEqual(a[k], b[k]));
}
function indexByCall(ctx) {
const cache = new Map();
return (type, id) => {
let byId = cache.get(type);
if (byId === undefined) {
byId = new Map();
for (const e of ctx.view.ofType(type)) {
const key = str(e.payload.toolCallId);
if (key === undefined)
continue;
const list = byId.get(key) ?? [];
list.push(e);
byId.set(key, list);
}
cache.set(type, byId);
}
return byId.get(id) ?? [];
};
}
/**
* The ONE reading of a tool's coverage declaration(s) — from its events for a
* call of this run, or from its rows of the committed `coverageDeclared`
* state for a call before a pause (`readStateCoverage`). Same fields, same
* refusals; only the pointer base differs.
*/
function readDeclarations(sources, events) {
const first = sources[0];
if (first === undefined)
return undefined;
const items = [];
let lookedFor;
let tryInsteadTool;
for (const { fields, base, isKeptOut: wordsKeptOut, keptOutField } of sources) {
// What the tool looked for, unless the record keeps those words out (then
// the account says what it found without them — never the placeholder).
const lf = keptOutField('lookedFor') ? undefined : str(fields.lookedFor);
if (lookedFor === undefined && lf !== undefined && lf.length > 0 && !wordsKeptOut(lf))
lookedFor = { text: lf, pointer: base('lookedFor') };
const tit = fields.tryInsteadTool;
if (tryInsteadTool === undefined && isRecord(tit) && typeof tit.tool === 'string') {
tryInsteadTool = { tool: tit.tool, pointer: base('tryInsteadTool', 'tool') };
}
for (const section of SECTIONS) {
const list = fields[section];
if (!Array.isArray(list))
continue;
list.forEach((item, position) => {
if (!isRecord(item) || typeof item.what !== 'string')
return;
// The item's words kept out: it still exists, with its kind — said so, never printed.
const kept = wordsKeptOut(item.what);
const short = !kept && typeof item.short === 'string' && item.short.trim().length > 0
? item.short
: undefined;
// `kind` is refused on `checked` (a checked item is not a limit), read-never-repaired:
// a hand-built envelope that puts one there gets no kind, no chip and no signal.
const kind = section !== 'checked' && (item.kind === 'existence' || item.kind === 'scope')
? item.kind
: undefined;
items.push({
section,
what: kept ? '' : item.what,
...(short !== undefined && { short }),
...(kind !== undefined && { kind }),
...(kept && { keptOut: true }),
base,
position,
});
});
}
}
const named = sources.find((src) => typeof src.fields.toolName === 'string');
return {
kind: sources.some((src) => src.absent) ? 'absent' : 'coverage',
events,
head: named !== undefined ? named.base('toolName') : first.base('toolCallId'),
...(lookedFor !== undefined && { lookedFor }),
items,
...(tryInsteadTool !== undefined && { tryInsteadTool }),
};
}
/**
* A call's declaration(s) from its events. `served`: the record was served
* under a redaction policy, so a placeholder in it is words kept out
* (`view.ts` · `isKeptOut`) — never in any other record, where a tool may
* have declared that string itself.
*/
function readCoverage(events, served) {
return readDeclarations(events.map((event) => ({
fields: event.payload,
base: (...keys) => at(event, ...keys),
absent: event.type.endsWith('tools.absent'),
isKeptOut: (value) => served && isKeptOut(value),
keptOutField: (field) => keptOut(event, field),
})), events);
}
/** The committed state's coverage key: every declaration the run's tools made, tracked. */
export const COVERAGE_STATE_KEY = 'coverageDeclared';
/**
* A call's declaration as the committed state keeps it — its rows of
* `coverageDeclared` (tracked state: a limit is a fact about the answer, so it
* rides every recording, `core/agent/types.ts` · `coverageDeclared`).
* What a call BEFORE a pause declared is read here: its events are in the
* earlier leg, its rows are in this record's state.
*/
export function readStateCoverage(rows, toolCallId, served) {
const sources = [];
const wordsKeptOut = (value) => served && isKeptOut(value);
rows.forEach((row, index) => {
if (!isRecord(row) || row.toolCallId !== toolCallId)
return;
sources.push({
fields: row,
base: (...keys) => stateAt(COVERAGE_STATE_KEY, index, ...keys),
absent: row.kind === 'absence',
isKeptOut: wordsKeptOut,
keptOutField: (field) => wordsKeptOut(row[field]),
});
});
return readDeclarations(sources, []);
}
function toolNameFor(ctx, id, start, coverage) {
const fromStart = str(start?.payload.toolName);
if (start !== undefined && fromStart !== undefined)
return { name: fromStart, pointer: at(start, 'toolName') };
const declared = coverage?.events.find((e) => str(e.payload.toolName) !== undefined);
if (declared !== undefined)
return { name: str(declared.payload.toolName), pointer: at(declared, 'toolName') };
const history = historyOf(ctx.view);
const index = history.findIndex((m) => isRecord(m) && m.toolCallId === id && typeof m.toolName === 'string');
if (index < 0)
return undefined;
const message = history[index];
return { name: message.toolName, pointer: historyAt(index, '/toolName', id) };
}
/** The permission rows between this call's start and end that name its tool. */
function permissionRows(ctx, toolName, start, end) {
if (start === undefined)
return [];
const upTo = end?.index ?? Number.POSITIVE_INFINITY;
return ctx.view
.ofType('permission.check')
.filter((e) => e.index > start.index && e.index < upTo && e.payload.target === toolName);
}
/** The permission verdicts that stop a call before it runs. */
export const PERMISSION_REFUSALS = new Set(['deny', 'halt']);
/** The `tool_end` fields that say how a call ended — read by `outcomeOf`. */
const OUTCOME_FIELDS = ['notDispatched', 'notExecuted', 'error'];
function outcomeOf(ctx, byCall, id, toolName, start, end) {
const decisions = byCall('middleware.decision', id);
const beforeDeny = decisions.find((e) => e.payload.moment === 'before-tool' && e.payload.outcome === 'deny');
const afterDeny = decisions.find((e) => e.payload.moment === 'after-tool' && e.payload.outcome === 'deny');
// Only `deny` and `halt` stop a call. `gate_open` lets it run (`core/Agent.ts`: allowed =
// allow || gate_open), so it is never a refusal.
const permissionDeny = permissionRows(ctx, toolName, start, end).find((e) => PERMISSION_REFUSALS.has(e.payload.result));
const checkInDeclined = byCall('checkin.decision', id).find((e) => e.payload.approved === false);
const pausedId = str(ctx.view.state?.pausedToolCallId);
const isPausedCall = ctx.resumedLeg && ((pausedId !== undefined && pausedId === id) || start === undefined);
// The record keeps out a field that says how the call ended (a redaction
// policy's placeholder): how it ended cannot be told, so it is not guessed.
if (end !== undefined && OUTCOME_FIELDS.some((field) => keptOut(end, field)))
return { outcome: 'unknown' };
if (end?.payload.notDispatched !== undefined)
return { outcome: 'not-dispatched', ruleEvent: end };
if (checkInDeclined !== undefined)
return { outcome: 'declined', ruleEvent: checkInDeclined };
if (beforeDeny !== undefined && isPausedCall)
return { outcome: 'declined', ruleEvent: beforeDeny };
if (beforeDeny !== undefined) {
const by = str(beforeDeny.payload.middleware);
return {
outcome: 'refused',
ruleEvent: beforeDeny,
...(by !== undefined && { refusedBy: by }),
};
}
if (permissionDeny !== undefined) {
const by = str(permissionDeny.payload.policyRuleId);
return {
outcome: 'refused',
ruleEvent: permissionDeny,
...(by !== undefined && { refusedBy: by }),
};
}
if (end?.payload.notExecuted === true)
return { outcome: 'refused', ruleEvent: end };
if (end?.payload.error === true)
return { outcome: 'failed' };
if (end === undefined)
return { outcome: 'unknown' };
const by = str(afterDeny?.payload.middleware);
return afterDeny !== undefined
? { outcome: 'ran', ruleEvent: afterDeny, ...(by !== undefined && { withheldBy: by }) }
: { outcome: 'ran' };
}
/** The one leaf on `tool_end` that says how the call ended, as the account read it. */
export function endPointer(end, emptiness) {
if (isRecord(end.payload.notDispatched)) {
return at(end, 'notDispatched', 'pausedCall', 'toolCallId');
}
for (const key of ['notExecuted', 'error', 'status']) {
if (end.payload[key] !== undefined)
return at(end, key);
}
return emptiness.emptiness === 'unknown' ? at(end, 'toolCallId') : derived(end, '#emptiness');
}
/**
* Whether the result the emptiness would be read from is kept out of the
* record: `modelResult` when the call has one, else `result` (a kept-out
* field reads as absent, so a kept-out `modelResult` falls to the first arm).
*/
function resultIsKeptOut(end) {
if (keptOut(end, 'modelResult'))
return true;
return !('modelResult' in end.payload) && keptOut(end, 'result');
}
function viewOf(end) {
if (!('modelResult' in end.payload))
return 'result';
return deepEqual(strip(end.payload.result), end.payload.modelResult)
? 'model-result-record-only'
: 'model-result';
}
function readOne(ctx, byCall, id) {
const start = byCall('stream.tool_start', id)[0];
const ends = byCall('stream.tool_end', id);
const end = ends[ends.length - 1];
const coverageEvents = [...byCall('tools.absent', id), ...byCall('tools.coverage_declared', id)];
const coverage = readCoverage(coverageEvents, ctx.view.servedUnderPolicy);
const described = byCall('tools.semantics_declared', id)[0];
const named = toolNameFor(ctx, id, start, coverage);
// A call no event names is still a call: counted as unread, judged, and said to be unnamed.
if (named === undefined)
ctx.noteUnread();
const toolName = named?.name ?? '';
const outcome = outcomeOf(ctx, byCall, id, toolName, start, end);
const judged = outcome.outcome === 'ran' && outcome.withheldBy === undefined && end !== undefined;
// The door THIS record holds for the call — its events — decides what was declared; a marker
// in the bytes the run did not recognize (an envelope returned as JSON text) is plain data.
// A result the record keeps out (a redaction policy left its placeholder
// there — `view.ts` · `keptOut`) is not judged: whether it was empty cannot be
// read, and the account says why instead of reading the placeholder.
const resultKeptOut = judged && resultIsKeptOut(end);
const emptiness = resultKeptOut
? { emptiness: 'unknown', undeclaredShape: false, rowsUnread: 'redacted' }
: judged
? readEmptiness('modelResult' in end.payload ? end.payload.modelResult : end.payload.result, {
...(rowsAtOf(ctx.declarations, toolName) !== undefined && {
rowsAt: rowsAtOf(ctx.declarations, toolName),
}),
// A served record's rows key holding the placeholder is kept out, never "no list".
...(ctx.view.servedUnderPolicy && { keptOut: isKeptOut }),
door: {
absent: end.payload.status === 'absent' || coverage?.kind === 'absent',
bounded: coverageEvents.some((e) => e.type.endsWith('tools.coverage_declared')),
...(described !== undefined && { described: described.payload.semantics }),
},
})
: { emptiness: 'unknown', undeclaredShape: false };
const findings = byCall('findings.declared', id)[0];
const basis = str(findings?.payload.basis);
const expect = str(findings?.payload.expect);
const idPointer = start ? at(start, 'toolCallId') : end ? at(end, 'toolCallId') : undefined;
const namePointer = named?.pointer ?? idPointer;
const pointers = [
...(namePointer ? [namePointer] : []),
...(end ? [endPointer(end, emptiness)] : []),
];
const fact = {
toolCallId: id.slice(0, FACT_TEXT_CHARS),
toolName: toolName.slice(0, FACT_TEXT_CHARS),
...(named === undefined && { unnamed: true }),
outcome: outcome.outcome,
...(outcome.refusedBy !== undefined && { refusedBy: outcome.refusedBy }),
...(outcome.withheldBy !== undefined && { withheldBy: outcome.withheldBy }),
emptiness: emptiness.emptiness,
...countedFields(emptiness),
...(emptiness.described !== undefined && { described: emptiness.described }),
...(emptiness.bounded === true && { bounded: true }),
...(judged && !resultKeptOut && { view: viewOf(end) }),
...(coverage !== undefined && {
coverage: {
kind: coverage.kind,
...(coverage.lookedFor !== undefined && {
lookedFor: coverage.lookedFor.text.slice(0, FACT_TEXT_CHARS),
}),
checked: coverage.items.filter((i) => i.section === 'checked').length,
notChecked: coverage.items.filter((i) => i.section === 'notChecked').length,
cannotCover: coverage.items.filter((i) => i.section === 'cannotCover').length,
kinds: coverage.items.filter((i) => i.kind !== undefined).length,
...(coverage.tryInsteadTool !== undefined && {
tryInsteadTool: coverage.tryInsteadTool.tool.slice(0, FACT_TEXT_CHARS),
}),
},
}),
...(basis !== undefined && {
expectation: { basis, ...(expect !== undefined && { expect }) },
}),
pointers,
};
return {
fact,
...(start !== undefined && { start }),
...(end !== undefined && { end }),
...(outcome.ruleEvent !== undefined && { ruleEvent: outcome.ruleEvent }),
...(coverage !== undefined && { coverage }),
...(described !== undefined && { described }),
emptiness,
...(findings !== undefined && { findings }),
...(namePointer !== undefined && { toolPointer: namePointer }),
tool: named
? v(named.name, 'library', named.pointer, FACT_TEXT_CHARS)
: v(id, 'library', idPointer, FACT_TEXT_CHARS),
toolName,
...(named === undefined && { unnamed: true }),
};
}
/** Every call of this run, first-seen order (a resumed leg's paused call has only an end). */
export function readCalls(ctx) {
const firstSeen = new Map();
for (const type of ['stream.tool_start', 'stream.tool_end']) {
for (const e of ctx.view.ofType(type)) {
const id = str(e.payload.toolCallId);
if (id !== undefined && !firstSeen.has(id))
firstSeen.set(id, e.index);
}
}
const ids = [...firstSeen.entries()].sort((a, b) => a[1] - b[1]).map(([id]) => id);
const byCall = indexByCall(ctx);
const calls = ids.map((id) => readOne(ctx, byCall, id));
return {
all: calls,
calls: calls.slice(0, MAX_CALLS),
omitted: Math.max(0, calls.length - MAX_CALLS),
ids: new Set(ids),
};
}
/** The declaration's head: its tool name when the row holds one, else the call id it was joined by. */
export function coverageHead(coverage) {
return coverage.head;
}
/** Pointer to one coverage item's leaf. */
export const itemAt = (item, field) => item.base(item.section, item.position, field);
//# sourceMappingURL=calls.js.map