@compilr-dev/sdk 0.10.11 → 0.10.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,6 +38,16 @@ const SUMMARY_RENDERERS = ['compact', 'detailed'];
38
38
  * slug doesn't map to a component.
39
39
  */
40
40
  const ICON_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
41
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition, @typescript-eslint/no-unnecessary-type-conversion --
42
+ * The entire validator section runs DEFENSIVE runtime checks against agent
43
+ * JSON arriving over IPC. TypeScript types are lies at this boundary — an
44
+ * agent's malformed input can have any field missing or wrong-typed, and we
45
+ * must return structured errors rather than throw. The "always-falsy" and
46
+ * "redundant String()" warnings would be true if we only saw type-safe
47
+ * callers, but our caller is `JSON.parse(IPC payload)` cast to Flow. The
48
+ * pragmatic alternative (passing `unknown` through every helper signature)
49
+ * would be a much larger refactor for no behavioral gain.
50
+ */
41
51
  /**
42
52
  * Validate a flow. Accepts `unknown` because inputs flow in as JSON from the
43
53
  * agent and TS types are lies at the boundary — runtime defensive checks
@@ -75,10 +85,18 @@ export function validateFlow(flowInput) {
75
85
  // 2. Each node's id must match the key it lives under
76
86
  for (const id of nodeIds) {
77
87
  const node = flow.nodes[id];
88
+ if (!node || typeof node !== 'object') {
89
+ errors.push({
90
+ code: 'INVALID_NODE_TYPE',
91
+ message: `flow.nodes['${id}'] is null or not an object`,
92
+ nodeId: id,
93
+ });
94
+ continue;
95
+ }
78
96
  if (node.id !== id) {
79
97
  errors.push({
80
98
  code: 'DUPLICATE_NODE_ID',
81
- message: `Node key '${id}' does not match node.id '${node.id}'`,
99
+ message: `Node key '${id}' does not match node.id '${String(node.id)}'`,
82
100
  nodeId: id,
83
101
  });
84
102
  }
@@ -86,6 +104,8 @@ export function validateFlow(flowInput) {
86
104
  // 3. Per-node validation: type, references, icons, renderers, conditions
87
105
  for (const id of nodeIds) {
88
106
  const node = flow.nodes[id];
107
+ if (!node || typeof node !== 'object')
108
+ continue; // already flagged above
89
109
  validateNode(node, flow.nodes, errors);
90
110
  }
91
111
  // 4. Reachability — warn on orphans
@@ -125,6 +145,24 @@ function validateNode(node, allNodes, errors) {
125
145
  }
126
146
  }
127
147
  function validateQuestion(node, allNodes, errors) {
148
+ // Required: prompt
149
+ if (typeof node.prompt !== 'string' || node.prompt.trim().length === 0) {
150
+ errors.push({
151
+ code: 'INVALID_NODE_TYPE',
152
+ message: `Question node '${node.id}' is missing required 'prompt' string (the question text shown to the user)`,
153
+ nodeId: node.id,
154
+ });
155
+ }
156
+ // Defensive: agent JSON may omit required nested fields. Surface as
157
+ // structured errors rather than letting the validator throw.
158
+ if (!node.input || typeof node.input !== 'object') {
159
+ errors.push({
160
+ code: 'INVALID_NODE_TYPE',
161
+ message: `Question node '${node.id}' is missing the required 'input' object (must contain 'mode' and choices/options/etc.)`,
162
+ nodeId: node.id,
163
+ });
164
+ return;
165
+ }
128
166
  // Input mode must have a valid renderer if specified. The lookup CAN miss
129
167
  // at runtime if the agent passed an unknown mode in the JSON — TS narrowing
130
168
  // doesn't help us across the boundary, hence the explicit widened type.
@@ -132,7 +170,7 @@ function validateQuestion(node, allNodes, errors) {
132
170
  if (!validRenderers) {
133
171
  errors.push({
134
172
  code: 'INVALID_NODE_TYPE',
135
- message: `Unknown question input mode '${node.input.mode}'`,
173
+ message: `Unknown question input mode '${String(node.input.mode)}' for node '${node.id}' (expected: single, multi, text, or proposal)`,
136
174
  nodeId: node.id,
137
175
  });
138
176
  return;
@@ -144,23 +182,125 @@ function validateQuestion(node, allNodes, errors) {
144
182
  nodeId: node.id,
145
183
  });
146
184
  }
147
- // Validate icons on choices/proposals
185
+ // Validate choices/options arrays — must be non-empty and each item must
186
+ // have a non-empty 'id' AND 'label'. Without these the user sees an empty
187
+ // row and clicking it produces no answer.
148
188
  if (node.input.mode === 'single' || node.input.mode === 'multi') {
149
- for (const choice of node.input.choices) {
150
- if (choice.icon !== undefined)
151
- checkIcon(choice.icon, node.id, errors);
189
+ if (!Array.isArray(node.input.choices)) {
190
+ errors.push({
191
+ code: 'INVALID_NODE_TYPE',
192
+ message: `Question '${node.id}' with mode '${node.input.mode}' requires a 'choices' array`,
193
+ nodeId: node.id,
194
+ });
195
+ }
196
+ else if (node.input.choices.length === 0) {
197
+ errors.push({
198
+ code: 'INVALID_NODE_TYPE',
199
+ message: `Question '${node.id}' has an empty 'choices' array — must have at least 1 choice (each with 'id' and 'label')`,
200
+ nodeId: node.id,
201
+ });
202
+ }
203
+ else {
204
+ for (let i = 0; i < node.input.choices.length; i++) {
205
+ const choice = node.input.choices[i];
206
+ if (!choice || typeof choice !== 'object') {
207
+ errors.push({
208
+ code: 'INVALID_NODE_TYPE',
209
+ message: `Question '${node.id}' choice at index ${String(i)} is null or not an object`,
210
+ nodeId: node.id,
211
+ });
212
+ continue;
213
+ }
214
+ if (typeof choice.id !== 'string' || choice.id.trim().length === 0) {
215
+ errors.push({
216
+ code: 'INVALID_NODE_TYPE',
217
+ message: `Question '${node.id}' choice at index ${String(i)} is missing a non-empty 'id' string`,
218
+ nodeId: node.id,
219
+ });
220
+ }
221
+ if (typeof choice.label !== 'string' || choice.label.trim().length === 0) {
222
+ errors.push({
223
+ code: 'INVALID_NODE_TYPE',
224
+ message: `Question '${node.id}' choice at index ${String(i)} is missing a non-empty 'label' string (the visible text the user clicks)`,
225
+ nodeId: node.id,
226
+ });
227
+ }
228
+ if (choice.icon !== undefined)
229
+ checkIcon(choice.icon, node.id, errors);
230
+ }
152
231
  }
153
232
  }
154
233
  else if (node.input.mode === 'proposal') {
155
- for (const opt of node.input.options) {
156
- if (opt.icon !== undefined)
157
- checkIcon(opt.icon, node.id, errors);
234
+ if (!Array.isArray(node.input.options)) {
235
+ errors.push({
236
+ code: 'INVALID_NODE_TYPE',
237
+ message: `Question '${node.id}' with mode 'proposal' requires an 'options' array`,
238
+ nodeId: node.id,
239
+ });
158
240
  }
241
+ else if (node.input.options.length === 0) {
242
+ errors.push({
243
+ code: 'INVALID_NODE_TYPE',
244
+ message: `Question '${node.id}' has an empty 'options' array — proposal mode needs at least 2 options (each with 'id' and 'label')`,
245
+ nodeId: node.id,
246
+ });
247
+ }
248
+ else {
249
+ for (let i = 0; i < node.input.options.length; i++) {
250
+ const opt = node.input.options[i];
251
+ if (!opt || typeof opt !== 'object') {
252
+ errors.push({
253
+ code: 'INVALID_NODE_TYPE',
254
+ message: `Question '${node.id}' option at index ${String(i)} is null or not an object`,
255
+ nodeId: node.id,
256
+ });
257
+ continue;
258
+ }
259
+ if (typeof opt.id !== 'string' || opt.id.trim().length === 0) {
260
+ errors.push({
261
+ code: 'INVALID_NODE_TYPE',
262
+ message: `Question '${node.id}' option at index ${String(i)} is missing a non-empty 'id' string`,
263
+ nodeId: node.id,
264
+ });
265
+ }
266
+ if (typeof opt.label !== 'string' || opt.label.trim().length === 0) {
267
+ errors.push({
268
+ code: 'INVALID_NODE_TYPE',
269
+ message: `Question '${node.id}' option at index ${String(i)} is missing a non-empty 'label' string (the visible title)`,
270
+ nodeId: node.id,
271
+ });
272
+ }
273
+ if (opt.icon !== undefined)
274
+ checkIcon(opt.icon, node.id, errors);
275
+ }
276
+ }
277
+ }
278
+ // Validate next references (defensive — `next` might be undefined)
279
+ if (node.next === undefined || node.next === null) {
280
+ errors.push({
281
+ code: 'UNRESOLVED_NEXT',
282
+ message: `Question node '${node.id}' is missing the required 'next' field (string nodeId or { byAnswer: ... })`,
283
+ nodeId: node.id,
284
+ });
285
+ return;
159
286
  }
160
- // Validate next references
161
287
  validateNextRef(node.next, node, allNodes, errors);
162
288
  }
163
289
  function validateInfo(node, allNodes, errors) {
290
+ if (typeof node.title !== 'string' || node.title.length === 0) {
291
+ errors.push({
292
+ code: 'INVALID_NODE_TYPE',
293
+ message: `Info node '${node.id}' is missing required 'title' string`,
294
+ nodeId: node.id,
295
+ });
296
+ }
297
+ if (typeof node.body !== 'string') {
298
+ errors.push({
299
+ code: 'INVALID_NODE_TYPE',
300
+ message: `Info node '${node.id}' is missing required 'body' string (markdown content)`,
301
+ nodeId: node.id,
302
+ });
303
+ }
164
304
  if (node.render && !INFO_RENDERERS.includes(node.render)) {
165
305
  errors.push({
166
306
  code: 'INVALID_RENDERER',
@@ -168,36 +308,68 @@ function validateInfo(node, allNodes, errors) {
168
308
  nodeId: node.id,
169
309
  });
170
310
  }
311
+ if (node.next === undefined || node.next === null) {
312
+ errors.push({
313
+ code: 'UNRESOLVED_NEXT',
314
+ message: `Info node '${node.id}' is missing the required 'next' field`,
315
+ nodeId: node.id,
316
+ });
317
+ return;
318
+ }
171
319
  validateNextRef(node.next, node, allNodes, errors);
172
320
  }
173
321
  function validateBranch(node, allNodes, errors) {
174
- // All routes must reference existing nodes and use valid conditions
175
- for (let i = 0; i < node.routes.length; i++) {
176
- const route = node.routes[i];
177
- if (!isValidCondition(route.when)) {
178
- errors.push({
179
- code: 'INVALID_CONDITION',
180
- message: `Branch route ${String(i)} has an invalid condition shape`,
181
- nodeId: node.id,
182
- });
183
- }
184
- if (!(route.goto in allNodes)) {
185
- errors.push({
186
- code: 'UNRESOLVED_BRANCH',
187
- message: `Branch route ${String(i)} goto '${route.goto}' is not defined in flow.nodes`,
188
- nodeId: node.id,
189
- });
322
+ // Defensive: routes might be missing or non-array
323
+ if (!Array.isArray(node.routes)) {
324
+ errors.push({
325
+ code: 'INVALID_NODE_TYPE',
326
+ message: `Branch node '${node.id}' requires a 'routes' array (each item: { when: ..., goto: nodeId })`,
327
+ nodeId: node.id,
328
+ });
329
+ }
330
+ else {
331
+ for (let i = 0; i < node.routes.length; i++) {
332
+ const route = node.routes[i];
333
+ if (!route || typeof route !== 'object') {
334
+ errors.push({
335
+ code: 'INVALID_CONDITION',
336
+ message: `Branch route ${String(i)} on node '${node.id}' is null or not an object`,
337
+ nodeId: node.id,
338
+ });
339
+ continue;
340
+ }
341
+ if (!isValidCondition(route.when)) {
342
+ errors.push({
343
+ code: 'INVALID_CONDITION',
344
+ message: `Branch route ${String(i)} has an invalid condition shape`,
345
+ nodeId: node.id,
346
+ });
347
+ }
348
+ if (typeof route.goto !== 'string' || !(route.goto in allNodes)) {
349
+ errors.push({
350
+ code: 'UNRESOLVED_BRANCH',
351
+ message: `Branch route ${String(i)} goto '${String(route.goto)}' is not defined in flow.nodes`,
352
+ nodeId: node.id,
353
+ });
354
+ }
190
355
  }
191
356
  }
192
- if (!node.default || !(node.default in allNodes)) {
357
+ if (!node.default || typeof node.default !== 'string' || !(node.default in allNodes)) {
193
358
  errors.push({
194
359
  code: 'UNRESOLVED_BRANCH',
195
- message: `Branch default '${node.default}' is not defined in flow.nodes`,
360
+ message: `Branch default '${String(node.default)}' is not defined in flow.nodes`,
196
361
  nodeId: node.id,
197
362
  });
198
363
  }
199
364
  }
200
365
  function validateSummary(node, errors) {
366
+ if (typeof node.title !== 'string' || node.title.trim().length === 0) {
367
+ errors.push({
368
+ code: 'INVALID_NODE_TYPE',
369
+ message: `Summary node '${node.id}' is missing required 'title' string`,
370
+ nodeId: node.id,
371
+ });
372
+ }
201
373
  if (node.render && !SUMMARY_RENDERERS.includes(node.render)) {
202
374
  errors.push({
203
375
  code: 'INVALID_RENDERER',
@@ -218,12 +390,20 @@ function validateNextRef(next, node, allNodes, errors) {
218
390
  }
219
391
  return;
220
392
  }
221
- // byAnswer object
393
+ // byAnswer object — defensive on shape
394
+ if (!next || typeof next !== 'object' || !next.byAnswer || typeof next.byAnswer !== 'object') {
395
+ errors.push({
396
+ code: 'UNRESOLVED_NEXT',
397
+ message: `Node '${node.id}' next is not a valid NextRef (expected string nodeId or { byAnswer: { ... } })`,
398
+ nodeId: node.id,
399
+ });
400
+ return;
401
+ }
222
402
  for (const [answer, target] of Object.entries(next.byAnswer)) {
223
- if (!(target in allNodes)) {
403
+ if (typeof target !== 'string' || !(target in allNodes)) {
224
404
  errors.push({
225
405
  code: 'UNRESOLVED_NEXT',
226
- message: `Node '${node.id}' next.byAnswer['${answer}'] references '${target}' which is not defined in flow.nodes`,
406
+ message: `Node '${node.id}' next.byAnswer['${answer}'] references '${String(target)}' which is not defined in flow.nodes`,
227
407
  nodeId: node.id,
228
408
  });
229
409
  }
@@ -320,7 +500,62 @@ export function createInteractiveFlowTool(handler) {
320
500
  'Use this when a decision branches into multiple paths that benefit from ' +
321
501
  'visual exploration — the user can move forward, go back, and the agent ' +
322
502
  'gets back both their answers and the path they took through the tree. ' +
323
- 'Prefer over ask_user when the decision has 2+ branching considerations.',
503
+ 'Prefer over ask_user when the decision has 2+ branching considerations.' +
504
+ '\n\n' +
505
+ 'INPUT SHAPE — get this right or validation will reject:\n' +
506
+ '{\n' +
507
+ ' "flow": {\n' +
508
+ ' "title": "<modal header>", // REQUIRED, non-empty\n' +
509
+ ' "startNode": "<nodeId>", // REQUIRED, must be a key in nodes\n' +
510
+ ' "nodes": { // REQUIRED, map of nodeId → node\n' +
511
+ ' "<nodeId>": { ...node },\n' +
512
+ ' ...\n' +
513
+ ' }\n' +
514
+ ' }\n' +
515
+ '}\n\n' +
516
+ 'NODE TYPES — each node MUST include its own "id" matching the map key:\n\n' +
517
+ '1. QUESTION (gather input):\n' +
518
+ ' { "type": "question", "id": "<id>", "prompt": "<text>",\n' +
519
+ ' "input": { "mode": "single", "choices": [\n' +
520
+ ' { "id": "yes", "label": "Yes" }, // id + label BOTH required, non-empty\n' +
521
+ ' { "id": "no", "label": "No" }\n' +
522
+ ' ]\n' +
523
+ ' },\n' +
524
+ ' "next": "<nodeId>" // OR { "byAnswer": { "yes": "<id>", "no": "<id>" }, "default": "<id>" }\n' +
525
+ ' }\n' +
526
+ ' input.mode can be: single | multi | text | proposal\n' +
527
+ ' - single/multi: choices[] required, each with id + label\n' +
528
+ ' - proposal: options[] with id + label + optional pros[] + cons[]\n' +
529
+ ' - text: placeholder + multiline optional\n\n' +
530
+ '2. INFO (show, no input):\n' +
531
+ ' { "type": "info", "id": "<id>", "title": "<header>", "body": "<markdown>", "next": "<nodeId>" }\n\n' +
532
+ '3. BRANCH (pure routing, no UI):\n' +
533
+ ' { "type": "branch", "id": "<id>",\n' +
534
+ ' "routes": [ { "when": { "questionId": "<id>", "equals": "<value>" }, "goto": "<nodeId>" } ],\n' +
535
+ ' "default": "<nodeId>"\n' +
536
+ ' }\n\n' +
537
+ '4. SUMMARY (terminal recap, flow ends here):\n' +
538
+ ' { "type": "summary", "id": "<id>", "title": "<header>", "recap": "<optional markdown>" }\n\n' +
539
+ 'MINIMAL WORKING EXAMPLE — copy this shape:\n' +
540
+ '{\n' +
541
+ ' "flow": {\n' +
542
+ ' "title": "Pick a color",\n' +
543
+ ' "startNode": "q1",\n' +
544
+ ' "nodes": {\n' +
545
+ ' "q1": {\n' +
546
+ ' "type": "question", "id": "q1", "prompt": "Which color?",\n' +
547
+ ' "input": { "mode": "single", "choices": [\n' +
548
+ ' { "id": "red", "label": "Red" },\n' +
549
+ ' { "id": "blue", "label": "Blue" }\n' +
550
+ ' ]},\n' +
551
+ ' "next": "done"\n' +
552
+ ' },\n' +
553
+ ' "done": { "type": "summary", "id": "done", "title": "Got it" }\n' +
554
+ ' }\n' +
555
+ ' }\n' +
556
+ '}\n\n' +
557
+ 'Phase 1a renders only single-mode questions, info, summary, and branch. ' +
558
+ 'multi/text/proposal modes accept the input but show a "not yet in Phase 1a" placeholder.',
324
559
  inputSchema: INTERACTIVE_FLOW_INPUT_SCHEMA,
325
560
  execute: async (input) => {
326
561
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/sdk",
3
- "version": "0.10.11",
3
+ "version": "0.10.13",
4
4
  "description": "Universal agent runtime for building AI-powered applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",