agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
353 lines • 19 kB
JavaScript
/**
* absent — an absence that names its own coverage.
*
* Pattern: minted by a helper, RECOGNIZED by the framework (the tool-effects
* envelope precedent). A return shape the framework does not
* understand is a convention, and a convention cannot set a status,
* keep a value out of the evidence corpus, or stop a retry loop.
* Role: core/ layer, pure. The dispatch loop calls `readAbsence` at the
* execute boundary; `absent()` is what a tool author writes.
* Emits: N/A (the caller emits `agentfootprint.tools.absent`).
*
* ## The direction-of-error argument — the whole point of this file
*
* A tool that finds nothing returns *something*: an empty array, a `null`, a
* sentence. From any of those a model cannot tell **"I looked and there is
* nothing"** from **"I could not look"**. That confusion is not symmetric,
* and the asymmetry is what makes it worth a primitive:
*
* • a *nothing-found* misread as an *outage* sends someone to investigate a
* collector that is working perfectly — expensive, and self-correcting.
* • an *outage* misread as *nothing-found* declares a system healthy that
* was never checked — cheap, silent, and wrong in the direction that
* hurts.
*
* So the two must not share a shape. An error is a `role: 'tool'` result with
* `error: true`, a message, and no coverage. An absence is this: a result
* that RAN, carries the ground it covered, and says out loud that asking
* again changes nothing.
*
* ## Why the note is never interpolated
*
* Tool results are the evidence gate's corpus, so an absence that echoed the
* model's own arguments into that corpus would GROUND every identifier a model
* invented, as long as it handed the invention to one tool that found nothing.
* That is laundering an invention through a failed lookup — the same bug
* `evidence/frames.ts` exists to stop on the other side of the conversation.
*
* The gate therefore withholds `looked_for` (see `coverage/evidence.ts`): it
* is the one field whose job is to quote the request. The record-only item
* keys `short` and `kind` are withheld with it — a short form restates
* `what`, so nothing is lost, and it is the field an author is most tempted to
* personalise. Everything else the absence carries — the coverage lists, the
* author's `tryInstead` and `tryInsteadTool`, any extra key the tool attached
* to the envelope — is the TOOL speaking about the world and does ground,
* because an answer that follows the absence's own advice must not be called
* ungrounded for doing so.
* The note is kept free of interpolation for the same reason the exclusion
* exists: static library text has nothing of the caller's to leak.
*
* ## A suggestion to try another tool is typed, never parsed (9.113.0)
*
* `tryInstead` is ONE sentence, for the model; nothing in this library reads
* a tool name out of it. When the sentence points at another tool, the author
* names that tool beside it — `tryInsteadTool: { tool, why? }` — and the
* envelope carries it under its OWN key, `try_instead_tool`. Its own key, not
* `try_instead`: that field has been a string since it shipped and every
* reader typed against `ToolAbsence` reads it as one, so a second shape there
* would be dropped by each of them without a word. `tools.absent` carries
* both, as declared. ONE rule set decides what each is ({@link readSentence},
* {@link readToolSuggestion}): `absent()` refuses by it and the recognizer
* reads by it, so a mint and a read cannot disagree.
*/
import { mintProvenance } from '../described.js';
import { warnIfInvalidToolName } from '../../lib/toolName.js';
import { mintAmong } from './among.js';
import { normalizeCoverageList } from './items.js';
import { mintPeriod } from './period.js';
import { refusal, refuseUnknownKeys } from './refusal.js';
import { mintPaging, mintResolved } from './search/resultFacts.js';
// The recognizer lives in the leaf `recognize.ts` (a post-hoc reader asks it
// without loading this mint); re-exported here so every import keeps working.
import { ABSENCE_MARKER, DECLARED_CHECKED, readAbsence } from './recognize.js';
export { ABSENCE_MARKER, readAbsence };
/**
* The static sentence every absence carries. Says the three things the field
* implementation proved a model needs: that the call SUCCEEDED, that nothing
* was substituted, and that a retry is futile. The third clause is the one
* that ends the loop.
*/
// One literal on purpose: its TYPE is the sentence, so a redacted record keeps
// it as the library's own words (`redaction/knownStrings.ts`).
export const ABSENCE_NOTE = 'The search ran and matched nothing. This is an ANSWER, not an error: nothing failed, nothing was substituted for what was asked, and calling this tool again with the same arguments returns this same result. `checked` is the ground this answer covers; anything under `not_checked` or `cannot_cover` is ground it does NOT cover, and reaching that needs a different question, not a retry.';
/**
* The note a COMPOSED absence is served (`Tool.search`): `checked` there is
* what the library composed from the call's arguments and the tool's declared
* defaults, so the note cannot say "nothing was substituted for what was
* asked" — a declared default, or an argument the call left out, may have
* narrowed the search. It keeps what stays true (the call ran, a retry
* returns the same) and points at the gaps. Static, never interpolated.
*/
// One literal on purpose, as `ABSENCE_NOTE`: its TYPE is the sentence, so a
// redacted record keeps it as the library's own words.
export const ABSENCE_NOTE_COMPOSED = 'The search ran and matched nothing — the call did not fail, and calling this tool again with the same arguments returns this same result. `checked` is the ground this answer covers, as the arguments of the call and the defaults the tool declares set it: where an argument was not sent, a default the tool applies may have narrowed the search. Anything under `not_checked` or `cannot_cover` is ground it does NOT cover, and reaching that needs a different question, not a retry.';
/**
* The note the MODEL is served in place of {@link ABSENCE_NOTE} when the
* absence's own period is `partly-held` or `not-held` (honesty step 7b, bench
* round 1) — `coverage/read.ts` · `strip` swaps it in and appends the period's
* one clause. It keeps what stays true (the call ran, a retry returns the same
* result) and drops the two completeness claims that do not: "nothing was
* substituted for what was asked" and `checked` as the whole ground. Static,
* never interpolated, never minted — the tool's own output keeps
* {@link ABSENCE_NOTE}.
*/
export const ABSENCE_NOTE_HELD_ONLY = 'The search ran and matched nothing — the call did not fail, and calling this tool again ' +
'with the same arguments returns this same result. `checked` is the ground this answer ' +
'covers inside `period.held` only; anything under `not_checked` or `cannot_cover` is ground ' +
'it does NOT cover, and reaching that needs a different question, not a retry.';
/**
* The typed tool's keys, tied to {@link TryInsteadTool} in BOTH directions: a
* key the interface gains, or one listed here that it does not have, fails to
* compile. Any other key on a value is refused, so a misspelt `why` cannot
* vanish without a word.
*/
const TOOL_SUGGESTION_KEYS = Object.keys({
tool: true,
why: true,
});
/**
* The declaration's keys, tied to {@link AbsenceDeclaration} in BOTH
* directions, the {@link TOOL_SUGGESTION_KEYS} way. Any other key is refused,
* naming the spelling meant when it is a slip of one of these: a declaration
* fed from plain JavaScript or JSON with `not_checked` used to mint without
* that list, and the limit it declared vanished without a word.
*/
const ABSENCE_DECLARATION_KEYS = Object.keys({
what: true,
checked: true,
notChecked: true,
cannotCover: true,
tryInstead: true,
tryInsteadTool: true,
provenance: true,
period: true,
resolved: true,
paging: true,
among: true,
});
/**
* `undefined` and `null` both mean "not given" for the typed tool and its
* `why`. A JSON producer writes a missing optional value as `null`, and
* `tryInstead: null` read as no suggestion before 9.113.0 — refusing it would
* turn a nothing-found into a tool error, the direction of error this file
* exists to prevent.
*/
const notGiven = (raw) => raw === undefined || raw === null;
/** An object literal's shape — `{…}` or `Object.create(null)`; never an
* array, a Date, a boxed string or any other class instance. */
const isPlainObject = (raw) => {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
return false;
const prototype = Object.getPrototypeOf(raw);
return prototype === Object.prototype || prototype === null;
};
/**
* The rule for the SENTENCE (`tryInstead`, `try_instead` on the wire):
* trimmed, and one that trims to nothing is no suggestion, as it always was.
*
* ONE non-string is refused: a plain object. That is the typed form
* `{ tool, why? }` written into the sentence's slot, and the refusal points it
* at `tryInsteadTool` rather than drop the author's tool without a word. Any
* OTHER value that is not a string — `null`, `false` from
* `tryInstead: cond && '…'`, a number, a list, a Date — reads as no
* suggestion, exactly as it did before 9.113.0: `absent()` runs inside a
* tool's `execute`, so refusing it would turn a nothing-found the 9.112.2
* loop delivered as an absence into that call's error result.
*/
function readSentence(raw) {
if (typeof raw === 'string') {
const sentence = raw.trim();
return { value: sentence === '' ? undefined : sentence };
}
if (isPlainObject(raw)) {
return {
problem: '`tryInstead` is the sentence the model reads, and only that. To name the other ' +
'tool as data, give { tool, why? } as `tryInsteadTool`, beside the sentence.',
};
}
return { value: undefined };
}
/**
* The rule for the TYPED TOOL (`tryInsteadTool`, `try_instead_tool` on the
* wire): `{ tool, why? }`, where `tool` is a non-empty name and `why`, when
* given, says something. Both are trimmed; `why: null` reads as omitted. The
* name is recorded as declared: never looked up (a provider may serve it on a
* later iteration), and held to no charset here — which names a provider
* accepts is `lib/toolName.ts` · `assertValidToolName`'s question, and
* `absent` asks the dev-mode warning that owns it.
*
* Why ONE tool and not a list: every suggestion on record names at most one
* other tool, and where a sentence joins that tool to "or widen the window",
* the rest of the advice stays in the sentence. A list would be a second
* shape of this field for every reader to narrow — the cost this key exists
* to spare `try_instead`.
*/
function readToolSuggestion(raw) {
if (notGiven(raw))
return { value: undefined };
if (Array.isArray(raw)) {
return {
problem: '`tryInsteadTool` names ONE tool — { tool, why? }, never a list. Say the rest of the ' +
'advice in the `tryInstead` sentence.',
};
}
if (typeof raw !== 'object') {
return {
problem: `\`tryInsteadTool\` must be { tool, why? } — got ${typeof raw}. For a sentence, use ` +
'`tryInstead`; to suggest nothing, omit the field.',
};
}
const value = raw;
const unknownKey = Object.keys(value).find((key) => !TOOL_SUGGESTION_KEYS.includes(key));
if (unknownKey !== undefined) {
return {
problem: `\`tryInsteadTool\` has an unknown key '${unknownKey}' — the form is { tool, why? }, ` +
'and a misspelt `why` would otherwise vanish without a word.',
};
}
const { tool, why } = value;
if (typeof tool !== 'string' || tool.trim() === '') {
return {
problem: '`tryInsteadTool.tool` must name the tool to try — its registered name, e.g. ' +
"{ tool: 'cluster_inventory', why: 'it lists the collected names' }. The name is " +
'recorded as declared and never looked up (a provider may serve it).',
};
}
const name = tool.trim();
if (notGiven(why))
return { value: { tool: name } };
if (typeof why !== 'string' || why.trim() === '') {
return {
problem: `\`tryInsteadTool.why\` (for '${name}') must be a sentence — say why that tool, or ` +
'omit the field.',
};
}
return { value: { tool: name, why: why.trim() } };
}
export function absent(decl) {
const fn = 'absent';
if (typeof decl !== 'object' || decl === null) {
throw refusal(`${fn}() takes a declaration — { what, checked, notChecked?, cannotCover?, tryInstead?, ` +
`tryInsteadTool?, provenance?, period? }, or { what, among, … }.`);
}
refuseUnknownKeys(decl, ABSENCE_DECLARATION_KEYS);
const what = typeof decl.what === 'string' ? decl.what.trim() : '';
if (what === '') {
throw refusal(`\`what\` must say what was looked for (e.g. 'FLOGI entries on fc1/3'). An ` +
`absence that cannot name what it did not find is indistinguishable from a tool that ` +
`returned nothing by accident.`);
}
// `checked: 'declared'` — the tool's `search` declares the ground, and the
// dispatch funnel composes it from the arguments the call ran with. The
// envelope carries the literal until then; `readAbsence` does not read it.
const declared = decl.checked === DECLARED_CHECKED;
// `among` (the c6 fix): the list a missed name was looked up in. `null`
// reads as omitted, as for every optional field. Its facet leads `checked`
// and its gap leads `notChecked`, so the author can add ground, never
// remove the gap.
const raw = decl;
const among = raw.among == null ? undefined : mintAmong(raw.among);
if (among !== undefined && declared) {
throw refusal("`among` and `checked: 'declared'` each compose an absence's ground — `among` for a " +
"name looked up in a known list, `'declared'` for a tool whose `search` declares it. " +
'Give one.');
}
if (among !== undefined && raw.provenance != null) {
throw refusal('`provenance` and `among` both name a source and a time — `among` composes the ' +
"absence's `provenance` from its own `source` and `measuredAt`, so give `provenance` " +
'only without `among`.');
}
const authored = declared
? []
: normalizeCoverageList('checked', decl.checked, false);
const checked = among === undefined ? authored : [among.facet, ...authored];
if (!declared && checked.length === 0) {
throw refusal(`'${what}' — \`checked\` must name at least one source, window or population ` +
`that WAS searched. An absence with no coverage is a null with extra steps: the reader ` +
`still cannot tell "I looked and there is nothing" from "I could not look", which is ` +
`the confusion this function exists to remove. If you genuinely could not look, that ` +
`is an error — throw, or return a failure the model can retry.`);
}
const notCheckedAuthored = normalizeCoverageList('notChecked', decl.notChecked, false);
const notChecked = among === undefined ? notCheckedAuthored : [...among.gaps, ...notCheckedAuthored];
const cannotCover = normalizeCoverageList('cannotCover', decl.cannotCover, true);
const sentence = readSentence(decl.tryInstead);
if (sentence.problem !== undefined)
throw refusal(`'${what}' — ${sentence.problem}`);
const tool = readToolSuggestion(decl.tryInsteadTool);
if (tool.problem !== undefined)
throw refusal(`'${what}' — ${tool.problem}`);
const tryInstead = sentence.value;
const tryInsteadTool = tool.value;
// The name's charset is the tool registry's question: the owner warns in
// dev mode and never throws — the verdict a `defineTool` of it would get.
if (tryInsteadTool !== undefined)
warnIfInvalidToolName(tryInsteadTool.tool);
// Honesty step 7b — the source and time of the search, by the ONE provenance
// rule `describedResult()` mints with (`semantics/described.ts` ·
// `mintProvenance`), and the period its read covered, by the ONE period rule
// (`period.ts` · `mintPeriod`). Each refuses at this line, in camelCase.
// `null` reads as omitted for either — a JSON producer's missing value.
const provenance = among !== undefined
? mintProvenance(among.provenance)
: raw.provenance == null
? undefined
: mintProvenance(raw.provenance);
const period = mintPeriod(decl.period);
// The two facts a search tool's result states (`search/resultFacts.ts`).
const resolved = mintResolved(decl.resolved);
const paging = mintPaging(decl.paging);
return {
af_absent: true,
outcome: 'nothing_found',
looked_for: what,
checked: declared ? DECLARED_CHECKED : checked,
...(notChecked.length > 0 && { not_checked: notChecked }),
...(cannotCover.length > 0 && { cannot_cover: cannotCover }),
...(provenance !== undefined && { provenance }),
...(period !== undefined && { period }),
...(resolved !== undefined && { resolved }),
...(paging !== undefined && { paging }),
retry_returns_the_same: true,
...(tryInstead !== undefined && { try_instead: tryInstead }),
...(tryInsteadTool !== undefined && { try_instead_tool: tryInsteadTool }),
note: ABSENCE_NOTE,
};
}
/** The absence's coverage, in the normalized three-list shape everything
* downstream (the event, the answer block) reads. */
export function coverageOfAbsence(absence) {
return {
checked: absence.checked ?? [],
notChecked: absence.not_checked ?? [],
cannotCover: absence.cannot_cover ?? [],
};
}
/**
* The absence's sentence, read by the rules `absent()` mints by. `undefined`
* when it declared none, AND when it declared one those rules refuse: an
* envelope minted elsewhere (a non-JS tool, a hand-built value) is read,
* never repaired, and the absence itself is still an absence either way.
*/
export function tryInsteadOfAbsence(absence) {
return readSentence(absence.try_instead).value;
}
/**
* The absence's typed tool, read by the same rules — a copy, never the
* object the envelope holds. `undefined` when it declared none, or one those
* rules refuse (see {@link tryInsteadOfAbsence}).
*/
export function tryInsteadToolOfAbsence(absence) {
return readToolSuggestion(absence.try_instead_tool).value;
}
//# sourceMappingURL=absent.js.map