agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
190 lines • 10.3 kB
JavaScript
;
/**
* evidence — what an absence is allowed to GROUND.
*
* Pattern: one projection, one consumer (`evidence/evidenceIndex.ts`). The
* `evidence/frames.ts` argument applied to the other side of the
* conversation: there, text the library wrote into a `role: 'user'`
* turn must not become user-supplied evidence; here, text the MODEL
* supplied that a tool echoed back must not become tool evidence.
* Role: core/ layer, pure.
* Emits: N/A.
*
* ## The bug this file exists to prevent
*
* The evidence gate's corpus is every `role: 'tool'` result: a value the
* model read from a tool is grounded. An absence is a tool result — and its
* whole job is to say what was looked for, which in practice means quoting
* the arguments the model passed ("no FLOGI entries on fc1/3", where `fc1/3`
* came from the model, not from the fabric).
*
* Index that whole frame and an invented identifier becomes grounded by the
* one route that proves nothing about it: handing it to a tool that found
* nothing. The model could then assert "fc1/3 has no FLOGI entries" about a
* port it invented, and the gate would agree, because it saw the string in a
* tool result. **A failed lookup is the cheapest possible laundering
* machine**, and adding a primitive whose purpose is to make failed lookups
* informative would have made it cheaper still.
*
* ## The line, and where it actually falls
*
* The first cut of this file drew the line at the coverage lists. That was too
* narrow, and the field said so: a share-lookup tool returned its absence with
* the answer attached — an extra `known_shares` key holding the 40 real share
* names on the filer — and a `try_instead` telling the model to pick one. The
* model did exactly that, and the gate called the share it picked ungrounded.
* Following the tool's own advice produced a flagged answer, which makes the
* advice worthless and the gate wrong.
*
* So the line is not "coverage vs everything else". It is **tool-authored
* knowledge vs caller echo**:
*
* • Everything a recognized absence carries is the TOOL speaking about the
* world — the coverage lists, an extra key like `known_shares`, the
* author's `try_instead`, the library's own note. Tool knowledge is
* precisely what the corpus is for, so all of it grounds.
* • `looked_for` is the one field that quotes the REQUEST rather than
* stating what the tool knows. It never grounds. See
* {@link CALLER_ECHO_FIELD}.
*
* This widens leniency — the gate accuses less — which is the safe direction
* on an accusation boundary: a missed fabrication costs a value nobody
* checked, a false accusation costs a correct answer and a real turn.
*
* The consequence of the exclusion is deliberate and worth stating: if the
* USER named the thing, it is exempt anyway (their message is the exempt
* corpus), so a real question about a real port is unaffected. Only a value
* that appears *for the first time* in an absence's `looked_for` loses its
* grounding — which is the only case where it was never evidence to begin
* with.
*
* ## What this does NOT close, said plainly
*
* The rule rests on "everything but `looked_for` is the tool's own words about
* the world". An author who interpolates an unvalidated argument into any
* other field — a `checked` line built by string-interpolating the `switch`
* argument the model passed, an extra key echoing the request back — puts a
* model-supplied token into the corpus by hand. That is narrower than the hole
* this file closes (the author chose to echo, rather than the primitive
* echoing by design), and it is not detectable from here: this library cannot
* tell which characters of a sentence a tool composed and which it copied. The
* guidance is on the door itself — interpolate identifiers you RESOLVED, not
* identifiers you were handed. It is also the general limit of the evidence
* corpus, not a new one: any tool that echoes its arguments into its result
* has always grounded them.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.absenceEvidenceProjection = void 0;
const absent_js_1 = require("./absent.js");
const items_js_1 = require("./items.js");
const ledger_js_1 = require("./ledger.js");
const read_js_1 = require("./read.js");
/**
* The ONE caller-echo field of an absence, withheld from the evidence corpus
* (beside it only the record-only item keys are withheld — see
* `toolAuthoredOnly` below).
*
* Tool-authored knowledge grounds; caller echoes never do. Every other field —
* the coverage lists, the note, `try_instead`, and any extra key the tool
* attached — is the tool stating what it knows about the world, and that is
* what an answer built on an absence is entitled to cite. `looked_for` states
* what was ASKED FOR, and in practice quotes the arguments the model passed,
* so indexing it would ground every identifier a model invented as long as it
* handed the invention to one tool that found nothing.
*
* The distinction is about the SOURCE of the words, not their position in the
* shape: a tool author who interpolates an unvalidated argument into any other
* field re-opens the same hole by hand — see "What this does NOT close" above.
*/
const CALLER_ECHO_FIELD = 'looked_for';
/**
* The same rule, applied to one field kind (`Tool.search`): a COMPOSED item
* whose value the call sent (`from: 'call'`) quotes the request — its
* `value`, its `what` (`<argument>: <value>`) and a `span` resolved from it
* are withheld; its facet, argument and the result's `resolved` (the tool's
* own claim) still ground. Declared constants and defaults ground. The SAME
* reference back when no item was sent by the call, so every envelope that
* never carried a composed item is projected exactly as before.
*/
function withoutCallValues(list) {
if (!Array.isArray(list))
return list;
const sent = (i) => typeof i === 'object' && i !== null && i.from === 'call';
if (!list.some(sent))
return list;
return list.map((i) => sent(i)
? Object.fromEntries(Object.entries(i).filter(([key]) => key !== 'what' && key !== 'value' && key !== 'span'))
: i);
}
/** A holder with its `checked` list's call values withheld — the same reference when none. */
function withheldChecked(holder) {
const checked = holder.checked;
const kept = withoutCallValues(checked);
return kept === checked ? holder : { ...holder, checked: kept };
}
/**
* The record-only item keys — `short` and `kind` — are withheld too, on every
* coverage list of an absence AND of the ledger around it.
*
* `short` is the field an author is most tempted to personalise with the
* caller's argument ("no disks on SHPSTRPLPCL003"), which would re-open the
* `looked_for` hole one field over. And nothing is lost: a true short form
* restates `what`, so no value lives ONLY in `short`. `kind` is a word from a
* closed vocabulary, not a statement about the world. Neither is served to the
* model (`read.ts` · `servedToModel`), so this matters for the value that
* reaches history without passing the dispatch strip — a result the tool
* returned as JSON TEXT, which no recognizer reads at the door and this
* projection parses here. The strip itself is `items.ts` ·
* `listsWithoutRecordOnly`, the helper the served value is built with.
*
* One absence, minus the caller's echo and the record-only item keys.
*/
function toolAuthoredOnly(absence) {
// Entries, not fields: a tool may spread `absent(…)` and attach keys the
// `ToolAbsence` type never named, and those are the point of this widening.
const entries = Object.entries(withheldChecked((0, items_js_1.listsWithoutRecordOnly)(absence)));
return Object.fromEntries(entries.filter(([key]) => key !== CALLER_ECHO_FIELD));
}
/**
* Project a parsed tool result down to what it may ground, or `undefined`
* when it carries no absence frame — the ordinary case, which the caller
* indexes exactly as it always did.
*
* Two shapes carry one: a bare absence, and an absence bounded by a coverage
* ledger (`coverage(absent(…), …)`). A ledger's own lists are all tool prose,
* so they are indexed whole; the absence inside it loses the same one field a
* bare absence loses, and nothing else. An absence buried deeper than that —
* inside a tool's own domain object — is NOT found, and is left indexed as
* ordinary data: this projection recognizes the shapes this library mints, and
* does not go hunting through values it did not write.
*
* What comes back is a value to WALK, not a set of strings: the caller's
* indexer does the leaf and key work, so an extra key holding a nested object
* or an array of names is projected without this file knowing its shape.
*/
function absenceEvidenceProjection(parsed) {
// The words the SERVE door derived from a declared period — the verdict
// word and its note clause (`read.ts` · `withPeriodServed`) — are library
// text, not the tool speaking, and never ground (honesty step 7b).
const own = (0, read_js_1.withoutServedPeriod)(parsed);
return frameProjection(own) ?? (own === parsed ? undefined : own);
}
exports.absenceEvidenceProjection = absenceEvidenceProjection;
/** The absence frame's projection of a value, or `undefined` when it carries none. */
function frameProjection(parsed) {
const absence = (0, absent_js_1.readAbsence)(parsed);
if (absence !== undefined)
return toolAuthoredOnly(absence);
const covered = (0, ledger_js_1.readCoverageLedger)(parsed);
if (covered === undefined)
return undefined;
const marker = withheldChecked((0, items_js_1.listsWithoutRecordOnly)(covered.af_coverage));
const inner = (0, absent_js_1.readAbsence)(covered.result);
if (inner === undefined) {
// A bare ledger is indexed whole, as it always was — unless an item
// carries a record-only key, which is withheld here as on an absence.
return marker === covered.af_coverage ? undefined : { ...covered, af_coverage: marker };
}
return [marker, toolAuthoredOnly(inner)];
}
//# sourceMappingURL=evidence.js.map