UNPKG

agentfootprint

Version:

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

163 lines (162 loc) • 9.94 kB
/** * 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 type { AbsenceAmongDeclaration, AbsenceDeclaration, Coverage, DeclaredAbsenceDeclaration, DeclaredToolAbsence, ToolAbsence, TryInsteadTool } from './types.js'; import { ABSENCE_MARKER, 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. */ export declare 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. */ export declare const ABSENCE_NOTE_COMPOSED = "The search ran and matched nothing \u2014 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 declare const ABSENCE_NOTE_HELD_ONLY: string; /** * Say "I looked, and there is nothing" in a way a model cannot read as a * failure — and cannot productively retry. * * Returns the value a tool's `execute` should return. The framework * recognizes it at the dispatch boundary and gives it a delivered status of * `'absent'` (routable by `onToolStatus`), a `tools.absent` event, and an * evidence-corpus rule of its own. * * Refuses (throws, where it is called) a declaration it cannot honor — and * any key the declaration does not have, naming the spelling meant when the * key is a casing slip (`not_checked` → `notChecked`), so a list declared * from plain JavaScript or JSON cannot vanish without a word. Every refusal * starts `refused: `: inside `execute` it becomes the call's error result, * which the model reads. * * Since honesty step 7b an absence also says WHERE it looked and WHEN — * `provenance: { measuredAt, source }`, the shape and rules `describedResult()` * uses — and what time its read covered — `period: { queried, held, readAt? }`, * ISO 8601 instants with a zone (`held` may be `'unknown'`). "Searched the 02:00 * export — nothing" becomes data; the results layer judges the period. * * A name looked up in a known list is a search over that list: declare the * list as `among` (see {@link AbsenceAmongDeclaration}) and the mint composes * the names facet, the gap "names outside <source>" — which only * `among.authoritative` removes; a `partial` read also gets "not every name in * <source> was read", which nothing removes — and the `provenance`. The names are read only * to check `count`; none is served. * * @example a port-lookup tool that found no matching FLOGI * defineTool({ * name: 'flogi_for_port', * description: 'FLOGI entries for one interface', * inputSchema: { type: 'object', properties: { switch: { type: 'string' }, * port: { type: 'string' } }, required: ['switch', 'port'] }, * execute: ({ switch: sw, port }) => { * const rows = fcns.flogi(sw, port); * if (rows.length > 0) return rows; * return absent({ * what: `FLOGI entries on ${port}`, * checked: [ * `${sw}: the live fcns database`, * { what: 'window: the last 24h', why: 'FLOGI history retention on this fabric' }, * ], * notChecked: [{ what: 'the archived FLOGI history', why: 'older than the 24h window' }], * cannotCover: [{ what: 'ports on the peer fabric', * why: 'this collector is scoped to one fabric' }], * tryInstead: 'Ask for a different interface, or query the peer fabric by name.', * }); * }, * }); */ export declare function absent(decl: DeclaredAbsenceDeclaration): DeclaredToolAbsence; export declare function absent(decl: AbsenceDeclaration | AbsenceAmongDeclaration): ToolAbsence; /** The absence's coverage, in the normalized three-list shape everything * downstream (the event, the answer block) reads. */ export declare function coverageOfAbsence(absence: ToolAbsence): Coverage; /** * 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 declare function tryInsteadOfAbsence(absence: ToolAbsence): string | undefined; /** * 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 declare function tryInsteadToolOfAbsence(absence: ToolAbsence): TryInsteadTool | undefined;