UNPKG

agentfootprint

Version:

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

321 lines • 15.9 kB
/** * "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`. */ import { eventBelongsToRun } from '../bridge/eventMeta.js'; import { jsonBytes } from '../lib/boundedJson.js'; import { SHOWN_BYTES } from './size.js'; import { pointerKey } from './render.js'; import { isRecord } from './view.js'; export const 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. */ export const MAX_SHOWN_BYTES = 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. */ export const SHOWN_MORE_KEY = '#more'; const SENTINEL_BYTES = jsonBytes({ [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. */ export const 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`). */ export const 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. */ export const 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). */ export function isShowable(p, allowList = 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 === '' ? SHOWN_STATE_KEYS.includes(p.key) : (SHOWN_STATE_PATHS[p.key] ?? []).includes(shape(p.path)); case 'history': return HISTORY_PATHS.includes(p.path); case 'declaration': return true; } } function resolvePointer(value, path) { let cur = value; for (const seg of segments(path)) { if (Array.isArray(cur)) cur = cur[Number(seg)]; else if (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 > 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. */ export 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; } 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. */ export function showLeaves(account, recording, declarations = {}) { const events = Array.isArray(recording.events) ? recording.events : []; const snapshot = isRecord(recording.snapshot) ? recording.snapshot : {}; const state = 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) => isRecord(e) && e.type === `${PREFIX}agent.run_configured` && 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 = 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 (!isRecord(event) || event.type !== p.type || !isRecord(event.payload)) { out = { withheld: 'not-found' }; } else { const meta = isRecord(event.meta) ? event.meta : {}; const own = run === null || 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 = !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 = jsonBytes(key) + jsonBytes(out) + 2; if (bytes + size > MAX_SHOWN_BYTES - SENTINEL_BYTES) { // Past the cap nothing more is added — one sentinel says the rest is withheld. shown[SHOWN_MORE_KEY] = { withheld: 'too-large' }; break; } bytes += size; shown[key] = out; } return shown; } //# sourceMappingURL=shown.js.map