|
| 1 | +/** |
| 2 | + * Interaction contract — the generalized human-in-the-loop primitive. |
| 3 | + * |
| 4 | + * An agent emits an `InteractionRequest`: a typed ask that carries a |
| 5 | + * self-describing `answerSpec` (the fields and types of a valid answer). A |
| 6 | + * human (or an automated policy) returns an `InteractionResponse` keyed by the |
| 7 | + * same `id`. This subsumes the original question/answer pair and extends it to |
| 8 | + * permissions, plans, and provider-specific asks. |
| 9 | + * |
| 10 | + * Design contract: |
| 11 | + * - The envelope is stable; `kind` is an OPEN label, so new ask types need no |
| 12 | + * change to this contract. Well-known kinds (see `InteractionKind`) get |
| 13 | + * richer rendering and platform handling; unknown kinds render generically |
| 14 | + * from `answerSpec` and still work end-to-end. |
| 15 | + * - `answerSpec` is a small closed set of flat field types, so any consumer can |
| 16 | + * render a form and validate a response without a general schema engine. This |
| 17 | + * mirrors MCP elicitation so MCP-originated asks map onto this 1:1. |
| 18 | + * - `default` + `timeoutMs`/`onTimeout` make unattended resolution explicit and |
| 19 | + * auditable, replacing blanket permission-bypass flags. |
| 20 | + */ |
| 21 | + |
| 22 | +import { z } from "zod"; |
| 23 | + |
| 24 | +// ============================================================================= |
| 25 | +// Answer specification — describes the shape of a valid answer. |
| 26 | +// ============================================================================= |
| 27 | + |
| 28 | +const FieldBase = { |
| 29 | + /** Stable key the answer is returned under in `InteractionResponse.data`. */ |
| 30 | + name: z.string().min(1), |
| 31 | + /** Human-readable label for the form control. */ |
| 32 | + label: z.string().min(1), |
| 33 | + /** Whether the answer must supply this field to be `accepted`. */ |
| 34 | + required: z.boolean().optional(), |
| 35 | +}; |
| 36 | + |
| 37 | +export const InteractionFieldSchema = z.discriminatedUnion("type", [ |
| 38 | + z.object({ |
| 39 | + ...FieldBase, |
| 40 | + type: z.literal("text"), |
| 41 | + multiline: z.boolean().optional(), |
| 42 | + placeholder: z.string().optional(), |
| 43 | + default: z.string().optional(), |
| 44 | + }), |
| 45 | + z.object({ |
| 46 | + ...FieldBase, |
| 47 | + type: z.literal("number"), |
| 48 | + min: z.number().optional(), |
| 49 | + max: z.number().optional(), |
| 50 | + default: z.number().optional(), |
| 51 | + }), |
| 52 | + z.object({ |
| 53 | + ...FieldBase, |
| 54 | + type: z.literal("boolean"), |
| 55 | + default: z.boolean().optional(), |
| 56 | + }), |
| 57 | + z.object({ |
| 58 | + ...FieldBase, |
| 59 | + type: z.literal("select"), |
| 60 | + options: z |
| 61 | + .array( |
| 62 | + z.object({ |
| 63 | + value: z.string(), |
| 64 | + label: z.string(), |
| 65 | + description: z.string().optional(), |
| 66 | + }), |
| 67 | + ) |
| 68 | + .min(1), |
| 69 | + /** When true the user may pick more than one option. */ |
| 70 | + multi: z.boolean().optional(), |
| 71 | + default: z.array(z.string()).optional(), |
| 72 | + }), |
| 73 | + /** Like `text` but the value is sensitive (token/key) and must be masked. */ |
| 74 | + z.object({ |
| 75 | + ...FieldBase, |
| 76 | + type: z.literal("secret"), |
| 77 | + placeholder: z.string().optional(), |
| 78 | + }), |
| 79 | +]); |
| 80 | +export type InteractionField = z.infer<typeof InteractionFieldSchema>; |
| 81 | + |
| 82 | +export const InteractionAnswerSpecSchema = z.object({ |
| 83 | + fields: z.array(InteractionFieldSchema), |
| 84 | +}); |
| 85 | +export type InteractionAnswerSpec = z.infer<typeof InteractionAnswerSpecSchema>; |
| 86 | + |
| 87 | +// ============================================================================= |
| 88 | +// Subject — what the request is about (drives preview/permission UX). |
| 89 | +// ============================================================================= |
| 90 | + |
| 91 | +export const InteractionSubjectSchema = z.discriminatedUnion("type", [ |
| 92 | + z.object({ type: z.literal("tool"), toolName: z.string(), input: z.unknown().optional() }), |
| 93 | + z.object({ type: z.literal("command"), command: z.string() }), |
| 94 | + z.object({ type: z.literal("file"), path: z.string(), preview: z.string().optional() }), |
| 95 | + z.object({ type: z.literal("resource"), uri: z.string() }), |
| 96 | +]); |
| 97 | +export type InteractionSubject = z.infer<typeof InteractionSubjectSchema>; |
| 98 | + |
| 99 | +// ============================================================================= |
| 100 | +// Outcome + resolution — the answer. |
| 101 | +// ============================================================================= |
| 102 | + |
| 103 | +export const InteractionOutcomeSchema = z.enum(["accepted", "declined", "cancelled"]); |
| 104 | +export type InteractionOutcome = z.infer<typeof InteractionOutcomeSchema>; |
| 105 | + |
| 106 | +/** Field values keyed by `InteractionField.name`. Validated against `answerSpec`. */ |
| 107 | +export const InteractionDataSchema = z.record( |
| 108 | + z.string(), |
| 109 | + z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]), |
| 110 | +); |
| 111 | +export type InteractionData = z.infer<typeof InteractionDataSchema>; |
| 112 | + |
| 113 | +export const InteractionResolutionSchema = z.object({ |
| 114 | + outcome: InteractionOutcomeSchema, |
| 115 | + /** Present (and validated) only when `outcome === "accepted"`. */ |
| 116 | + data: InteractionDataSchema.optional(), |
| 117 | +}); |
| 118 | +export type InteractionResolution = z.infer<typeof InteractionResolutionSchema>; |
| 119 | + |
| 120 | +// ============================================================================= |
| 121 | +// The request envelope. |
| 122 | +// ============================================================================= |
| 123 | + |
| 124 | +export const InteractionRequestSchema = z.object({ |
| 125 | + /** Correlation id; unique within a session. The response carries the same id. */ |
| 126 | + id: z.string().min(1), |
| 127 | + /** |
| 128 | + * Open label for rendering, handling, and authorization. Well-known values: |
| 129 | + * `question` | `permission` | `plan`. Vendor extensions SHOULD namespace, |
| 130 | + * e.g. `x-pi.choose-extension`. |
| 131 | + */ |
| 132 | + kind: z.string().min(1), |
| 133 | + /** Short human-readable prompt. */ |
| 134 | + title: z.string().min(1), |
| 135 | + /** Optional longer context (markdown). */ |
| 136 | + body: z.string().optional(), |
| 137 | + subject: InteractionSubjectSchema.optional(), |
| 138 | + answerSpec: InteractionAnswerSpecSchema, |
| 139 | + /** Resolution applied when unattended or timed out — explicit, not a bypass flag. */ |
| 140 | + default: InteractionResolutionSchema.optional(), |
| 141 | + /** Wait this long for a human before applying `onTimeout`. */ |
| 142 | + timeoutMs: z.number().int().positive().optional(), |
| 143 | + /** On timeout: apply `default`, `fail` the turn, or keep `wait`ing. Default `wait`. */ |
| 144 | + onTimeout: z.enum(["default", "fail", "wait"]).optional(), |
| 145 | +}); |
| 146 | +export type InteractionRequest = z.infer<typeof InteractionRequestSchema>; |
| 147 | + |
| 148 | +export const InteractionResponseSchema = InteractionResolutionSchema.extend({ |
| 149 | + id: z.string().min(1), |
| 150 | +}); |
| 151 | +export type InteractionResponse = z.infer<typeof InteractionResponseSchema>; |
| 152 | + |
| 153 | +// ============================================================================= |
| 154 | +// Well-known kinds + helpers. |
| 155 | +// ============================================================================= |
| 156 | + |
| 157 | +export const InteractionKind = { |
| 158 | + /** Agent asks the user to answer/choose. Answer = the chosen field values. */ |
| 159 | + Question: "question", |
| 160 | + /** Agent requests approval to run a tool/command. Answer = a `PermissionGrant`. */ |
| 161 | + Permission: "permission", |
| 162 | + /** Agent shares a plan/todo list for review/approval. */ |
| 163 | + Plan: "plan", |
| 164 | +} as const; |
| 165 | +export type WellKnownInteractionKind = |
| 166 | + (typeof InteractionKind)[keyof typeof InteractionKind]; |
| 167 | + |
| 168 | +/** Field name carrying the grant on a `permission` interaction's response. */ |
| 169 | +export const PERMISSION_GRANT_FIELD = "grant"; |
| 170 | +/** Optional free-text field carrying the user's reason on a `permission` response. */ |
| 171 | +export const PERMISSION_FEEDBACK_FIELD = "feedback"; |
| 172 | + |
| 173 | +/** Graduated permission decision — the value of the `grant` field. */ |
| 174 | +export const PermissionGrantSchema = z.enum([ |
| 175 | + "allow_once", |
| 176 | + "allow_session", |
| 177 | + "allow_always", |
| 178 | + "deny", |
| 179 | +]); |
| 180 | +export type PermissionGrant = z.infer<typeof PermissionGrantSchema>; |
| 181 | + |
| 182 | +/** Build the answer spec for a `permission` interaction (graduated grant + feedback). */ |
| 183 | +export function permissionAnswerSpec(opts?: { allowFeedback?: boolean }): InteractionAnswerSpec { |
| 184 | + const fields: InteractionField[] = [ |
| 185 | + { |
| 186 | + type: "select", |
| 187 | + name: PERMISSION_GRANT_FIELD, |
| 188 | + label: "Decision", |
| 189 | + required: true, |
| 190 | + options: [ |
| 191 | + { value: "allow_once", label: "Allow once" }, |
| 192 | + { value: "allow_session", label: "Allow for this session" }, |
| 193 | + { value: "allow_always", label: "Always allow" }, |
| 194 | + { value: "deny", label: "Deny" }, |
| 195 | + ], |
| 196 | + }, |
| 197 | + ]; |
| 198 | + if (opts?.allowFeedback !== false) { |
| 199 | + fields.push({ |
| 200 | + type: "text", |
| 201 | + name: PERMISSION_FEEDBACK_FIELD, |
| 202 | + label: "Feedback (optional)", |
| 203 | + multiline: true, |
| 204 | + }); |
| 205 | + } |
| 206 | + return { fields }; |
| 207 | +} |
| 208 | + |
| 209 | +/** Shape of one legacy question (kept for the back-compat shim). */ |
| 210 | +export type LegacyQuestion = { |
| 211 | + question: string; |
| 212 | + options?: Array<{ label: string; description?: string }>; |
| 213 | + multiSelect?: boolean; |
| 214 | +}; |
| 215 | + |
| 216 | +/** |
| 217 | + * Build an answer spec from the legacy `question` event shape. Each question |
| 218 | + * becomes one select field (free text when it declares no options), so the old |
| 219 | + * question/answer path is expressible as a `question` interaction. |
| 220 | + */ |
| 221 | +export function questionAnswerSpec(questions: LegacyQuestion[]): InteractionAnswerSpec { |
| 222 | + const fields: InteractionField[] = questions.map((q, i) => { |
| 223 | + const name = `q${i}`; |
| 224 | + if (q.options && q.options.length > 0) { |
| 225 | + return { |
| 226 | + type: "select", |
| 227 | + name, |
| 228 | + label: q.question, |
| 229 | + required: true, |
| 230 | + multi: q.multiSelect === true, |
| 231 | + options: q.options.map((o) => ({ value: o.label, label: o.label, description: o.description })), |
| 232 | + }; |
| 233 | + } |
| 234 | + return { type: "text", name, label: q.question, required: true }; |
| 235 | + }); |
| 236 | + return { fields }; |
| 237 | +} |
| 238 | + |
| 239 | +// ============================================================================= |
| 240 | +// Generic validation — does `data` satisfy `answerSpec`? Fail-closed. |
| 241 | +// ============================================================================= |
| 242 | + |
| 243 | +export type InteractionValidation = { ok: true } | { ok: false; errors: string[] }; |
| 244 | + |
| 245 | +/** |
| 246 | + * Validate an accepted answer against its spec. Used by the broker before a |
| 247 | + * response reaches the adapter, so malformed answers are rejected centrally. |
| 248 | + */ |
| 249 | +export function validateInteractionAnswer( |
| 250 | + spec: InteractionAnswerSpec, |
| 251 | + data: InteractionData | undefined, |
| 252 | +): InteractionValidation { |
| 253 | + const errors: string[] = []; |
| 254 | + const d = data ?? {}; |
| 255 | + for (const field of spec.fields) { |
| 256 | + const v = d[field.name]; |
| 257 | + const present = v !== undefined && v !== null && !(typeof v === "string" && v === ""); |
| 258 | + if (!present) { |
| 259 | + if (field.required) errors.push(`missing required field "${field.name}"`); |
| 260 | + continue; |
| 261 | + } |
| 262 | + switch (field.type) { |
| 263 | + case "text": |
| 264 | + case "secret": |
| 265 | + if (typeof v !== "string") errors.push(`field "${field.name}" must be a string`); |
| 266 | + break; |
| 267 | + case "number": |
| 268 | + if (typeof v !== "number") { |
| 269 | + errors.push(`field "${field.name}" must be a number`); |
| 270 | + } else { |
| 271 | + if (field.min !== undefined && v < field.min) errors.push(`field "${field.name}" below min ${field.min}`); |
| 272 | + if (field.max !== undefined && v > field.max) errors.push(`field "${field.name}" above max ${field.max}`); |
| 273 | + } |
| 274 | + break; |
| 275 | + case "boolean": |
| 276 | + if (typeof v !== "boolean") errors.push(`field "${field.name}" must be a boolean`); |
| 277 | + break; |
| 278 | + case "select": { |
| 279 | + if (!Array.isArray(v)) { |
| 280 | + errors.push(`field "${field.name}" must be an array of option values`); |
| 281 | + break; |
| 282 | + } |
| 283 | + if (!field.multi && v.length > 1) errors.push(`field "${field.name}" accepts a single value`); |
| 284 | + if (field.required && v.length === 0) errors.push(`field "${field.name}" requires a selection`); |
| 285 | + const allowed = new Set(field.options.map((o) => o.value)); |
| 286 | + for (const choice of v) { |
| 287 | + if (!allowed.has(choice)) errors.push(`field "${field.name}" has invalid option "${choice}"`); |
| 288 | + } |
| 289 | + break; |
| 290 | + } |
| 291 | + } |
| 292 | + } |
| 293 | + return errors.length === 0 ? { ok: true } : { ok: false, errors }; |
| 294 | +} |
0 commit comments