agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
327 lines • 16.6 kB
JavaScript
;
/**
* "Show me" — the leaf values behind an account's pointers, allow-listed.
*
* The server hands `{ account, shown }` to a reader who may not be an engineer,
* so what leaves is decided HERE, by one list: a pointer whose event type and
* path are on `SHOW_ME_ALLOW_LIST` shows its leaf; anything else says
* `not-shown-here`. The deny list (`DENIED_SEGMENTS`) wins over the allow-list if
* a future pointer ever names one: injection bodies (skill bodies, app
* prompts), tool arguments, tool results (only a derived `{ rows, at }` count
* leaves), a decision's `why` (it can carry a person's note), `resumeInput`,
* every state key but `turnNumber`, `userMessage`, the tool words of
* `coverageDeclared` and the routing verdict's leaves (`SHOWN_STATE_PATHS`),
* history content, and any event of another run.
*
* Bounds: a leaf over 2,048 serialized characters is `too-large`; the whole map
* stops at its share of the op's body, measured in serialized UTF-8 bytes
* (`size.ts` · `SHOWN_BYTES`, 64 KiB less the body's frame) — no later pointer
* adds a key; one `#more` entry says the rest is withheld as `too-large`.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.showLeaves = exports.accountPointers = exports.isShowable = exports.SHOWN_STATE_PATHS = exports.SHOWN_STATE_KEYS = exports.SHOW_ME_ALLOW_LIST = exports.SHOWN_MORE_KEY = exports.MAX_SHOWN_BYTES = exports.MAX_LEAF_CHARS = void 0;
const eventMeta_js_1 = require("../bridge/eventMeta.js");
const boundedJson_js_1 = require("../lib/boundedJson.js");
const size_js_1 = require("./size.js");
const render_js_1 = require("./render.js");
const view_js_1 = require("./view.js");
exports.MAX_LEAF_CHARS = 2048;
/**
* The map's size, in its own unit — serialized UTF-8 bytes (`size.ts` ·
* `SHOWN_BYTES`): what the account and the body's frame leave of the op's
* 192 KiB body. Each entry is measured as JSON writes it (`jsonBytes`), so a
* leaf of characters JSON writes six bytes wide costs six.
*/
exports.MAX_SHOWN_BYTES = size_js_1.SHOWN_BYTES;
/**
* The one key added when the map reaches `MAX_SHOWN_BYTES`: every pointer with
* no key of its own is then withheld as `too-large`. Never a `pointerKey` (those
* start with a pointer kind), so it cannot collide with one.
*/
exports.SHOWN_MORE_KEY = '#more';
const SENTINEL_BYTES = (0, boundedJson_js_1.jsonBytes)({ [exports.SHOWN_MORE_KEY]: { withheld: 'too-large' } }) + 2;
const PREFIX = 'agentfootprint.';
/** Brace sets expand: `/{a,b}/*` → `/a/*`, `/b/*`. `*` matches one array index. */
function expand(pattern) {
const m = /\{([^}]+)\}/.exec(pattern);
if (!m)
return [pattern];
return (m[1] ?? '').split(',').flatMap((alt) => expand(pattern.replace(m[0], alt)));
}
const list = (...patterns) => patterns.flatMap(expand);
/**
* Event type (without the `agentfootprint.` prefix) → the payload leaves "show me" may show.
* A call id (`toolCallId`) is an identity the account's facts carry, never content.
*/
exports.SHOW_ME_ALLOW_LIST = Object.freeze({
'agent.run_configured': list('/llm/model', '/skillGraph/{routing,continuity,scorer}', '/evidenceGate', '/window'),
'agent.turn_start': list('/userPrompt'),
'agent.turn_end': list('/finalContent',
// A typed answer's limits as data: the tools' own coverage words, as `tools.coverage_declared`
// shows them, and which tool's rule assumed a value — never the value (an argument's value).
'/answerCoverage/{checked,notChecked,cannotCover}/*/{what,why,short,kind}', '/answerCoverage/assumed/*/{toolName,argument}',
// The scripts the library attached: the call, its label and its counts — never `text`
// (a tool argument: the program).
'/scripts/shown/*/{toolCallId,dispatch,tool,language,label,exitCode,resultWithheld,rewritten,bytes,lines}', '/scripts/shown/*/truncated/shownBytes', '/scripts/omitted', '/scripts/closedFence/{char,length}', '/scripts/withheldFromText'),
'middleware.decision': list('/{middleware,moment,outcome,changed,toolName,toolCallId}'),
'permission.check': list('/{target,result,policyRuleId}'),
'checkin.decision': list('/{approved,toolName,toolCallId}'),
'skill.turn_routed': list('/{by,from,to,scorer,decisive}', '/scores/*/{id,score}', '/policy/{nearTieMargin,menuSize,floor}',
// R3-S2: the matched words of the PERSON's own message (≤ 80 chars) and the decider's model.
'/witness/text', '/decider/model'),
'skill.graph_declared': list('/nodes/*/{id,title}'),
'skill.rejected': list('/requestedId'),
'context.injected': list('/{slot,source,sourceId}'),
'context.slot_composed': list('/{slot,iteration}'),
'tools.absent': list('/{toolName,toolCallId,lookedFor,tryInstead}', '/{checked,notChecked,cannotCover}/*/{what,why,short,kind}', '/tryInsteadTool/tool'),
'tools.coverage_declared': list('/{toolName,toolCallId}', '/{checked,notChecked,cannotCover}/*/{what,why,short,kind}'),
// A described result: the tool's own source and time — never its data (the facts, series and
// edges are counted by the library and shown only as that count).
'tools.semantics_declared': list('/{toolName,toolCallId}', '/semantics/provenance/{source,measured_at}'),
'stream.tool_start': list('/{toolName,toolCallId}'),
'stream.tool_end': list('/{toolCallId,status,error,notExecuted}', '/notDispatched/pausedCall/{toolCallId,toolName}', '#emptiness'),
'findings.declared': list('/{toolName,toolCallId,basis,expect}'),
'agent.evidence_checked': list('/{posture,candidates,lookedUp,action,afterRevision,evidenceTruncated}', '/unsupported/*/{value,region}',
// The script match's verdict on each block the answer showed — never the code.
'/scripts/blocks/*/{block,label,exitCode,toolCallId,tool,dispatch,alsoMatches,reason}', '/scripts/{earlierTurnRows,regionsDropped,scriptsDropped}'),
});
/** Derived leaves any event of the run may show. */
// The conversation id is NOT shown: in an `open` door it is the conversation's only key, and an
// account is what people share (a PDF, a pasted link). The run id is a per-run name, not a key.
const ANY_EVENT = ['#meta/runId'];
/** The two state keys the account reads for a sentence (R3-S2 adds `userMessage`). */
exports.SHOWN_STATE_KEYS = ['turnNumber', 'userMessage'];
/**
* State keys whose LEAVES "show me" may show, by path shape — the committed
* `coverageDeclared` rows a call before a pause is read from: the same tool
* words `tools.absent` / `tools.coverage_declared` show for a call of this leg.
*/
exports.SHOWN_STATE_PATHS = Object.freeze({
coverageDeclared: list('/*/{toolName,toolCallId,lookedFor}', '/*/{checked,notChecked,cannotCover}/*/{what,why,short,kind}'),
// The routing verdict committed before a pause (`facts/understood.ts` · `routingVerdictOf`):
// the same leaves `skill.turn_routed` shows for a leg that routed — the matched words of the
// PERSON's own message, the skill ids and the scorer's numbers.
turnRoute: list('/{by,from,to}', '/witness/text'),
entryScores: list('/*/{id,score}'),
// A call's script row (`core/agent/scripts/`): its label and counts — never `text`.
findingsLedger: list('/*/{dispatch,outcome,bytes,lines,exitCode,serviceError,resultWithheld,rewritten}'),
});
const HISTORY_PATHS = ['/toolName', '/toolCallId', '#emptiness'];
/** Leaves that are never shown, whatever list names them — per event type. */
const DENIED = {
'context.injected': ['rawContent', 'contentSummary', 'reason'],
'stream.tool_start': ['args'],
'stream.tool_end': ['result', 'modelResult'],
'middleware.decision': ['why'],
'pause.resume': ['resumeInput'],
// An attached script's program is a tool argument, whatever list names it.
'agent.turn_end': ['text'],
};
const segments = (path) => path
.split('/')
.slice(1)
.map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));
const shape = (path) => path.startsWith('#')
? path
: `/${segments(path)
.map((s) => (/^\d+$/.test(s) ? '*' : s))
.join('/')}`;
/**
* Is this pointer's leaf one the allow-list admits (and the deny list does not
* refuse)? The DENY list is asked FIRST and wins: a future edit that puts a
* denied leaf on the allow-list still shows nothing (`allowList` is a parameter
* only so a test can pin exactly that).
*/
function isShowable(p, allowList = exports.SHOW_ME_ALLOW_LIST) {
switch (p.kind) {
case 'event': {
const type = p.type.startsWith(PREFIX) ? p.type.slice(PREFIX.length) : p.type;
const denied = DENIED[type] ?? [];
if (!p.path.startsWith('#') && denied.includes(segments(p.path)[0] ?? ''))
return false;
if (ANY_EVENT.includes(p.path))
return true;
return (allowList[type] ?? []).includes(shape(p.path));
}
case 'state':
return p.path === ''
? exports.SHOWN_STATE_KEYS.includes(p.key)
: (exports.SHOWN_STATE_PATHS[p.key] ?? []).includes(shape(p.path));
case 'history':
return HISTORY_PATHS.includes(p.path);
case 'declaration':
return true;
}
}
exports.isShowable = isShowable;
function resolvePointer(value, path) {
let cur = value;
for (const seg of segments(path)) {
if (Array.isArray(cur))
cur = cur[Number(seg)];
else if ((0, view_js_1.isRecord)(cur))
cur = cur[seg];
else
return undefined;
}
return cur;
}
function leaf(value) {
if (value === undefined)
return { withheld: 'not-found' };
if (value !== null && typeof value === 'object')
return { withheld: 'not-shown-here' };
if (typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean' &&
value !== null) {
return { withheld: 'not-shown-here' };
}
if (JSON.stringify(value).length > exports.MAX_LEAF_CHARS)
return { withheld: 'too-large' };
return { value };
}
function declarationValue(declarations, field) {
const [head, ...rest] = field.split('.');
if (head === 'skills' && rest.length >= 2)
return declarations.skills?.[rest.slice(0, -1).join('.')]?.label;
if (head === 'tools' && rest.length >= 2)
return declarations.tools?.[rest.slice(0, -1).join('.')]?.rowsAt;
if (head === 'routing')
return declarations.routing?.appDecides;
return undefined;
}
/** Every pointer the account uses, in first-use order. */
function accountPointers(account) {
const out = [];
const fromSentence = (s) => {
if (s === undefined)
return;
out.push(...s.pointers);
};
const fromFact = (f) => out.push(...f.pointers);
fromFact(account.run);
fromFact(account.question);
fromFact(account.answer);
for (const row of account.rows) {
fromSentence(row.heading);
row.lines.forEach(fromSentence);
fromSentence(row.more);
}
account.signals.forEach((s) => fromSentence(s.sentence));
account.unreachable.forEach((u) => fromSentence(u.sentence));
fromSentence(account.summary.sentence);
const f = account.facts;
[
f.routing.configured,
f.routing.verdict,
f.routing.scores,
f.routing.delivered,
f.evidence,
f.limitsBlock,
f.limitsData,
].forEach((x) => fromFact(x));
f.calls.forEach((c) => out.push(...c.pointers));
f.inView.forEach((c) => out.push(...c.pointers));
f.beforePause.forEach((c) => out.push(...(c.pointers ?? [])));
return out;
}
exports.accountPointers = accountPointers;
function emptinessLeaf(account, declarations, toolCallId, where) {
const call = where === 'event'
? account.facts.calls.find((c) => c.toolCallId === toolCallId)
: account.facts.inView.find((c) => c.toolCallId === toolCallId) ??
account.facts.beforePause.find((c) => c.toolCallId === toolCallId);
if (call === undefined || call.rows === undefined)
return { withheld: 'not-shown-here' };
const base = where === 'history'
? '/content'
: 'view' in call && call.view !== undefined && call.view !== 'result'
? '/modelResult'
: '/result';
// Where the reader took the count (`countedAt`): the rowset, or the dataset ticket's `rows`
// left in its place. An account made before the reader said so: the declared key, as then.
if (call.countedAt !== undefined)
return { rows: call.rows, at: `${base}${call.countedAt}` };
const rowsAt = call.emptinessSource === 'app' ? declarations.tools?.[call.toolName]?.rowsAt : undefined;
return { rows: call.rows, at: rowsAt !== undefined ? `${base}/${rowsAt}` : base };
}
/**
* The leaf map for an account's pointers — one entry per distinct pointer, keyed
* by `pointerKey`. `recording` is the same recording the account was built from.
*/
function showLeaves(account, recording, declarations = {}) {
const events = Array.isArray(recording.events) ? recording.events : [];
const snapshot = (0, view_js_1.isRecord)(recording.snapshot) ? recording.snapshot : {};
const state = (0, view_js_1.isRecord)(snapshot.sharedState) ? snapshot.sharedState : {};
const history = Array.isArray(state.history) ? state.history : [];
const run = account.run.value;
const shown = {};
let bytes = 0;
// The run's session, read from the recording itself (the account never carries it): it
// decides whether an event that names no run is this run's.
const configured = events.find((e) => (0, view_js_1.isRecord)(e) &&
e.type === `${PREFIX}agent.run_configured` &&
(0, view_js_1.isRecord)(e.meta) &&
e.meta.runId === run?.runId);
const session = typeof configured?.meta.sessionId === 'string' ? configured.meta.sessionId : undefined;
for (const p of accountPointers(account)) {
const key = (0, render_js_1.pointerKey)(p);
if (key in shown)
continue;
let out;
if (!isShowable(p)) {
out = { withheld: 'not-shown-here' };
}
else if (p.kind === 'event') {
const event = events[p.index];
if (!(0, view_js_1.isRecord)(event) || event.type !== p.type || !(0, view_js_1.isRecord)(event.payload)) {
out = { withheld: 'not-found' };
}
else {
const meta = (0, view_js_1.isRecord)(event.meta) ? event.meta : {};
const own = run === null ||
(0, eventMeta_js_1.eventBelongsToRun)({
...(typeof meta.runId === 'string' && { runId: meta.runId }),
...(typeof meta.sessionId === 'string' && { sessionId: meta.sessionId }),
}, {
runId: run.runId,
...(session !== undefined && { sessionId: session }),
});
if (!own)
out = { withheld: 'foreign' };
else if (p.path === '#emptiness')
out = emptinessLeaf(account, declarations, event.payload.toolCallId, 'event');
else if (p.path.startsWith('#meta/'))
out = leaf(meta[p.path.slice('#meta/'.length)]);
else
out = leaf(resolvePointer(event.payload, p.path));
}
}
else if (p.kind === 'state') {
out = leaf(p.path === '' ? state[p.key] : resolvePointer(state[p.key], p.path));
}
else if (p.kind === 'history') {
const message = history[p.index];
out = !(0, view_js_1.isRecord)(message)
? { withheld: 'not-found' }
: p.path === '#emptiness'
? emptinessLeaf(account, declarations, message.toolCallId, 'history')
: leaf(resolvePointer(message, p.path));
}
else {
out = leaf(declarationValue(declarations, p.field));
}
const size = (0, boundedJson_js_1.jsonBytes)(key) + (0, boundedJson_js_1.jsonBytes)(out) + 2;
if (bytes + size > exports.MAX_SHOWN_BYTES - SENTINEL_BYTES) {
// Past the cap nothing more is added — one sentinel says the rest is withheld.
shown[exports.SHOWN_MORE_KEY] = { withheld: 'too-large' };
break;
}
bytes += size;
shown[key] = out;
}
return shown;
}
exports.showLeaves = showLeaves;
//# sourceMappingURL=shown.js.map