UNPKG

@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
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