UNPKG

agentfootprint

Version:

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

441 lines • 22.1 kB
"use strict"; /** * assessment/compose — the answer's standing as ONE line for the person, under * its own opt-in arm (`.answerLayer({ standingLine: true })`). * * Pattern: Lens. A pure composer over the standing as data * (`AnswerAssessmentData`, the projection the answer layer files on * `turn_end` and on `agentfootprint.answer.assessed`) and the values * a tool's rule assumed this turn (the same `AssumedLine`s the * "Assumed" block prints — `arguments/serve.ts` · `assumedLinesFor`). * One closed, versioned table of words; nothing here reads the run. * Role: core/ layer leaf of the answer layer (honesty layer 4). Loaded only * by the layer's stage (`assessment/stage.ts`), which is itself * loaded through `import()` under the arm — a plain agent's graph * never carries it. * Emits: N/A. * * ## What the line may say * * The owner's five words, in precedence order — ask > not sure > known > * consistent > not assessed — each rendered from the fold's value and reasons * alone (`assessment/assess.ts` · `assessAnswer`), never from how sure the * model sounded. "Consistent with the run's record" is never "known" and never * "verified". A reason is named by what the record holds, in the past tense of * the run that holds it. The ONE place the line prints a value is an assumed * argument, and then only in the tool's own argument view (a value that view * hides is said to be hidden, never printed) — the "Assumed" block's rule, so * "the standing line owns the assumed sentence" whenever both arms are on: * one composer for one fact. * * ## What it never does * * It never edits the model's reply. The line is appended after it by the * final branch's one composer (`semantics/coverage/answer.ts` · * `composeAnswerWithCoverage`), under the separator the limits block uses, and * only on a PROSE answer: a typed answer (`.outputSchema()`) is JSON, and the * builder refuses the line beside it — as it refuses the line beside * `.answerValidation()`, which judges exact bytes. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.standingLineOf = exports.standingDisplayNames = exports.sourcesByReason = exports.standingNamesFrom = exports.assumedValuesSourceOf = exports.rewrittenBehind = exports.assessmentDataFrom = exports.assessmentDataOf = exports.STANDING_LINE_OPENINGS = exports.standingLineSaid = exports.STANDING_LINE_VERSION = void 0; const displayName_js_1 = require("../../../lib/displayName.js"); /** * Bumped whenever any word of the tables below changes. 2: the named clauses * (`NAMED_REASON_WORDS`) and the assumed clause's named form — said only for a * call whose tool declares a `displayName`; every version-1 line is unchanged. * 3: the `script-unmatched` clause and the `script-match` check's word; every * version-2 line is unchanged. */ exports.STANDING_LINE_VERSION = 3; /** The standings each mode leaves the line off for — `ask` is in none of them. */ const LEFT_UNSAID = Object.freeze({ always: [], 'unless-consistent': ['consistent'], 'not-sure-or-ask': ['consistent', 'not-assessed', 'known'], }); // FOLD · the one choice of whether the line is appended for a standing // consumers read this and never re-derive it: assessment/stage.ts · assessAnswerStage /** * Whether `when` appends the line for an answer of `standing`. Never decides * anything else: the standing, its data and the limits block are the same in * every mode. * * @example * ```ts * standingLineSaid('not-sure-or-ask', 'consistent'); // false — no line; the data still travels * standingLineSaid('not-sure-or-ask', 'ask'); // true — `ask` is said in every mode * ``` */ function standingLineSaid(when, standing) { return !LEFT_UNSAID[when].includes(standing); } exports.standingLineSaid = standingLineSaid; /** * The line's first words, one per standing — the owner's words. They are * plain words a model can write too, so a reader of the answer text never * matches on them alone: the answer account finds the line by rebuilding the * WHOLE line from the run's record (`answer-account/account.ts` · * `readAnswer`, through `standingLineOf`). */ exports.STANDING_LINE_OPENINGS = Object.freeze({ ask: 'Ask — ', 'not-sure': 'Not sure — ', known: 'Known — ', consistent: "Consistent with the run's record — ", 'not-assessed': 'Not assessed — ', }); /** * The projection of one fold — names, enums and counts only: the value, its * rendering, the reason KINDS and the checks that ran. No witness pointer, no * digest, no value. */ function assessmentDataOf(a) { return { assessment: a.assessment, standing: a.standing, reasons: a.reasons.map((r) => r.reason), checked: a.checked.map((c) => ({ layer: c.layer, check: c.check, ran: c.ran, of: c.of })), }; } exports.assessmentDataOf = assessmentDataOf; const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); const owns = (table, key) => typeof key === 'string' && Object.prototype.hasOwnProperty.call(table, key); const ASSESSMENTS = [ 'known', 'unrefuted', 'unknown', 'not-applicable', ]; // FOLD · the one reader of the standing as data off a RECORD (`turn_end.answerAssessment`) // consumers read this and never re-derive it: answer-account/account.ts · appendedStandingLine /** * The standing as data read back off a record — `turn_end.answerAssessment` * as a recording holds it — or `undefined` unless every field is in this * version's vocabulary (a value, a standing with an opening, known reason * kinds, checks with counts). A fresh copy, field by field. */ function assessmentDataFrom(value) { if (!isRecord(value)) return undefined; const { assessment, standing, reasons, checked } = value; if (!ASSESSMENTS.includes(assessment)) return undefined; if (!owns(exports.STANDING_LINE_OPENINGS, standing)) return undefined; if (!Array.isArray(reasons)) return undefined; if (!reasons.every((r) => r === 'argument-assumed' || owns(REASON_WORDS, r))) return undefined; if (!Array.isArray(checked)) return undefined; const ran = []; for (const c of checked) { if (!isRecord(c) || !owns(CHECK_WORDS, c.check)) return undefined; const { layer, ran: count, of } = c; if (typeof layer !== 'number' || typeof count !== 'number' || typeof of !== 'number') { return undefined; } ran.push({ layer: layer, check: c.check, ran: count, of, }); } return { assessment: assessment, standing: standing, reasons: [...reasons], checked: ran, }; } exports.assessmentDataFrom = assessmentDataFrom; /** * Whether a before-tool rewrite with no declared origin fired * `argument-assumed` — the fold's witness that points at `middlewareDecisions` * rather than at a row. The line then names it once, without a value. */ function rewrittenBehind(assessed) { return assessed.reasons.some((r) => r.reason === 'argument-assumed' && r.witness.some((w) => w.kind === 'state' && w.key === 'middlewareDecisions')); } exports.rewrittenBehind = rewrittenBehind; // FOLD · the one choice of what the line's assumed values are read from // consumers read this and never re-derive it: assessment/stage.ts · assessAnswerStage (to compose // the line), answer-account/account.ts · appendedStandingLine (to find it again in the answer) /** * The source of the line's assumed values in `record` (committed state) — * `undefined` when the line names none: no `argument-assumed` reason, no * turn, or no `argument` row. Rows that are not objects are passed over, so a * damaged record is read, never thrown on. */ function assumedValuesSourceOf(reasons, record) { if (!reasons.includes('argument-assumed')) return undefined; const turn = record.turnNumber; if (typeof turn !== 'number') return undefined; const rows = Array.isArray(record.findingsLedger) ? record.findingsLedger : []; const ledger = rows.filter((row) => isRecord(row) && typeof row.kind === 'string'); if (!ledger.some((row) => row.kind === 'argument')) return undefined; const decisions = record.middlewareDecisions; return { ledger, turn, readDecisions: Array.isArray(decisions) ? () => decisions : undefined, }; } exports.assumedValuesSourceOf = assumedValuesSourceOf; /** * How many reason clauses the line prints before it folds the rest into a * count. The assumed clauses are never counted and never cut: beside * `.limitsTravelWithTheAnswer()` the line REPLACES the "Assumed" block, which * prints every assumed value, so a cut there would drop a value the person * would otherwise have read. A fold's distinct reason kinds stay under the cap * on their own; it bounds a hand-built projection. */ const MAX_CLAUSES = 12; const CHECK_WORDS = { 'argument-rules': 'argument rules', 'argument-sources': 'argument sources', 'tool-coverage': 'tool coverage', 'result-shape': 'what came back', 'result-period': 'the time each result covered', 'names-and-numbers': 'names and numbers', 'script-match': 'scripts shown', 'answer-checks': "the app's answer checks", }; const REASON_WORDS = { asked: 'a question the run asked is still waiting for its answer', 'argument-asked': 'the run asked you for values its calls need, and the answer has not come yet', 'argument-unverified': 'a value a call ran with came from the model, with no source on the record', 'argument-read': 'a value a call ran with was read into your words: the quoted words are on the record, the value is not in them', 'value-contingent': 'a value a call ran with was taken from a result the model itself had set aside', 'coverage-gap': 'a tool said there is ground it did not check or cannot cover', 'declared-absent': 'a tool said nothing matched', 'empty-undeclared': 'a lookup came back empty without saying what it searched', 'period-not-held': "a tool's data does not reach the time the question asked about", 'period-partly-held': "a tool's data covers only part of the time the question asked about", 'period-unknown': 'a tool could not say what time its data covers', 'period-undeclared': 'a tool did not say what time its result covers', 'period-differs-from-asked': 'a tool read a different stretch of time than the one asked about', 'period-beyond-retention': 'the time asked about is older than a tool declares its source keeps', 'sources-conflict': 'two results the answer stood on disagree', 'value-unsupported': 'names or numbers in the answer appear in no tool result', 'value-survived-revision': 'names or numbers in the answer appear in no tool result, even after one revision', 'derived-from-reading': "a time in the answer is the library's own spelling of your words, not something you said or a tool returned", 'script-unmatched': 'a script shown in the answer matches no submission of this turn on the record', 'stopped-early': 'the run stopped before the model finished', 'steps-unfinished': "the answer came before the skill's declared steps finished", 'negative-prose-unverified': 'model prose was not verified for negative claims', 'claim-exceeds-search': 'a negative claim exceeds the recorded search', 'declaration-overclaimed': 'a tool declaration overstates what was checked', 'answer-check-failed': "the app's answer checks failed this answer", 'check-unreachable': "the app's answer checks could not reach a verdict on this answer", }; /** * The same clauses with their calls NAMED — one per reason that rests on * calls, `{who}` being the calls in the app's own words (`lib/displayName.ts` * · `namedCalls`: the declared names, the rest counted — "loading the attached * data and 1 other call"). Used only when at least one of the reason's calls * declares a `displayName`; otherwise the clause above is said, word for word. * Each says what the record holds about those calls — never which part of the * answer leans on them, and never that the rest of it is unaffected. Written * with no pronoun that must agree with `{who}`, which may name one call or * several. */ const NAMED_REASON_WORDS = { 'argument-unverified': (who) => `${who} ran with a value from the model, with no source on the record`, 'argument-read': (who) => `${who} ran with a value read into your words: the quoted words are on the record, the value is not in them`, 'value-contingent': (who) => `a value came from ${who}, whose results the model itself had set aside`, 'coverage-gap': (who) => `${who} declared ground that was not checked or cannot be covered`, 'declared-absent': (who) => `${who} reported no matching results`, 'empty-undeclared': (who) => `${who} came back empty without saying what was searched`, 'period-not-held': (who) => `${who} read data that does not reach the time the question asked about`, 'period-partly-held': (who) => `${who} read data that covers only part of the time the question asked about`, 'period-unknown': (who) => `${who} could not say what time the data covers`, 'period-undeclared': (who) => `${who} did not say what time was covered`, 'period-differs-from-asked': (who) => `${who} read a different stretch of time than the one asked about`, 'period-beyond-retention': (who) => `${who} asked about time older than the source is declared to keep`, 'sources-conflict': (who) => `results from ${who} disagree`, 'declaration-overclaimed': (who) => `${who} declared more than was checked`, }; // FOLD · the one reader of the line's display names off a RECORD (`turn_end.standingLineNames`) // consumers read this and never re-derive it: answer-account/account.ts · appendedStandingLine /** * The words a record kept for the line — registered tool name → display name, * as `turn_end.standingLineNames` holds them — or `undefined` unless every * entry is one a person may read, exactly as the stage filed it (the rule * `defineTool` holds a `displayName` to, trimmed). A fresh map, entry by * entry. */ function standingNamesFrom(value) { if (!isRecord(value)) return undefined; const names = new Map(); for (const [tool, words] of Object.entries(value)) { const read = (0, displayName_js_1.readDisplayName)(words); if (read === undefined || read !== words) return undefined; names.set(tool, read); } return names.size > 0 ? names : undefined; } exports.standingNamesFrom = standingNamesFrom; /** The fold's sources keyed by reason — what {@link StandingNames} carries. */ function sourcesByReason(a) { return new Map(a.reasons.map((r) => [r.reason, r.source])); } exports.sourcesByReason = sourcesByReason; // FOLD · the one choice of which display names the standing line may use // consumers read this and never re-derive it: assessment/stage.ts · assessAnswerStage (to name // the calls and file the names on turn_end), answer-account/account.ts · appendedStandingLine // (the line rebuilt from the names the record kept) /** * The display names the line may use for `a`: for every tool a `not-sure` * reason's calls name, and every tool whose rule assumed a value, the app's * words when its tool declares them — read through `displayNameOf`, the * agent's own tools. Empty unless the standing is `not-sure` (only that line * names reasons) and a tool declared a name: the line then says what it always * said. */ function standingDisplayNames(a, assumed, displayNameOf) { const names = new Map(); if (a.standing !== 'not-sure') return names; const tools = [ ...a.reasons.flatMap((r) => (r.source.kind === 'calls' ? r.source.calls : [])), ...assumed, ].map((c) => c.toolName); for (const tool of tools) { if (names.has(tool)) continue; const words = displayNameOf(tool); if (words !== undefined) names.set(tool, words); } return names; } exports.standingDisplayNames = standingDisplayNames; /** The clause for one assumed value — its value only in the tool's own view; the tool in the app's words when it declares them. */ function assumedClause(line, displayName) { const by = displayName === undefined ? `${line.toolName}'s rule` : `the rule of ${displayName}`; return line.hidden ? `${line.argument} was assumed by ${by} (its value is hidden by the tool's view), not given by you` : `${line.argument} = ${JSON.stringify(line.value)} was assumed by ${by}, not given by you`; } /** One clause per distinct (tool, argument, shown value), in the order the rows were filed. */ function assumedClauses(lines, names) { const seen = new Set(); const out = []; for (const line of lines) { const key = JSON.stringify([line.toolName, line.argument, line.hidden ? '' : line.value]); if (seen.has(key)) continue; seen.add(key); out.push(assumedClause(line, names?.displayNames.get(line.toolName))); } return out; } /** The clause for one reason: its calls named in the app's words when one declares them, else today's words. */ function reasonClause(reason, names) { const named = NAMED_REASON_WORDS[reason]; const source = names?.sources.get(reason); if (named === undefined || names === undefined || source?.kind !== 'calls') { return REASON_WORDS[reason]; } const who = (0, displayName_js_1.namedCalls)(source.calls, source.unnamed ?? 0, (tool) => names.displayNames.get(tool)); return who === undefined ? REASON_WORDS[reason] : named(who); } /** The "not sure" clauses, in the fold's reason order — every assumed one, the rest capped. */ function reasonClauses(data, assumed, rewritten, names) { const clauses = []; let counted = 0; let folded = 0; for (const reason of data.reasons) { if (reason === 'argument-assumed') { // Never cut (see MAX_CLAUSES): the line owns the assumed sentence. clauses.push(...assumedClauses(assumed, names)); // A before-tool rewrite with no declared origin fires the same reason and // names no row the block would print: said once, without a value. if (rewritten || assumed.length === 0) { clauses.push('a before-tool rule set a value a call ran with and did not say where it came from'); } continue; } if (counted < MAX_CLAUSES) { clauses.push(reasonClause(reason, names)); counted += 1; } else { folded += 1; } } if (folded > 0) clauses.push(`… and ${folded} more (in the run record)`); return clauses; } // LENS · appended-answer · persistent-history // reads: the standing as data (`assessmentDataOf` over the one fold) and this turn's assumed values // (`arguments/serve.ts` · `assumedLinesFor`, the "Assumed" block's own rows) // law: may omit, never deny; says what the record holds, never that the answer is true. /** * The standing line for one answer — `''` never: every standing has words. * * `assumed` is this turn's assumed values (empty unless the inputs layer filed * `default` rows); `rewritten` says a before-tool middleware rewrote a ruled * argument without declaring where the value came from (the fold's other * `argument-assumed` witness). `names` lets a `not-sure` line name the calls a * reason rests on in the app's words — only calls whose tool declares a * `displayName`, the rest counted; never the registered name. Without it, or * when no such call is behind a reason, the clause is today's, word for word. * Naming never changes the standing: the opening is read from `data` alone. * * @example * ```ts * standingLineOf( * { assessment: 'unknown', standing: 'not-sure', reasons: ['argument-assumed'], checked: [] }, * [{ toolName: 'search_logs', argument: 'window', value: '2h', hidden: false }], * false, * ); * // 'Not sure — window = "2h" was assumed by search_logs\'s rule, not given by you.' * * standingLineOf( * { assessment: 'unknown', standing: 'not-sure', reasons: ['declared-absent'], checked: [] }, * [], * false, * { * sources: new Map([['declared-absent', { kind: 'calls', calls: [{ toolCallId: 'c1', toolName: 'load_attachment' }] }]]), * displayNames: new Map([['load_attachment', 'loading the attached data']]), * }, * ); * // 'Not sure — loading the attached data reported no matching results.' * ``` */ function standingLineOf(data, assumed, rewritten, names) { const opening = exports.STANDING_LINE_OPENINGS[data.standing]; switch (data.standing) { case 'ask': return `${opening}the run stopped to ask a question before it could answer.`; case 'not-sure': return `${opening}${reasonClauses(data, assumed, rewritten, names).join('; ')}.`; case 'known': return `${opening}the app's answer checks passed this exact answer.`; case 'consistent': { const ran = data.checked.filter((c) => c.ran > 0).map((c) => CHECK_WORDS[c.check]); const names = [...new Set(ran)].join(' · '); return ran.length === 1 ? `${opening}1 check ran and did not fire: ${names}. This is not a verification.` : `${opening}${ran.length} checks ran and none fired: ${names}. This is not a verification.`; } case 'not-assessed': return `${opening}no check applied to this answer.`; } } exports.standingLineOf = standingLineOf; //# sourceMappingURL=compose.js.map