@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
227 lines • 8.32 kB
JavaScript
export const ELICITATION_INPUT_TYPES = [
'text',
'textarea',
'number',
'boolean',
'date',
'email',
'url',
'select',
];
/** The question's options, or none — `options` may be omitted on a typed question. */
export function questionOptions(question) {
return question.options ?? [];
}
/** A question answered by a typed value rather than by picking one of its options. */
export function isTypedQuestion(question) {
return question.input !== undefined && question.input.type !== 'select';
}
const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const NUMBER_PATTERN = /^-?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
function isDate(value) {
const match = DATE_PATTERN.exec(value);
if (match === null) {
return false;
}
const [, year, month, day] = match;
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
return (date.getUTCFullYear() === Number(year) &&
date.getUTCMonth() === Number(month) - 1 &&
date.getUTCDate() === Number(day));
}
function isHttpUrl(value) {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
}
catch {
return false;
}
}
function fullMatch(pattern, value) {
try {
return new RegExp(`^(?:${pattern})$`, 'u').test(value);
}
catch {
// An author's unreadable pattern constrains nothing rather than refusing every answer.
return undefined;
}
}
function asNumber(bound) {
if (typeof bound === 'number' && Number.isFinite(bound)) {
return bound;
}
if (typeof bound === 'string' && NUMBER_PATTERN.test(bound.trim())) {
return Number(bound);
}
return undefined;
}
/**
* Why `value` is not an acceptable answer to `question`, or `null` when it is. Checks ONE value;
* {@link validateElicitationAnswer} checks the list (required, single choice).
*/
export function validateElicitationValue(question, value) {
const input = question.input;
if (input === undefined || input.type === 'select') {
if (question.allowFreeText === true) {
return null;
}
return questionOptions(question).some((option) => option.value === value)
? null
: 'must be one of the offered options';
}
switch (input.type) {
case 'number': {
if (!NUMBER_PATTERN.test(value.trim()) || !Number.isFinite(Number(value))) {
return 'must be a number';
}
const number = Number(value);
const min = asNumber(input.min);
const max = asNumber(input.max);
if (min !== undefined && number < min) {
return `must be at least ${min}`;
}
if (max !== undefined && number > max) {
return `must be at most ${max}`;
}
return null;
}
case 'boolean':
return value === 'true' || value === 'false' ? null : 'must be "true" or "false"';
case 'date': {
if (!isDate(value)) {
return 'must be a date as YYYY-MM-DD';
}
if (typeof input.min === 'string' && isDate(input.min) && value < input.min) {
return `must be on or after ${input.min}`;
}
if (typeof input.max === 'string' && isDate(input.max) && value > input.max) {
return `must be on or before ${input.max}`;
}
return null;
}
case 'email':
if (!EMAIL_PATTERN.test(value)) {
return 'must be an email address';
}
break;
case 'url':
if (!isHttpUrl(value)) {
return 'must be an http(s) URL';
}
break;
case 'text':
case 'textarea': {
const min = asNumber(input.min);
const max = asNumber(input.max);
if (min !== undefined && value.length < min) {
return `must be at least ${min} characters`;
}
if (max !== undefined && value.length > max) {
return `must be at most ${max} characters`;
}
break;
}
}
if (input.pattern !== undefined && fullMatch(input.pattern, value) === false) {
return 'does not match the expected format';
}
return null;
}
/**
* Why `values` is not an acceptable answer to `question`, or `null` when it is: a `required`
* question left empty, more than one value for a single-choice question, or any value
* {@link validateElicitationValue} refuses. An empty string counts as no value.
*/
export function validateElicitationAnswer(question, values) {
const present = values.filter((value) => value !== '');
if (present.length === 0) {
return question.input?.required === true ? 'requires an answer' : null;
}
if (question.multiple !== true && present.length > 1) {
return 'takes a single value';
}
for (const value of present) {
const problem = validateElicitationValue(question, value);
if (problem !== null) {
return problem;
}
}
return null;
}
/**
* Read an {@link ElicitationInput} from untrusted JSON, or `undefined` when it is not one. Unknown
* keys are dropped; a malformed optional field is dropped rather than failing the input.
*/
export function readElicitationInput(raw) {
if (typeof raw !== 'object' || raw === null) {
return undefined;
}
const candidate = raw;
const type = candidate.type;
if (typeof type !== 'string' || !ELICITATION_INPUT_TYPES.includes(type)) {
return undefined;
}
const bound = (value) => (typeof value === 'number' && Number.isFinite(value)) || typeof value === 'string'
? value
: undefined;
const min = bound(candidate.min);
const max = bound(candidate.max);
return {
type: type,
...(typeof candidate.placeholder === 'string' ? { placeholder: candidate.placeholder } : {}),
...(candidate.required === true ? { required: true } : {}),
...(min !== undefined ? { min } : {}),
...(max !== undefined ? { max } : {}),
...(typeof candidate.pattern === 'string' ? { pattern: candidate.pattern } : {}),
};
}
/**
* The questions a parked call carries, read leniently from its recorded input (`{ questions }` —
* what both an intake and an `ask` persist). Used where a reply is checked against the questions it
* answers; a question that cannot be read is left out rather than failing the rest.
*/
export function readElicitationQuestions(input) {
if (typeof input !== 'object' || input === null) {
return [];
}
const raw = input.questions;
if (!Array.isArray(raw)) {
return [];
}
const questions = [];
for (const item of raw) {
if (typeof item !== 'object' || item === null) {
continue;
}
const candidate = item;
if (typeof candidate.id !== 'string' || typeof candidate.prompt !== 'string') {
continue;
}
const options = Array.isArray(candidate.options)
? candidate.options.flatMap((option) => {
if (typeof option !== 'object' || option === null) {
return [];
}
const { value, label } = option;
return typeof value === 'string' && typeof label === 'string' ? [{ value, label }] : [];
})
: [];
const typed = readElicitationInput(candidate.input);
const defaults = Array.isArray(candidate.defaults)
? candidate.defaults.filter((value) => typeof value === 'string')
: [];
questions.push({
id: candidate.id,
prompt: candidate.prompt,
options,
...(typed !== undefined ? { input: typed } : {}),
...(defaults.length > 0 ? { defaults } : {}),
...(candidate.multiple === true ? { multiple: true } : {}),
...(candidate.allowFreeText === true ? { allowFreeText: true } : {}),
});
}
return questions;
}
//# sourceMappingURL=elicitation-input.js.map