|
| 1 | +// Shared contract for agent interaction events (kind: "question" et al) — the |
| 2 | +// generalized human-in-the-loop primitive on the chat stream. Framework-agnostic |
| 3 | +// (no server- or React-only imports) so a producer (chat route) and a consumer |
| 4 | +// (stream parser, transcript, question card) agree on one wire shape and one |
| 5 | +// persisted-part shape. |
| 6 | +// |
| 7 | +// An `interaction` event means the run is BLOCKED inside the sidecar's |
| 8 | +// InteractionBroker until the user answers, the agent withdraws the ask |
| 9 | +// (`interaction.cancel`), or the broker times out. A pending interaction is |
| 10 | +// "waiting on the user", not "model working". |
| 11 | + |
| 12 | +import { |
| 13 | + InteractionRequestSchema, |
| 14 | + type InteractionData, |
| 15 | + type InteractionField, |
| 16 | + type InteractionOutcome, |
| 17 | + type InteractionRequest, |
| 18 | +} from '@tangle-network/agent-interface' |
| 19 | + |
| 20 | +export type { InteractionData, InteractionOutcome, InteractionRequest } |
| 21 | + |
| 22 | +// --------------------------------------------------------------------------- |
| 23 | +// Event names |
| 24 | + |
| 25 | +/** Sidecar → client: the agent raised an ask; data = `{ request }`. */ |
| 26 | +export const INTERACTION_EVENT = 'interaction' as const |
| 27 | +/** Sidecar → client: the ask was withdrawn; data = `{ id, reason? }`. */ |
| 28 | +export const INTERACTION_CANCEL_EVENT = 'interaction.cancel' as const |
| 29 | +/** An ask was answered; data = `{ id, status }`. In the wire contract so a |
| 30 | + * server broadcast and a client-local mark share one event name. */ |
| 31 | +export const INTERACTION_RESOLVED_EVENT = 'interaction.resolved' as const |
| 32 | + |
| 33 | +/** Interaction kinds a product typically renders a card for. Anything else is |
| 34 | + * auto-declined by the chat producer's safety net and never reaches a client. |
| 35 | + * A pure default; a product may substitute its own renderable set. */ |
| 36 | +const RENDERABLE_INTERACTION_KINDS: ReadonlySet<string> = new Set(['question', 'plan']) |
| 37 | + |
| 38 | +export function isRenderableInteractionKind(kind: string): boolean { |
| 39 | + return RENDERABLE_INTERACTION_KINDS.has(kind) |
| 40 | +} |
| 41 | + |
| 42 | +/** Answer/field keys the sidecar will accept: identifier-safe and never a |
| 43 | + * prototype-pollution vector. */ |
| 44 | +export function isSafeInteractionFieldKey(key: string): boolean { |
| 45 | + return /^[A-Za-z0-9_-]+$/.test(key) && key !== '__proto__' && key !== 'constructor' && key !== 'prototype' |
| 46 | +} |
| 47 | + |
| 48 | +// --------------------------------------------------------------------------- |
| 49 | +// Field types |
| 50 | +// |
| 51 | +// `allowCustom` (a select that also accepts a write-in value) is defined by |
| 52 | +// newer agent-interface schemas; older pinned schemas strip unknown keys on |
| 53 | +// parse. The wire/persisted field types below carry the flag so a card can gate |
| 54 | +// its write-in input, and `parseInteractionRequest` returns the RAW payload |
| 55 | +// (schema-validated, not schema-parsed) so the flag survives. |
| 56 | + |
| 57 | +export type ChatSelectField = Extract<InteractionField, { type: 'select' }> & { |
| 58 | + allowCustom?: boolean |
| 59 | +} |
| 60 | +export type ChatInteractionField = Exclude<InteractionField, { type: 'select' }> | ChatSelectField |
| 61 | + |
| 62 | +/** `InteractionRequest` whose select fields may carry `allowCustom`. */ |
| 63 | +export type InteractionRequestWire = Omit<InteractionRequest, 'answerSpec'> & { |
| 64 | + answerSpec: { fields: ChatInteractionField[] } |
| 65 | +} |
| 66 | + |
| 67 | +// --------------------------------------------------------------------------- |
| 68 | +// Interaction lifecycle |
| 69 | + |
| 70 | +export type ChatInteractionStatus = 'pending' | 'answered' | 'declined' | 'cancelled' | 'expired' |
| 71 | + |
| 72 | +/** The client/persisted view of one ask. `fields` come verbatim off the wire. */ |
| 73 | +export interface ChatInteraction { |
| 74 | + id: string |
| 75 | + kind: string |
| 76 | + title: string |
| 77 | + body?: string |
| 78 | + fields: ChatInteractionField[] |
| 79 | + status: ChatInteractionStatus |
| 80 | + /** Set when status came from an `interaction.cancel` (e.g. "timeout"). */ |
| 81 | + cancelReason?: string |
| 82 | +} |
| 83 | + |
| 84 | +export function isTerminalInteractionStatus(status: ChatInteractionStatus): boolean { |
| 85 | + return status !== 'pending' |
| 86 | +} |
| 87 | + |
| 88 | +/** Statuses only move forward (pending → terminal); a replayed/stale `pending` |
| 89 | + * must never resurrect a resolved card. */ |
| 90 | +export function canTransitionInteractionStatus( |
| 91 | + from: ChatInteractionStatus, |
| 92 | + to: ChatInteractionStatus, |
| 93 | +): boolean { |
| 94 | + return from === 'pending' && to !== from |
| 95 | +} |
| 96 | + |
| 97 | +/** Maps an `interaction.cancel` reason to the card's terminal status. */ |
| 98 | +export function cancelStatusFor(reason: string | undefined): ChatInteractionStatus { |
| 99 | + return reason === 'timeout' ? 'expired' : 'cancelled' |
| 100 | +} |
| 101 | + |
| 102 | +function stableValue(value: unknown): unknown { |
| 103 | + if (Array.isArray(value)) return value.map(stableValue) |
| 104 | + if (!value || typeof value !== 'object') return value |
| 105 | + return Object.fromEntries( |
| 106 | + Object.entries(value as Record<string, unknown>) |
| 107 | + .sort(([left], [right]) => left.localeCompare(right)) |
| 108 | + .map(([key, nested]) => [key, stableValue(nested)]), |
| 109 | + ) |
| 110 | +} |
| 111 | + |
| 112 | +function normalizedInteractionText(value: string | undefined): string { |
| 113 | + return (value ?? '').replace(/\s+/g, ' ').trim() |
| 114 | +} |
| 115 | + |
| 116 | +/** Content identity for duplicate safety nets. Excludes volatile ids/statuses. */ |
| 117 | +export function questionInteractionContentSignature(interaction: ChatInteraction): string | null { |
| 118 | + if (interaction.kind !== 'question') return null |
| 119 | + return JSON.stringify(stableValue({ |
| 120 | + kind: interaction.kind, |
| 121 | + title: normalizedInteractionText(interaction.title), |
| 122 | + body: normalizedInteractionText(interaction.body), |
| 123 | + fields: interaction.fields, |
| 124 | + })) |
| 125 | +} |
| 126 | + |
| 127 | +export function dedupeQuestionInteractionsByContent(interactions: ChatInteraction[]): ChatInteraction[] { |
| 128 | + const seen = new Set<string>() |
| 129 | + return interactions.filter((interaction) => { |
| 130 | + const signature = questionInteractionContentSignature(interaction) |
| 131 | + if (!signature) return true |
| 132 | + if (seen.has(signature)) return false |
| 133 | + seen.add(signature) |
| 134 | + return true |
| 135 | + }) |
| 136 | +} |
| 137 | + |
| 138 | +// --------------------------------------------------------------------------- |
| 139 | +// Wire parsing — typed outcomes, fail loud at the caller (log + skip; never a |
| 140 | +// half-rendered card). |
| 141 | + |
| 142 | +export type ParseInteractionResult = |
| 143 | + | { succeeded: true; value: InteractionRequestWire } |
| 144 | + | { succeeded: false; error: string } |
| 145 | + |
| 146 | +/** Parses an `interaction` event's data (`{ request }`). Validates the shape |
| 147 | + * with the agent-interface schema but returns the raw request so a field a |
| 148 | + * pinned schema predates (`allowCustom`) survives. */ |
| 149 | +export function parseInteractionRequest(data: Record<string, unknown> | undefined): ParseInteractionResult { |
| 150 | + const request = data?.request |
| 151 | + if (!request || typeof request !== 'object') { |
| 152 | + return { succeeded: false, error: 'interaction event carried no request object' } |
| 153 | + } |
| 154 | + const validation = InteractionRequestSchema.safeParse(request) |
| 155 | + if (!validation.success) { |
| 156 | + return { succeeded: false, error: `malformed interaction request: ${validation.error.message}` } |
| 157 | + } |
| 158 | + return { succeeded: true, value: request as InteractionRequestWire } |
| 159 | +} |
| 160 | + |
| 161 | +export interface InteractionCancelData { |
| 162 | + id: string |
| 163 | + reason?: string |
| 164 | +} |
| 165 | + |
| 166 | +export function parseInteractionCancel( |
| 167 | + data: Record<string, unknown> | undefined, |
| 168 | +): { succeeded: true; value: InteractionCancelData } | { succeeded: false; error: string } { |
| 169 | + const id = typeof data?.id === 'string' && data.id ? data.id : null |
| 170 | + if (!id) return { succeeded: false, error: 'interaction.cancel event carried no id' } |
| 171 | + const reason = typeof data?.reason === 'string' && data.reason ? data.reason : undefined |
| 172 | + return { succeeded: true, value: { id, ...(reason ? { reason } : {}) } } |
| 173 | +} |
| 174 | + |
| 175 | +// --------------------------------------------------------------------------- |
| 176 | +// Composer-as-answer delivery: while asks are pending the composer never |
| 177 | +// blocks — typed text is delivered verbatim to every open ask and the agent |
| 178 | +// decides what it means. No interpretation here; the sidecar validates answers |
| 179 | +// fail-closed (invalid free text on an option-only ask → 400). |
| 180 | + |
| 181 | +export function fieldAcceptsFreeText(field: ChatInteractionField): boolean { |
| 182 | + if (field.type === 'text') return true |
| 183 | + if (field.type === 'select') return (field as ChatSelectField).allowCustom === true |
| 184 | + return false |
| 185 | +} |
| 186 | + |
| 187 | +export interface ComposerAnswerDelivery { |
| 188 | + interactionId: string |
| 189 | + field: ChatInteractionField |
| 190 | +} |
| 191 | + |
| 192 | +/** One delivery per pending ask: the first free-text-capable field, else the |
| 193 | + * first field. Zero-field asks are skipped (nothing to carry the text). */ |
| 194 | +export function composerAnswerDeliveries(pending: ChatInteraction[]): ComposerAnswerDelivery[] { |
| 195 | + const deliveries: ComposerAnswerDelivery[] = [] |
| 196 | + for (const interaction of pending) { |
| 197 | + // Only questions take a composer-routed answer. A non-question ask (a plan) |
| 198 | + // is POSTed as outcome:"accepted" when answered — routing composer text to |
| 199 | + // it would silently APPROVE it. Approval is an explicit card click, so the |
| 200 | + // composer skips it and the plan card stays the only path. |
| 201 | + if (interaction.kind !== 'question') continue |
| 202 | + const field = interaction.fields.find(fieldAcceptsFreeText) ?? interaction.fields[0] |
| 203 | + if (!field) continue |
| 204 | + deliveries.push({ interactionId: interaction.id, field }) |
| 205 | + } |
| 206 | + return deliveries |
| 207 | +} |
| 208 | + |
| 209 | +/** Shapes composer text into the respond payload for the routed field |
| 210 | + * (select answers are string arrays on the wire; text answers are strings). */ |
| 211 | +export function composerAnswerData(field: ChatInteractionField, text: string): InteractionData { |
| 212 | + return { [field.name]: field.type === 'select' ? [text] : text } |
| 213 | +} |
| 214 | + |
| 215 | +// --------------------------------------------------------------------------- |
| 216 | +// Part keys + codecs (persisted `messages.parts` entries and live stream parts |
| 217 | +// share these shapes). |
| 218 | + |
| 219 | +export function interactionPartKey(id: string): string { |
| 220 | + return `interaction:${id}` |
| 221 | +} |
| 222 | + |
| 223 | +export function noticePartKey(id: string): string { |
| 224 | + return `notice:${id}` |
| 225 | +} |
| 226 | + |
| 227 | +export type NoticeKind = 'warning' | 'auto-declined' |
| 228 | + |
| 229 | +/** Builds the persisted/streamed `notice` part — a one-line transcript notice |
| 230 | + * explaining an out-of-band event (warning, auto-declined interaction). */ |
| 231 | +export function noticePart(noticeKind: NoticeKind, id: string, text: string): Record<string, unknown> { |
| 232 | + return { type: 'notice', id, noticeKind, text } |
| 233 | +} |
| 234 | + |
| 235 | +/** Reads a wire request into the client's pending `ChatInteraction`. */ |
| 236 | +export function interactionFromWireRequest(request: InteractionRequestWire): ChatInteraction { |
| 237 | + return { |
| 238 | + id: request.id, |
| 239 | + kind: request.kind, |
| 240 | + title: request.title, |
| 241 | + ...(request.body ? { body: request.body } : {}), |
| 242 | + fields: request.answerSpec.fields, |
| 243 | + status: 'pending', |
| 244 | + } |
| 245 | +} |
| 246 | + |
| 247 | +/** Builds the persisted/streamed `interaction` part from a wire request. */ |
| 248 | +export function interactionToPersistedPart( |
| 249 | + request: InteractionRequestWire, |
| 250 | + status: ChatInteractionStatus, |
| 251 | + cancelReason?: string, |
| 252 | +): Record<string, unknown> { |
| 253 | + return { |
| 254 | + type: 'interaction', |
| 255 | + id: request.id, |
| 256 | + kind: request.kind, |
| 257 | + title: request.title, |
| 258 | + ...(request.body ? { body: request.body } : {}), |
| 259 | + answerSpec: { fields: request.answerSpec.fields }, |
| 260 | + status, |
| 261 | + ...(cancelReason ? { cancelReason } : {}), |
| 262 | + } |
| 263 | +} |
| 264 | + |
| 265 | +/** Reads a persisted/streamed `interaction` part back into a `ChatInteraction`. |
| 266 | + * Returns null (caller logs) when the part is not one of ours. */ |
| 267 | +export function persistedPartToInteraction(part: Record<string, unknown>): ChatInteraction | null { |
| 268 | + if (String(part.type ?? '') !== 'interaction') return null |
| 269 | + const id = typeof part.id === 'string' && part.id ? part.id : null |
| 270 | + const kind = typeof part.kind === 'string' && part.kind ? part.kind : null |
| 271 | + const title = typeof part.title === 'string' ? part.title : '' |
| 272 | + const answerSpec = part.answerSpec as { fields?: unknown } | undefined |
| 273 | + const fields = Array.isArray(answerSpec?.fields) ? (answerSpec.fields as ChatInteractionField[]) : null |
| 274 | + const status = part.status as ChatInteractionStatus | undefined |
| 275 | + const validStatus = status && ['pending', 'answered', 'declined', 'cancelled', 'expired'].includes(status) |
| 276 | + if (!id || !kind || !fields || !validStatus) return null |
| 277 | + return { |
| 278 | + id, |
| 279 | + kind, |
| 280 | + title, |
| 281 | + ...(typeof part.body === 'string' && part.body ? { body: part.body } : {}), |
| 282 | + fields, |
| 283 | + status, |
| 284 | + ...(typeof part.cancelReason === 'string' && part.cancelReason ? { cancelReason: part.cancelReason } : {}), |
| 285 | + } |
| 286 | +} |
0 commit comments