Skip to content
Source
Sign inSign up
Open History to see the last commit

elicitationForm.ts

254 lines9.7 KB

1import type {
2 OrchestrationV2UserInputQuestion,
3 ProviderUserInputAnswers,
4} from "@t3tools/contracts";
5import type * as EffectAcpSchema from "effect-acp/compat";
6​
7import { unknownRecord } from "./clientPolicy.ts";
8​
9type Field =
10 | { readonly kind: "text" }
11 | { readonly kind: "number"; readonly integer: boolean }
12 | { readonly kind: "boolean" }
13 | { readonly kind: "choice"; readonly values: ReadonlySet<string> }
14 | {
15 readonly kind: "multiChoice";
16 readonly values: ReadonlySet<string>;
17 readonly minItems: number;
18 readonly maxItems: number;
19 };
20​
21type Option = OrchestrationV2UserInputQuestion["options"][number];
22​
23export interface ElicitationForm {
24 readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>;
25 /**
26 * Types answers as the schema declares them. Unknown keys and options are
27 * dropped, as is an answer that does not fit its field. Returns undefined
28 * when a required field is left without a valid answer.
29 */
30 readonly content: (
31 answers: ProviderUserInputAnswers,
32 ) => Record<string, EffectAcpSchema.ElicitationContentValue> | undefined;
34​
35function text(value: unknown): string | undefined {
36 return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
38​
39/** All of `entries` as options, or undefined when any entry cannot be one. */
40function choiceOptions(
41 entries: unknown,
42 toOption: (entry: unknown) => Option | undefined,
43): Option[] | undefined {
44 if (!Array.isArray(entries) || entries.length === 0) return undefined;
45 const options = entries.map(toOption);
46 return options.every((option) => option !== undefined) ? options : undefined;
48​
49function enumOptions(entries: unknown): Option[] | undefined {
50 if (
51 !Array.isArray(entries) ||
52 entries.length === 0 ||
53 !entries.every((value): value is string => typeof value === "string")
54 )
55 return undefined;
56 const counts = new Map<string, number>();
57 for (const value of entries) counts.set(value.trim(), (counts.get(value.trim()) ?? 0) + 1);
58 const options = entries.map((value) => {
59 const plain = value.trim();
60 const label = plain !== "" && counts.get(plain) === 1 ? plain : JSON.stringify(value);
61 return { label, description: label, value };
62 });
63 // A quoted fallback can collide with a literal label; quote every value in that case.
64 return new Set(options.map((option) => option.label)).size === options.length
65 ? options
66 : options.map((option) => {
67 const label = JSON.stringify(option.value);
68 return { ...option, label, description: label };
69 });
71​
72/** Titled entries (`oneOf` on strings, `anyOf` on array items) carry a title for their `const`. */
73function titledOption(entry: unknown): Option | undefined {
74 const option = unknownRecord(entry);
75 const label = text(option?.title) ?? text(option?.const);
76 return typeof option?.const === "string" && label !== undefined
77 ? { label, description: text(option.description) ?? label, value: option.const }
78 : undefined;
80​
81function parseField(
82 property: Readonly<Record<string, unknown>>,
83): { readonly field: Field; readonly options: Option[] } | undefined {
84 switch (property.type) {
85 case "string": {
86 // ACP treats a null `enum` or `oneOf` like an omitted one.
87 if ((property.enum ?? property.oneOf ?? undefined) === undefined) {
88 return { field: { kind: "text" }, options: [] };
89 }
90 const options = Array.isArray(property.oneOf)
91 ? choiceOptions(property.oneOf, titledOption)
92 : enumOptions(property.enum);
93 return options === undefined
94 ? undefined
95 : {
96 field: { kind: "choice", values: new Set(options.map(optionValue)) },
97 options,
98 };
99 }
100 case "array": {
101 // String items list plain values in `enum`; titled items list `{ const, title }` in `anyOf`.
102 const items = unknownRecord(property.items);
103 const options =
104 items?.type === "string"
105 ? enumOptions(items.enum)
106 : items !== undefined && items.type === undefined
107 ? choiceOptions(items.anyOf, titledOption)
108 : undefined;
109 if (options === undefined) return undefined;
110 // The schema decode already limits counts to non-negative integers.
111 const values = new Set(options.map(optionValue));
112 const minItems = typeof property.minItems === "number" ? property.minItems : 0;
113 const maxItems = typeof property.maxItems === "number" ? property.maxItems : Infinity;
114 // Clients require a selection, so empty-only questions cannot be answered.
115 if (maxItems === 0 || minItems > maxItems || minItems > values.size) return undefined;
116 return { field: { kind: "multiChoice", values, minItems, maxItems }, options };
117 }
118 case "boolean":
119 return {
120 field: { kind: "boolean" },
121 options: [
122 { label: "true", description: "Yes", value: "true" },
123 { label: "false", description: "No", value: "false" },
124 ],
125 };
126 case "number":
127 case "integer":
128 return { field: { kind: "number", integer: property.type === "integer" }, options: [] };
129 default:
130 return undefined;
131 }
133​
134function optionValue(option: Option): string {
135 return option.value ?? option.label;
137​
138function typedAnswer(
139 field: Field,
140 answer: unknown,
141): EffectAcpSchema.ElicitationContentValue | undefined {
142 switch (field.kind) {
143 case "text":
144 return typeof answer === "string" ? answer : undefined;
145 case "boolean":
146 if (typeof answer === "boolean") return answer;
147 return answer === "true" ? true : answer === "false" ? false : undefined;
148 case "number": {
149 const parsed =
150 typeof answer === "number"
151 ? answer
152 : typeof answer === "string" && answer.trim().length > 0
153 ? Number(answer.trim())
154 : Number.NaN;
155 if (!Number.isFinite(parsed)) return undefined;
156 return field.integer && !Number.isInteger(parsed) ? undefined : parsed;
157 }
158 case "choice":
159 return typeof answer === "string" && field.values.has(answer) ? answer : undefined;
160 case "multiChoice": {
161 if (!Array.isArray(answer)) return undefined;
162 const selected = [
163 ...new Set(
164 answer.filter(
165 (entry): entry is string => typeof entry === "string" && field.values.has(entry),
166 ),
167 ),
168 ];
169 // `[]` is an explicit empty selection, but a selection of only unknown
170 // options is not one.
171 if (selected.length === 0 && answer.length > 0) return undefined;
172 return selected.length >= field.minItems && selected.length <= field.maxItems
173 ? selected
174 : undefined;
175 }
176 }
178​
179// Decline unaskable forms; validate types and selection counts, leaving other constraints to the agent.
180export function parseElicitationForm(
181 requestedSchema: unknown,
182 message: string,
183): ElicitationForm | undefined {
184 const schema = unknownRecord(requestedSchema);
185 const required = new Set(
186 Array.isArray(schema?.required)
187 ? schema.required.filter((key): key is string => typeof key === "string")
188 : [],
189 );
190 const fields = new Map<string, Field>();
191 const parsedFields: Array<{
192 readonly id: string;
193 readonly property: Readonly<Record<string, unknown>>;
194 readonly options: Option[];
195 }> = [];
196 for (const [id, value] of Object.entries(unknownRecord(schema?.properties) ?? {})) {
197 const property = unknownRecord(value);
198 const parsed = property === undefined ? undefined : parseField(property);
199 // Question ids must survive the contract's trim to round-trip answers.
200 if (property === undefined || parsed === undefined || text(id) !== id) return undefined;
201 fields.set(id, parsed.field);
202 parsedFields.push({ id, property, options: parsed.options });
203 }
204 // An optional `<id>__other` text field (OMP's ask dialog) is the free-text answer to
205 // optional single choice `<id>`: ask them as one question and send custom text to the
206 // companion. Multi-select keeps both fields, since it can answer with options and text.
207 const companions = new Map<string, string>();
208 for (const [id, field] of fields) {
209 const companionId = `${id}__other`;
210 if (
211 field.kind === "choice" &&
212 fields.get(companionId)?.kind === "text" &&
213 !required.has(id) &&
214 !required.has(companionId)
215 ) {
216 companions.set(id, companionId);
217 }
218 }
219 const folded = new Set(companions.values());
220 const questions: OrchestrationV2UserInputQuestion[] = [];
221 for (const { id, property, options } of parsedFields) {
222 if (folded.has(id)) continue;
223 const kind = fields.get(id)?.kind;
224 questions.push({
225 id,
226 header: text(property.title) ?? `Question ${questions.length + 1}`,
227 question: text(property.description) ?? message,
228 options,
229 ...(kind === "text" || kind === "number" || companions.has(id)
230 ? {}
231 : { allowCustomAnswer: false }),
232 ...(kind === "multiChoice" ? { multiSelect: true } : {}),
233 });
234 }
235 return {
236 questions,
237 content: (answers) => {
238 const entries: Array<[string, EffectAcpSchema.ElicitationContentValue]> = [];
239 for (const [id, field] of fields) {
240 if (folded.has(id)) continue;
241 const answer = Object.hasOwn(answers, id) ? answers[id] : undefined;
242 const typed = answer === undefined ? undefined : typedAnswer(field, answer);
243 const companionId = companions.get(id);
244 if (typed !== undefined) entries.push([id, typed]);
245 else if (companionId !== undefined && typeof answer === "string") {
246 entries.push([companionId, answer]);
247 } else if (required.has(id)) return undefined;
248 }
249 // Own properties for every field, `__proto__` included; assigning to
250 // `{}` would hit its inherited setter instead.
251 return Object.fromEntries(entries);
252 },
253 };