agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
450 lines • 20.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.applyInputResponse = exports.readAwaitingInput = exports.stampInputRequest = exports.repeatOf = exports.validateInputDeclaration = exports.isInputFieldValue = exports.InputRequestError = void 0;
const absent_js_1 = require("../semantics/coverage/absent.js");
const ask_js_1 = require("../time/ask.js");
const timeAsk_js_1 = require("../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. */
class InputRequestError extends TypeError {
code = 'ERR_INPUT_REQUEST_INVALID';
constructor(reason) {
super(`[input request] ${reason}. No input was accepted.`);
this.name = 'InputRequestError';
}
}
exports.InputRequestError = 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).
*/
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';
}
exports.isInputFieldValue = isInputFieldValue;
/** 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 (!(0, ask_js_1.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 ((0, ask_js_1.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. */
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 && (0, ask_js_1.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 }),
};
}
exports.validateInputDeclaration = validateInputDeclaration;
/** 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.
*/
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 }) };
}
exports.repeatOf = repeatOf;
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 = (0, absent_js_1.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. */
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)),
};
}
exports.stampInputRequest = stampInputRequest;
/** Read only an explicitly declared, well-shaped input pause; never assistant prose. */
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 }));
}
exports.readAwaitingInput = readAwaitingInput;
/**
* 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 = (0, ask_js_1.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 = { ...timeAsk_js_1.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: (0, ask_js_1.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.
*/
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),
};
}
exports.applyInputResponse = applyInputResponse;
//# sourceMappingURL=inputRequest.js.map