agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
274 lines • 13.2 kB
JavaScript
;
/**
* coverage/items — normalizing and refusing coverage lists.
*
* Pattern: one validator, two doors (`absent()` and `coverage()`), so the two
* primitives cannot disagree about what a well-formed piece of
* ground looks like.
* Role: core/ layer, pure. Throws at the CALL SITE (the
* `resolveEvidenceGate` precedent): a malformed declaration is a
* mistake in the line the author just wrote, and the stack should
* say so — not fail on the first absence of the first incident.
* Emits: N/A.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.listsWithoutRecordOnly = exports.mergeItems = exports.sameItem = exports.normalizeCoverageList = exports.COVERAGE_DECLARATION_KEYS = exports.listWithoutRecordOnly = exports.readItemExtras = exports.shortProblem = exports.RECORD_ONLY_ITEM_KEYS = exports.MAX_SHORT_CHARS = void 0;
const footprintjs_1 = require("footprintjs");
const plainLine_js_1 = require("../../lib/plainLine.js");
const refusal_js_1 = require("./refusal.js");
const isPlainString = (v) => typeof v === 'string' && v.trim().length > 0;
/** The longest `short` a report prints on one line. */
exports.MAX_SHORT_CHARS = 80;
/** The closed `kind` vocabulary, tied to {@link CoverageKind}: a member the
* union gains, or one listed here it does not have, fails to compile. */
const COVERAGE_KINDS = Object.keys({
existence: true,
scope: true,
});
/** The item keys that are RECORD-ONLY — never served to the model, never
* evidence. `read.ts` · `servedToModel` and `evidence.ts` remove these. */
exports.RECORD_ONLY_ITEM_KEYS = ['short', 'kind'];
/** What is wrong with a `short`, or `undefined` when it is fine. `what` is the
* item's own (trimmed) `what`. */
function shortProblem(short, what) {
if (typeof short !== 'string' || short.trim() === '') {
return '`short` must be a non-empty string — or omit it; the report then prints `what`.';
}
const s = short.trim();
const notPlain = (0, plainLine_js_1.plainLineProblem)('`short`', s);
if (notPlain !== undefined)
return notPlain;
if (s.length > exports.MAX_SHORT_CHARS) {
return `\`short\` is ${s.length} characters; the limit is ${exports.MAX_SHORT_CHARS}.`;
}
if (s.length > what.length) {
return '`short` is longer than `what` — a short form restates `what` in fewer words.';
}
return undefined;
}
exports.shortProblem = shortProblem;
/** What is wrong with a `kind` in `section`, or `undefined` when it is fine. */
function kindProblem(kind, section) {
if (section === 'checked') {
return ('`kind` is refused on `checked` — it says what kind of ground was NOT reached ' +
"('existence' or 'scope'), so it belongs on `notChecked` or `cannotCover`.");
}
if (typeof kind !== 'string' || !COVERAGE_KINDS.includes(kind)) {
return `\`kind\` must be one of ${COVERAGE_KINDS.map((k) => `'${k}'`).join(', ')}.`;
}
return undefined;
}
/** Declared = an own key holding a value. `null` reads as omitted: a JSON
* producer writes a missing optional value as `null` (Python's `None`). */
const has = (item, key) => Object.prototype.hasOwnProperty.call(item, key) && item[key] != null;
/** Tools already warned about a dropped extra — one warning per tool name
* per PROCESS (module state; a second agent in a long-lived host is not
* told again — dev mode only, so acceptable). */
const warnedTools = new Set();
/**
* The item's `short` / `kind`, when each is valid — the NON-throwing reader
* every dispatch door uses (`../../core/agent/stages/toolCalls.ts` · `declareCoverage`'s
* copy), so the event and the tracked row carry the same fields.
*
* An invalid value is DROPPED, never repaired, and named once per tool in dev
* mode: an envelope minted elsewhere (a Python sidecar, a hand-built value)
* is read as found, and the absence it carries is still an absence either
* way. `{}` when the item declares neither — so a spread of it adds no key.
*/
function readItemExtras(item, section, toolName) {
if (typeof item !== 'object' || item === null)
return {};
const rec = item;
const wantsShort = has(rec, 'short');
const wantsKind = has(rec, 'kind');
if (!wantsShort && !wantsKind)
return {};
const what = typeof rec.what === 'string' ? rec.what.trim() : '';
const problems = [];
let short;
let kind;
if (wantsShort) {
const problem = shortProblem(rec.short, what);
if (problem === undefined)
short = rec.short.trim();
else
problems.push(problem);
}
if (wantsKind) {
const problem = kindProblem(rec.kind, section);
if (problem === undefined)
kind = rec.kind;
else
problems.push(problem);
}
const [firstProblem] = problems;
if (firstProblem !== undefined)
warnDroppedExtra(toolName, section, firstProblem);
return { ...(short !== undefined && { short }), ...(kind !== undefined && { kind }) };
}
exports.readItemExtras = readItemExtras;
function warnDroppedExtra(toolName, section, problem) {
const key = toolName ?? '';
if (warnedTools.has(key) || !(0, footprintjs_1.isDevMode)())
return;
warnedTools.add(key);
// eslint-disable-next-line no-console
console.warn(`agentfootprint coverage: tool '${toolName ?? '(unknown)'}' declared a ${section} item ` +
`the record cannot carry, so that field was dropped (the item itself is kept): ` +
`${problem} This warning fires once per tool per process.`);
}
/**
* One coverage list with the record-only keys removed from every item that
* has one — the SAME reference when no item has one (the zero-cost law: a
* run whose tools declare neither serves exactly the value it always did).
* Anything that is not an array, and any item that is not an object, is left
* as found.
*/
function listWithoutRecordOnly(list) {
if (!Array.isArray(list))
return list;
// Declared = an own key holding a value — the record's reading
// (`readItemExtras`), so the two cannot disagree: `"short": None` from a
// Python producer is omitted for both, served as written, and stamps
// nothing. Any NON-null value under either key is removed, valid or not:
// the two names are RESERVED on a recognized envelope's items.
const carries = (i) => typeof i === 'object' && i !== null && exports.RECORD_ONLY_ITEM_KEYS.some((k) => has(i, k));
if (!list.some(carries))
return list;
return list.map((i) => carries(i)
? Object.fromEntries(Object.entries(i).filter(([key, v]) => !(exports.RECORD_ONLY_ITEM_KEYS.includes(key) && v != null)))
: i);
}
exports.listWithoutRecordOnly = listWithoutRecordOnly;
/**
* The keys a {@link CoverageDeclaration} has — the three lists, as the author
* spells them — tied to the type in BOTH directions: a key the interface
* gains, or one listed here that it does not have, fails to compile.
* `coverage()` and a `describedResult()` declaration's
* `coverage` refuse any other
* key (`refusal.ts` · `refuseUnknownKeys`), so `not_checked` is refused,
* naming `notChecked`, instead of vanishing.
*/
exports.COVERAGE_DECLARATION_KEYS = Object.keys({
checked: true,
notChecked: true,
cannotCover: true,
});
/**
* Normalize one author list into {@link CoverageItem}s, refusing anything a
* reader could not act on. Every refusal starts with `refusal.ts` ·
* `REFUSED_PREFIX` and names the list and index, never the helper.
*
* `requireWhy` is true only for `cannotCover`. The asymmetry is deliberate:
* "we checked the fcns database" and "we did not check the archive" are
* complete statements on their own, but "this tool can never see host-side
* multipathing" is a claim about capability — and a permanent blind spot with
* no reason attached cannot be acted on, escalated, or disproved. It is also
* the one an operator is most likely to want to fix, so the reason is the
* useful half.
*/
function normalizeCoverageList(section, list, requireWhy) {
if (list === undefined)
return [];
if (!Array.isArray(list)) {
throw (0, refusal_js_1.refusal)(`\`${section}\` must be an array of strings or { what, why } entries — to say ` +
`nothing about it, omit the field (absent means "not declared", never "nothing there").`);
}
const items = [];
list.forEach((raw, i) => {
const at = `${section}[${i}]`;
if (isPlainString(raw)) {
if (requireWhy) {
throw (0, refusal_js_1.refusal)(`${at} is '${raw.trim()}' with no reason. Every \`cannotCover\` entry needs a ` +
`\`why\` — a blind spot this tool can NEVER see is a claim about what it is, and a ` +
`reader cannot act on, escalate or disprove a claim with no reason. Write ` +
`{ what: '${raw.trim()}', why: '…' }, or move it to \`notChecked\` if a wider ` +
`call could reach it.`);
}
items.push({ what: raw.trim() });
return;
}
if (typeof raw !== 'object' || raw === null || !isPlainString(raw.what)) {
throw (0, refusal_js_1.refusal)(`${at} names no ground. Each entry is either a non-empty string or ` +
`{ what, why } — what a reader has to know is WHICH source, window or population ` +
`this is about.`);
}
const item = raw;
const why = item.why;
if (why !== undefined && !isPlainString(why)) {
throw (0, refusal_js_1.refusal)(`${at} ('${item.what.trim()}') has a \`why\` that says nothing. Give it a ` +
`reason or omit the field.`);
}
if (requireWhy && why === undefined) {
throw (0, refusal_js_1.refusal)(`${at} ('${item.what.trim()}') has no \`why\`. Every \`cannotCover\` entry ` +
`needs one — see the entry above this line in the docs for why a permanent blind ` +
`spot must say what makes it permanent.`);
}
const what = item.what.trim();
// The record-only extras, refused HERE by the same rules the dispatch
// door reads them by (`readItemExtras`). `null`/`undefined` = omitted.
const short = item.short == null ? undefined : item.short;
const kind = item.kind == null ? undefined : item.kind;
const shortFault = short === undefined ? undefined : shortProblem(short, what);
if (shortFault !== undefined)
throw (0, refusal_js_1.refusal)(`${at} ('${what}') — ${shortFault}`);
const kindFault = kind === undefined ? undefined : kindProblem(kind, section);
if (kindFault !== undefined)
throw (0, refusal_js_1.refusal)(`${at} ('${what}') — ${kindFault}`);
items.push({
what,
...(why !== undefined && { why: why.trim() }),
...(short !== undefined && { short: short.trim() }),
...(kind !== undefined && { kind }),
});
});
return items;
}
exports.normalizeCoverageList = normalizeCoverageList;
/** Two entries are the same ground when they say the same two things. Used to
* fold the run's declarations into ONE answer-level block: five tools that
* all name the same missing collector should say it once. */
function sameItem(a, b) {
return a.what === b.what && (a.why ?? '') === (b.why ?? '');
}
exports.sameItem = sameItem;
/** Merge lists in declaration order, dropping repeats. Order is the run's own
* order on purpose — it is the only ordering that means anything here. */
function mergeItems(lists) {
const out = [];
for (const list of lists) {
for (const item of list) {
if (!out.some((seen) => sameItem(seen, item)))
out.push(item);
}
}
return out;
}
exports.mergeItems = mergeItems;
/** The coverage lists as the WIRE spells them — an absence's, a ledger's
* `af_coverage` and a semantic envelope's `coverage` — and a ledger's
* `in_progress` (`inProgress.ts`), whose items carry the same record-only
* `short`. A key a holder does not carry is skipped, so the fourth costs an
* envelope that never declares it nothing. */
const WIRE_LISTS = ['checked', 'not_checked', 'cannot_cover', 'in_progress'];
/**
* `holder` (an envelope or a ledger marker) with each wire list stripped of
* the record-only keys — the SAME reference when none of its lists carried
* one. The one helper `read.ts` · `servedToModel` (what the model is served)
* and `evidence.ts` (what may ground) both use, so the two cannot disagree
* about which keys are record-only.
*/
function listsWithoutRecordOnly(holder) {
let out;
for (const key of WIRE_LISTS) {
const list = holder[key];
const kept = listWithoutRecordOnly(list);
if (kept === list)
continue;
out = out ?? { ...holder };
out[key] = kept;
}
return out ?? holder;
}
exports.listsWithoutRecordOnly = listsWithoutRecordOnly;
//# sourceMappingURL=items.js.map