/** * Questionnaire definitions. * * A definition comes from an agent tool call, so it is untrusted input: this * module is what turns it into something the rest of the system can rely on. * Definitions are frozen once accepted — an answer only means anything against * the exact question that was asked. */ import * as limits from "./limits"; import type { Question } from "@chopin/protocol"; export type Option = Question.Option; export type Item = Question.Item; export type Definition = Question.Definition; export type DecisionDefinition = Question.DecisionDefinition; export type Answer = Question.Answer; /** Thrown for any malformed definition; the message reaches the agent. */ export class QuestionError extends Error { override readonly name = "QuestionError"; } function fail(message: string): never { throw new QuestionError(message); } function record(value: unknown, name: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { fail(`${name} must be an object`); } return value as Record; } /** Reject unknown fields outright rather than ignoring them silently. */ function exact(value: Record, keys: string[], name: string): void { let actual = Object.keys(value).sort(); let expected = keys.toSorted(); if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { fail(`${name} has invalid fields`); } } function text(value: unknown, name: string, max: number, optional = false): string { if (typeof value !== "string") fail(`${name} must be text`); let result = value.trim(); if (!optional && !result) fail(`${name} is required`); if (result.length > max) fail(`${name} exceeds ${max} characters`); return result; } /** A stored definition keeps its original string, so enforce the bound on that string. */ function storedText(value: unknown, name: string, max: number, optional = false): void { text(value, name, max, optional); if ((value as string).length > max) fail(`${name} exceeds ${max} characters`); } /** * Validate an agent's tool input into a frozen definition. * * Option and question identifiers are positional because they only have to be * unique within one call. Durable plan questionnaires re-key them to ULIDs on * the way in, since those do have to survive rewrites. * * @throws {QuestionError} */ export function normalize(raw: unknown): Definition { let args = record(raw, "Question tool input"); exact(args, ["questions"], "Question tool input"); if (!Array.isArray(args.questions) || args.questions.length === 0) { fail("At least one question is required"); } if (args.questions.length > limits.MAX_QUESTIONS) { fail(`A questionnaire can contain at most ${limits.MAX_QUESTIONS} questions`); } let questions: Item[] = args.questions.map((source, index) => { let raw = record(source, `Question ${index + 1}`); exact(raw, ["header", "question", "options", "multiple"], `Question ${index + 1}`); if (!Array.isArray(raw.options) || raw.options.length === 0) { fail(`Question ${index + 1} requires at least one option`); } if (raw.options.length > limits.MAX_OPTIONS) { fail(`Question ${index + 1} can contain at most ${limits.MAX_OPTIONS} options`); } if (typeof raw.multiple !== "boolean") { fail(`Question ${index + 1} multiple must be a boolean`); } let options: Option[] = raw.options.map((source, position) => { let option = record(source, `Question ${index + 1} option ${position + 1}`); exact(option, ["label", "description"], `Question ${index + 1} option ${position + 1}`); return { id: `o${position}`, label: text( option.label, `Question ${index + 1} option ${position + 1} label`, limits.MAX_LABEL, ), description: text( option.description, `Question ${index + 1} option ${position + 1} description`, limits.MAX_DESCRIPTION, true, ), }; }); for (let option of options) Object.freeze(option); Object.freeze(options); return { id: `q${index}`, header: text(raw.header, `Question ${index + 1} header`, limits.MAX_HEADER), question: text(raw.question, `Question ${index + 1}`, limits.MAX_QUESTION), options, multiple: raw.multiple, }; }); for (let question of questions) Object.freeze(question); Object.freeze(questions); return Object.freeze({ questions }); } /** Validate a stored or wire definition without changing its durable IDs or order. */ export function identified(raw: unknown): Definition { let source = record(raw, "Questionnaire definition"); exact(source, ["questions"], "Questionnaire definition"); if ( !Array.isArray(source.questions) || source.questions.length === 0 || source.questions.length > limits.MAX_QUESTIONS ) { fail("Questionnaire definition has an invalid question count"); } let questionIds = new Set(); let optionIds = new Set(); for (let [index, candidate] of source.questions.entries()) { let question = record(candidate, `Question ${index + 1}`); exact(question, ["id", "header", "question", "options", "multiple"], `Question ${index + 1}`); storedText(question.id, `Question ${index + 1} id`, limits.MAX_CALL_ID); let id = question.id as string; if (questionIds.has(id)) fail("Questionnaire has duplicate question IDs"); questionIds.add(id); storedText(question.header, `Question ${index + 1} header`, limits.MAX_HEADER); storedText(question.question, `Question ${index + 1}`, limits.MAX_QUESTION); if ( typeof question.multiple !== "boolean" || !Array.isArray(question.options) || question.options.length === 0 && (source.questions.length !== 1 || question.multiple) || question.options.length > limits.MAX_OPTIONS ) { fail(`Question ${index + 1} has invalid options or multiple value`); } for (let [position, candidate] of question.options.entries()) { let option = record(candidate, `Question ${index + 1} option ${position + 1}`); exact(option, ["id", "label", "description"], `Question ${index + 1} option ${position + 1}`); storedText(option.id, `Question ${index + 1} option id`, limits.MAX_CALL_ID); let optionId = option.id as string; if (optionIds.has(optionId)) fail("Questionnaire has duplicate option IDs"); optionIds.add(optionId); storedText(option.label, `Question ${index + 1} option label`, limits.MAX_LABEL); storedText( option.description, `Question ${index + 1} option description`, limits.MAX_DESCRIPTION, true, ); } } return raw as Definition; } /** Narrow a questionnaire to the shape owned by one durable decision card. */ export function decision(definition: Definition): DecisionDefinition { if (definition.questions.length !== 1) { fail("A decision record must contain exactly one question"); } return definition as DecisionDefinition; } /** Reject a tool call id that could not have come from the SDK. */ export function assertCallId(id: string): void { if (!id || id.length > limits.MAX_CALL_ID) fail("Question tool call ID is invalid"); } export { fail as reject };