UNPKG

agentfootprint

Version:

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

266 lines • 11.9 kB
/** * coverage/inProgress — "this has not finished; its outcome is not known yet". * * Pattern: one rule set, two doors (the `period.ts` law): `coverage()`'s mint * REFUSES a malformed `inProgress` at the line the author wrote; the * dispatch door READS a foreign `in_progress` (a sidecar in another * language mints `af_coverage` too) and drops a malformed one from the * record, dev-warned, never repaired. Both ask {@link readInProgressList}. * Role: core/ layer, pure. * Emits: N/A (the caller emits `agentfootprint.tools.coverage_declared`). * * ## Why a third outcome needs its own words * * A vendor state machine has at least three outcomes: settled-ok, in * progress, failed. A tool that tests "equals the success value" counts the * middle one as a failure — a backup two hours into its run read beside a VM * with no backup at all; an array with zero unhealthy replication sessions * read as "97 non-OK" because 96 were still synchronizing. Both were measured * in the field, and both would have been escalated. * * The fix belongs in the TOOL: only the tool knows which of its vendor's states * are in flight, and when the vendor gives its own verdict field that verdict * wins over a state name. So this library never reads a state string. What it * adds is a place to SAY the third outcome — `coverage(value, { checked, * inProgress })` — so the model reads it as data, the record keeps it, and a * person reading the answer sees it beside the other limits. * * ## What it deliberately does not do * * It does not change the answer's standing. "Not sure" would be right only * when the answer STATES a settled outcome for an item in flight, and the * record holds the answer as prose: telling "vm-42's backup is still running" * from "vm-42 has no backup" is a reading of words — a phrase parser or a * model judge, both refused here. Firing on every answer from such a tool would * mark the honest answers too. So the declaration is a LABEL: served, recorded, * printed with the limits (docs/design/honesty/results.md § 11). */ import { isDevMode } from 'footprintjs'; import { shortProblem } from './items.js'; import { refusal } from './refusal.js'; /** * The wire spelling as DATA — the key an in-progress list sits under inside * `af_coverage`, and its one item key that is not the coverage items' own. * `canonical-notes.json` publishes it (`scripts/gen-canonical-notes.mjs`), so a * tool written in another language mints it byte for byte. */ export const IN_PROGRESS_WIRE = Object.freeze({ /** The key the list sits under inside `af_coverage`. */ key: 'in_progress', /** How many things one entry stands for — a positive whole number. */ count: 'count', }); /** * The one clause the dispatch door appends to the note of a ledger that * declares a well-formed, non-empty `in_progress` — the model is SERVED it; the * tool's own output keeps `COVERAGE_NOTE`. Static, never interpolated; anchored * to "the call this result answers" (the container deictic's own anchor), so a * later call re-reading it cannot take it for its own. Added at the door, not * at the mint, so an envelope a sidecar minted gets it too. */ export const IN_PROGRESS_CLAUSE = '`in_progress` is what the call this result answers found still running: its outcome is ' + 'not known yet, so it is neither a success nor a failure — report it as in progress, never ' + 'as either.'; /** An item's keys, tied to {@link InProgressItem} in BOTH directions. */ const ITEM_KEYS = Object.keys({ what: true, why: true, short: true, count: true, }); /** `null` reads as omitted — a JSON producer writes a missing value as `null`. */ const given = (v) => v !== undefined && v !== null; /** * THE rule set for one list. `at` is the list's name as the reader spells it * (`inProgress` at the mint, `in_progress` on the wire). `strictKeys` refuses * an item key the shape does not have (the mint: a misspelt `why` must not * vanish); the wire reader copies the known keys and lets the rest ride, as * every recognized envelope does. Everything else is one rule. */ export function readInProgressList(raw, at, strictKeys) { if (!Array.isArray(raw)) { return { problem: `\`${at}\` must be an array of strings or { what, why?, short?, count? } entries — ` + 'to say nothing is running, omit it.', }; } const items = []; for (let i = 0; i < raw.length; i++) { const read = itemOf(raw[i], `${at}[${i}]`, strictKeys); if (read.problem !== undefined) return { problem: read.problem }; items.push(read.item); } return { items }; } function itemOf(raw, at, strictKeys) { if (typeof raw === 'string') { const what = raw.trim(); return what === '' ? { problem: `${at} is blank — say what is still running.` } : { item: { what } }; } if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { return { problem: `${at} must be a non-empty string or { what, why?, short?, count? }.` }; } const rec = raw; if (strictKeys) { const unknownKey = Object.keys(rec).find((k) => !ITEM_KEYS.includes(k)); if (unknownKey === 'kind') { return { problem: `${at} has \`kind\` — it names ground a call did NOT reach, and an item in progress ` + 'is ground the call read.', }; } if (unknownKey !== undefined) { return { problem: `${at} has an unknown key '${unknownKey}' — the fields are what, why, short, count.`, }; } } if (typeof rec.what !== 'string' || rec.what.trim() === '') { return { problem: `${at} names nothing — \`what\` must say what is still running.` }; } const what = rec.what.trim(); const why = given(rec.why) ? rec.why : undefined; if (why !== undefined && (typeof why !== 'string' || why.trim() === '')) { return { problem: `${at} ('${what}') has a \`why\` that says nothing — give one or omit it.` }; } const short = given(rec.short) ? rec.short : undefined; const shortFault = short === undefined ? undefined : shortProblem(short, what); if (shortFault !== undefined) return { problem: `${at} ('${what}') — ${shortFault}` }; const count = given(rec.count) ? rec.count : undefined; if (count !== undefined && !(Number.isSafeInteger(count) && count >= 1)) { return { problem: `${at} ('${what}') — \`count\` must be a whole number of at least 1 (how many things ` + 'this entry stands for), or omitted for one.', }; } return { item: { what, ...(why !== undefined && { why: why.trim() }), ...(short !== undefined && { short: short.trim() }), ...(count !== undefined && { count: count }), }, }; } /** * The mint's reading: the normalized items, `[]` when omitted (`undefined` or * `null`) — and a refusal, thrown where `coverage()` is called, for anything * the rule set refuses. */ export function mintInProgress(raw) { if (!given(raw)) return []; const read = readInProgressList(raw, 'inProgress', true); if (read.problem !== undefined) throw refusal(read.problem); return read.items; } /** Tools already warned about a dropped list — once per tool per process. */ const warnedTools = new Set(); const MAX_WARNED = 1000; /** * A ledger marker's `in_progress`, read for the RECORD: the items when the list * is well-formed and non-empty, else `undefined` — a malformed one dropped from * the record and named once per tool in dev mode (the model still reads what * the tool wrote, without the clause). */ export function inProgressOf(marker, toolName) { const raw = marker.in_progress; if (!given(raw)) return undefined; const read = readInProgressList(raw, IN_PROGRESS_WIRE.key, false); if (read.problem !== undefined) { warnDropped(toolName, read.problem); return undefined; } return read.items.length > 0 ? read.items : undefined; } function warnDropped(toolName, problem) { const key = toolName ?? ''; if (warnedTools.has(key) || !isDevMode()) return; if (warnedTools.size < MAX_WARNED) warnedTools.add(key); // eslint-disable-next-line no-console console.warn(`agentfootprint coverage: tool '${toolName ?? '(unknown)'}' returned an in_progress list ` + `the record cannot carry, so it was left off the record (the result itself is kept and ` + `served as written): ${problem} This warning fires once per tool per process.`); } /** * Forget every dropped-list warning issued so far. * @internal test seam — the ledger is process-wide and warn-once. */ export function _resetInProgressWarnings() { warnedTools.clear(); } /** * One ledger marker as SERVED: {@link IN_PROGRESS_CLAUSE} after its note when * it declares a well-formed, non-empty `in_progress`. The same reference back * otherwise — an undeclared ledger is served byte for byte — and when the note * already ends with the clause (served once is served). */ export function withInProgressServed(marker) { const rec = marker; if (!given(rec.in_progress)) return marker; const read = readInProgressList(rec.in_progress, IN_PROGRESS_WIRE.key, false); if (read.problem !== undefined || read.items.length === 0) return marker; const note = rec.note; if (typeof note === 'string' && (note === IN_PROGRESS_CLAUSE || note.endsWith(` ${IN_PROGRESS_CLAUSE}`))) { return marker; } const served = typeof note === 'string' && note !== '' ? `${note} ${IN_PROGRESS_CLAUSE}` : IN_PROGRESS_CLAUSE; return { ...rec, note: served }; } /** * The inverse, for a reader of what the model was served (the evidence * projection): the marker with the clause removed from the END of its note — * words the library derived never ground an answer. The same reference back * when the note does not end with it. */ export function withoutInProgressServed(marker) { const rec = marker; const note = rec.note; if (typeof note !== 'string') return marker; if (note === IN_PROGRESS_CLAUSE) { const { note: _dropped, ...rest } = rec; return rest; } if (!note.endsWith(` ${IN_PROGRESS_CLAUSE}`)) return marker; return { ...rec, note: note.slice(0, -(IN_PROGRESS_CLAUSE.length + 1)) }; } /** The section heading the limits block prints in-progress items under. Stable — readers match on it. */ export const IN_PROGRESS_SECTION_LABEL = 'In progress (outcome not known yet)'; /** * One line of that section: the tool, then the item as it declared it — its * count in brackets, its `why` after a dash. Static words around the tool's own. * * @example * ```ts * inProgressLine('replication_sessions', { what: 'sessions still synchronizing', count: 96 }); * // 'replication_sessions: sessions still synchronizing (96)' * ``` */ export function inProgressLine(toolName, item) { const count = item.count !== undefined ? ` (${item.count})` : ''; const why = item.why !== undefined ? ` — ${item.why}` : ''; return `${toolName}: ${item.what}${count}${why}`; } /** One item as detached plain data — the declared fields, nothing else. */ export function copyInProgressItem(item) { return { what: item.what, ...(item.why !== undefined && { why: item.why }), ...(item.short !== undefined && { short: item.short }), ...(item.count !== undefined && { count: item.count }), }; } //# sourceMappingURL=inProgress.js.map