UNPKG

agentfootprint

Version:

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

440 lines • 19.5 kB
import { readAbsence } from '../semantics/coverage/absent.js'; import { checkTimeAnswer, isTimeFormat, refusalReason, } from '../time/ask.js'; import { defaultTimeAskMessages } from '../locales/timeAsk.js'; const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k); function object(value) { return (typeof value === 'object' && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)); } function nonempty(value) { return typeof value === 'string' && value.trim().length > 0 && value.length <= 4096; } function jsonValue(value, seen = new Set(), depth = 0) { if (depth > 32) return false; if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; if (typeof value === 'number') return Number.isFinite(value); if (!Array.isArray(value) && !object(value)) return false; if (seen.has(value)) return false; seen.add(value); const valid = Object.values(value).every((child) => jsonValue(child, seen, depth + 1)); seen.delete(value); return valid; } function fail(reason) { throw new InputRequestError(reason); } /** A malformed or stale data reply; no value was consumed and the ask remains live. */ export class InputRequestError extends TypeError { code = 'ERR_INPUT_REQUEST_INVALID'; constructor(reason) { super(`[input request] ${reason}. No input was accepted.`); this.name = 'InputRequestError'; } } /** * A value a typed ask can carry in a field — as a supplied value, a choice or * an answer: a non-blank string of at most 4096 characters, a finite number, * or a boolean. The one rule, shared by this module's validation and by the * declarations that build a typed ask (`tools/declare.ts` judges an * `ask` rule's choices with it at definition). */ export function isInputFieldValue(value) { if (typeof value === 'string') return value.trim().length > 0 && value.length <= 4096; if (typeof value === 'number') return Number.isFinite(value); return typeof value === 'boolean'; } /** Whether a field's `enum` is the only answers — always, except a `format` field that is not `strict`. */ const choicesOnly = (field) => field.format === undefined || field.strict === true; function validateValues(fields, raw) { if (!object(raw)) fail('values must be an object'); const values = {}; for (const [id, value] of Object.entries(raw)) { const field = fields.find((f) => f.id === id); if (!field) fail('values contain an undeclared field'); if (typeof value !== field.type || !isInputFieldValue(value) || (field.enum !== undefined && choicesOnly(field) && !field.enum.includes(value))) { fail('a value does not satisfy its declared field type or choices'); } Object.defineProperty(values, id, { value, enumerable: true, writable: true, configurable: true, }); } return values; } const FIELD_KEYS = [ 'id', 'type', 'required', 'enum', 'description', 'format', 'labels', 'strict', ]; /** * A time field's own rules (time design § 6.1), refused at definition and * never repaired: `format` only on a string field; each choice a well-formed * value of its format; `labels` one non-blank label per choice; `strict` only * where it would change something (a `format` field with choices). */ function validateTimeField(field) { if (field.format !== undefined) { if (!isTimeFormat(field.format)) fail("format must be 'instant', 'time-range' or 'zone'"); if (field.type !== 'string') fail("a format field must be type: 'string'"); for (const value of field.enum ?? []) { if (checkTimeAnswer(field.format, value) !== undefined) fail(`a choice is not a well-formed ${field.format}`); } } if (field.labels !== undefined) { if (field.enum === undefined || !Array.isArray(field.labels) || field.labels.length !== field.enum.length || !field.labels.every(nonempty)) fail('labels must give one non-blank label per enum choice, in its order'); } if (field.strict !== undefined) { if (typeof field.strict !== 'boolean') fail('strict must be a boolean'); if (field.format === undefined || field.enum === undefined) fail('strict needs a format field with choices — elsewhere the choices are the only answers already'); } } /** One validation owner, shared by the declaration and durable-pause readers. */ export function validateInputDeclaration(raw) { if (!object(raw) || !nonempty(raw.id) || !nonempty(raw.question) || !Array.isArray(raw.fields) || raw.fields.length < 1 || raw.fields.length > 32) { fail('declare an id, question and between one and 32 fields'); } if (Object.keys(raw).some((k) => !['id', 'question', 'fields', 'supplied', 'context', 'absence', 'refused'].includes(k))) fail('unknown declaration field'); const ids = new Set(); const fields = raw.fields.map((field) => { if (!object(field) || !nonempty(field.id) || ids.has(field.id) || typeof field.type !== 'string' || !['string', 'number', 'boolean'].includes(String(field.type)) || (field.required !== undefined && typeof field.required !== 'boolean') || (field.description !== undefined && !nonempty(field.description)) || Object.keys(field).some((k) => !FIELD_KEYS.includes(k))) fail('invalid or duplicate input field'); ids.add(field.id); const copy = { ...field }; if (field.enum !== undefined) { if (!Array.isArray(field.enum) || field.enum.length === 0 || field.enum.length > 100) fail('enum must contain between one and 100 choices'); for (const value of field.enum) validateValues([{ ...copy, enum: undefined }], { [field.id]: value }); } validateTimeField(copy); return { id: copy.id, type: copy.type, ...(copy.required !== undefined && { required: copy.required }), ...(copy.description !== undefined && { description: copy.description }), ...(copy.enum !== undefined && { enum: [...copy.enum] }), ...(copy.format !== undefined && { format: copy.format }), ...(copy.labels !== undefined && { labels: [...copy.labels] }), ...(copy.strict !== undefined && { strict: copy.strict }), }; }); const supplied = validateValues(fields, raw.supplied ?? {}); for (const [id, value] of Object.entries(supplied)) { const field = fields.find((f) => f.id === id); if (field.format !== undefined && checkTimeAnswer(field.format, value) !== undefined) fail(`a supplied value is not a well-formed ${field.format}`); } let context; if (raw.context !== undefined) { if (!object(raw.context) || !jsonValue(raw.context)) fail('context must be a JSON object'); const json = JSON.stringify(raw.context, (_key, value) => { if (value === undefined || typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint' || (typeof value === 'number' && !Number.isFinite(value))) fail('context must contain JSON values'); return value; }); if (json.length > 16384) fail('context exceeds 16384 characters'); context = JSON.parse(json); } // `null` is the field omitted — the coverage module's rule for a missing // optional value (`../semantics/coverage/absent.ts` · `notGiven`): refusing it // would turn a question into a tool error, and it carries no miss to file. const absence = raw.absence == null ? undefined : recognizedAbsence(raw.absence); const refused = raw.refused == null ? undefined : validateRefusal(fields, raw.refused); return { id: raw.id, question: raw.question, fields, ...(raw.supplied !== undefined && { supplied }), ...(context !== undefined && { context }), ...(absence !== undefined && { absence }), ...(refused !== undefined && { refused }), }; } /** The app's refusal: its reason in its own words, and the refused answer judged like any answer. */ function validateRefusal(fields, raw) { if (!object(raw) || !nonempty(raw.reason) || Object.keys(raw).some((k) => !['answer', 'reason'].includes(k))) fail('refused must be { reason, answer? } with a non-blank reason in the app’s own words'); return { ...(raw.answer != null && { answer: validateValues(fields, raw.answer) }), reason: raw.reason, }; } /** A stored re-ask mark: a positive whole count, and a previous answer judged like any answer. */ function validateRepeat(fields, raw) { if (!object(raw) || typeof raw.count !== 'number' || !Number.isInteger(raw.count) || raw.count < 1 || Object.keys(raw).some((k) => !['count', 'previousAnswer'].includes(k))) fail('malformed repeat mark'); return { count: raw.count, ...(raw.previousAnswer !== undefined && { previousAnswer: validateValues(fields, raw.previousAnswer), }), }; } /** * The re-ask mark for a declaration, or `undefined` when this is not a re-ask. * The previous answer is READ FROM THE RECORD — the * `input_received` result that landed for `answered.requestId`, after the * tool-result rules ran on it — so it is redacted exactly as the answer is. * Only the fields the PERSON supplied (origin `'response'`) are kept; when * the record holds no such result, or its values no longer satisfy the * fields, the mark carries the count alone. */ export function repeatOf(declaration, answered, history) { if (answered === undefined || answered.id !== declaration.id) return undefined; const previousAnswer = landedAnswer(declaration.fields, answered.requestId, history); return { count: answered.count, ...(previousAnswer !== undefined && { previousAnswer }) }; } function landedAnswer(fields, requestId, history) { for (let i = history.length - 1; i >= 0; i--) { const message = history[i]; if (message.role !== 'tool' || typeof message.content !== 'string') continue; let landed; try { landed = JSON.parse(message.content); } catch { continue; } if (!object(landed) || landed.status !== 'input_received' || landed.requestId !== requestId) continue; if (!object(landed.values)) return undefined; const origins = object(landed.origins) ? landed.origins : undefined; const given = Object.fromEntries(Object.entries(landed.values).filter(([k]) => !origins || origins[k] === 'response')); if (Object.keys(given).length === 0) return undefined; try { return validateValues(fields, given); } catch { return undefined; } } return undefined; } /** * The declaration's `absence`, as the ONE recognizer reads it * (`../semantics/coverage/absent.ts` · `readAbsence`) — refused, never repaired, * when it reads nothing, so a raise cannot carry a miss the dispatch door * would then drop without a word. Held as handed over: it is read once, at * the raise, and the rows filed from it are copies * (`core/agent/stages/toolCalls.ts` · `declareCoverage` copies item by item). * An envelope it reads whose lists that copy cannot read (a hand-built * `checked: [null]`) is not refused here: the door meets it at the raise * and errors the call, as it would the same value returned * (`core/agent/stages/toolCalls.ts` · `declareRaisedAbsence`). */ function recognizedAbsence(raw) { const absence = readAbsence(raw); if (absence === undefined) { fail('absence must be the envelope absent() returns — absent({ what, checked }) — ' + 'so the one recognizer can read what the tool looked at'); } return absence; } /** Runtime-only stamping: a model cannot choose its saved skill or original request. */ export function stampInputRequest(declaration, requestId, origin, repeat) { // The `absence` is filed at the raise and read by nothing after it — it // never rides the awaiting-input shape the person, the model and the // durable pause read (`readAwaitingInput` re-validates five keys, not six). const { absence: _filedAtTheRaise, ...clean } = validateInputDeclaration(declaration); void _filedAtTheRaise; const supplied = { ...clean.supplied }; return { ...clean, status: 'awaiting_input', requestId, supplied, origins: Object.fromEntries(Object.keys(supplied).map((k) => [k, 'declaration'])), missing: clean.fields .filter((f) => f.required !== false && !own(supplied, f.id)) .map((f) => f.id), ...(repeat !== undefined && { repeat: validateRepeat(clean.fields, repeat) }), origin: JSON.parse(JSON.stringify(origin)), }; } /** Read only an explicitly declared, well-shaped input pause; never assistant prose. */ export function readAwaitingInput(pauseData) { if (!object(pauseData) || !own(pauseData, 'awaitingInput')) return undefined; const value = pauseData.awaitingInput; if (!object(value) || value.status !== 'awaiting_input' || !nonempty(value.requestId) || !object(value.origin) || typeof value.origin.originalRequest !== 'string' || !nonempty(value.origin.toolCallId)) fail('malformed stored input request'); const clean = validateInputDeclaration({ id: value.id, question: value.question, fields: value.fields, supplied: value.supplied, ...(value.context !== undefined && { context: value.context }), ...(value.refused !== undefined && { refused: value.refused }), }); if (value.repeat !== undefined) validateRepeat(clean.fields, value.repeat); const origins = value.origins; if (!object(origins) || Object.keys(clean.supplied ?? {}).some((k) => !['declaration', 'response'].includes(String(origins[k])))) fail('malformed input origins'); return JSON.parse(JSON.stringify({ ...value, ...clean })); } /** * The time fields' answers the check refuses, field id → why — in field * order. Only a field with a `format` is judged: only a check the app armed * can refuse (time design § 6.2, TQ7). */ function refusedTimeAnswers(fields, values, zone) { const refused = []; for (const field of fields) { if (field.format === undefined || !own(values, field.id)) continue; const refusal = checkTimeAnswer(field.format, values[field.id], zone); if (refusal !== undefined) refused.push([field.id, refusal]); } return refused; } /** * The same ask, asked again: the refused time answers are not taken (the * fields are `missing` again, whether or not they are required — the person * tried to answer them), the rest of the reply is kept, and the re-ask * carries the 9.127.0 shapes — `refused: { answer, reason }` with the * catalog's reason, and the runtime's `repeat: { count }` (the refused * answer never reached the record, so there is no `previousAnswer`). */ function reaskRefused(waiting, values, refused, time) { const ids = new Set(refused.map(([id]) => id)); const accepted = Object.fromEntries(Object.entries(values).filter(([k]) => !ids.has(k))); const drop = (record) => Object.fromEntries(Object.entries(record).filter(([k]) => !ids.has(k))); const supplied = { ...drop(waiting.supplied), ...accepted }; const messages = { ...defaultTimeAskMessages, ...time?.messages }; return { ...waiting, supplied, origins: { ...drop(waiting.origins), ...Object.fromEntries(Object.keys(accepted).map((k) => [k, 'response'])), }, missing: waiting.fields .filter((f) => ids.has(f.id) || (f.required !== false && !own(supplied, f.id))) .map((f) => f.id), refused: { answer: Object.fromEntries(refused.map(([id]) => [id, values[id]])), reason: refusalReason(refused.map(([, refusal]) => refusal), messages), }, repeat: { count: (waiting.repeat?.count ?? 0) + 1 }, }; } /** * The refusal an ACCEPTED reply leaves standing. A refusal names the answer * it turned down (`InputRefusal.answer`); once the person has answered every * one of those fields again — and this reply was accepted — the refusal is * about an answer that no longer stands, so it is dropped: a UI must not say * "your answer was not accepted" beside a field it just took. A refusal that * names no answer, or one this reply did not answer again in full, stays. */ function refusalStillStanding(refusal, values) { if (refusal?.answer === undefined) return refusal; const named = Object.keys(refusal.answer); return named.length > 0 && named.every((id) => own(values, id)) ? undefined : refusal; } /** * Accept typed fields, or explicit cancellation, without coercing free text. * A time field's answer (`InputField.format`) is checked here too; one the * check refuses comes back as the same ask with `refused` and `repeat`, its * field `missing` again — the door that keeps a partial answer asks again * without running anything. */ export function applyInputResponse(waiting, raw, time) { if (!object(raw) || raw.requestId !== waiting.requestId) fail('response names a different input request'); if (raw.cancel === true) { if (Object.keys(raw).some((k) => !['requestId', 'cancel'].includes(k))) fail('cancellation cannot carry values'); return { requestId: waiting.requestId, cancel: true }; } if (Object.keys(raw).some((k) => !['requestId', 'values'].includes(k))) fail('unknown response field'); const values = validateValues(waiting.fields, raw.values); if (Object.keys(values).length === 0) fail('supply at least one field or cancel explicitly'); const refused = refusedTimeAnswers(waiting.fields, values, time?.zone); if (refused.length > 0) return reaskRefused(waiting, values, refused, time); const supplied = { ...waiting.supplied, ...values }; const { refused: previous, ...kept } = waiting; const standing = refusalStillStanding(previous, values); return { ...kept, ...(standing !== undefined && { refused: standing }), supplied, origins: { ...waiting.origins, ...Object.fromEntries(Object.keys(values).map((k) => [k, 'response'])), }, missing: waiting.fields .filter((f) => f.required !== false && !own(supplied, f.id)) .map((f) => f.id), }; } //# sourceMappingURL=inputRequest.js.map