/** * 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. The single exception is `appendOption`: * while a question is open, members may add options. That never changes the * meaning of an existing option or of a choice already made, and each append * yields a new frozen definition rather than mutating the old one. */ 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, verbatim = false, ): string { if (typeof value !== "string") fail(`${name} must be text`); if (verbatim) return value; 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, verbatim = false, ): void { text(value, name, max, optional, verbatim); if (!verbatim && (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. * * A verbatim definition is a host dialog the runtime already permitted: it * keeps its text exactly and is bounded by the draft and document byte limits * rather than by the `ask` tool's per-field contract. * * @throws {QuestionError} */ export function normalize(raw: unknown, { verbatim = false } = {}): 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 (!verbatim && 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) || (!verbatim && raw.options.length === 0)) { fail(`Question ${index + 1} requires at least one option`); } if (!verbatim && 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, verbatim, verbatim, ), description: text( option.description, `Question ${index + 1} option ${position + 1} description`, limits.MAX_DESCRIPTION, true, verbatim, ), }; }); 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, verbatim, verbatim, ), question: text( raw.question, `Question ${index + 1}`, limits.MAX_QUESTION, verbatim, verbatim, ), options, multiple: raw.multiple, ...(verbatim ? { verbatim: true as const } : {}), }; }); 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 && !source.questions.every(question => (question as { verbatim?: unknown })?.verbatim === true ) ) { 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}`); let verbatim = "verbatim" in question; if (verbatim && question.verbatim !== true) fail(`Question ${index + 1} has invalid fields`); exact( question, ["id", "header", "question", "options", "multiple", ...(verbatim ? ["verbatim"] : [])], `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, false, verbatim); storedText(question.question, `Question ${index + 1}`, limits.MAX_QUESTION, false, verbatim); if ( typeof question.multiple !== "boolean" || !Array.isArray(question.options) || question.options.length === 0 && !verbatim && (source.questions.length !== 1 || question.multiple) || !verbatim && 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, false, verbatim, ); storedText( option.description, `Question ${index + 1} option description`, limits.MAX_DESCRIPTION, true, verbatim, ); } } 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; } export type Appended = | { ok: true; definition: DecisionDefinition; option: Option } | { ok: false; reason: "invalid" | "duplicate" | "full"; message: string; }; const KEY = /^[A-Za-z0-9_-]{8,64}$/; /** * Append one option to a decision's only question. * * Pure: the caller mints `id`, persists the result, and decides who may call * this. Validation happens here so the server and any client that wants to * warn early apply identical bounds. */ export function appendOption( definition: DecisionDefinition, input: { question: string; key: string; label: unknown; description?: unknown }, id: string, ): Appended { let [item] = definition.questions; if (!item || item.id !== input.question) { return { ok: false, reason: "invalid", message: "Unknown question" }; } if (typeof input.key !== "string" || !KEY.test(input.key)) { return { ok: false, reason: "invalid", message: "Option request key is invalid" }; } let label: string; let description: string; try { label = text(input.label, "Option", limits.MAX_LABEL); description = input.description === undefined ? "" : text(input.description, "Option description", limits.MAX_DESCRIPTION, true); } catch (err) { return { ok: false, reason: "invalid", message: (err as Error).message }; } let folded = label.toLowerCase(); if (item.options.some(option => option.label.trim().toLowerCase() === folded)) { return { ok: false, reason: "duplicate", message: "That option already exists" }; } if (item.options.length >= limits.MAX_SHARED_OPTIONS) { return { ok: false, reason: "full", message: `A question can have at most ${limits.MAX_SHARED_OPTIONS} options`, }; } if (item.options.some(option => option.id === id)) { return { ok: false, reason: "invalid", message: "Option id is already in use" }; } let option: Option = Object.freeze({ id, label, description }); let options = Object.freeze([...item.options, option]); let next = Object.freeze({ ...item, options }) as Item; return { ok: true, option, definition: Object.freeze({ questions: Object.freeze([next]) }) 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 };