@compilr-dev/sdk 0.10.14 → 0.10.16

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.
@@ -184,7 +184,7 @@ export interface InteractiveFlowResult {
184
184
  /** UI handler — the consumer provides this; the SDK calls it once validation passes */
185
185
  export type InteractiveFlowHandler = (input: InteractiveFlowInput) => Promise<InteractiveFlowResult>;
186
186
  /** Error codes — see spec §4 */
187
- export type FlowErrorCode = 'MISSING_START_NODE' | 'DUPLICATE_NODE_ID' | 'UNRESOLVED_NEXT' | 'UNRESOLVED_BRANCH' | 'INVALID_ICON' | 'INVALID_RENDERER' | 'INVALID_NODE_TYPE' | 'INVALID_CONDITION';
187
+ export type FlowErrorCode = 'MISSING_START_NODE' | 'DUPLICATE_NODE_ID' | 'UNRESOLVED_NEXT' | 'UNRESOLVED_BRANCH' | 'INVALID_ICON' | 'INVALID_RENDERER' | 'INVALID_NODE_TYPE' | 'INVALID_CONDITION' | 'CYCLE_DETECTED';
188
188
  export interface FlowValidationError {
189
189
  code: FlowErrorCode;
190
190
  message: string;
@@ -108,7 +108,20 @@ export function validateFlow(flowInput) {
108
108
  continue; // already flagged above
109
109
  validateNode(node, flow.nodes, errors);
110
110
  }
111
- // 4. Reachabilitywarn on orphans
111
+ // 4. Cycle detection any cycle is an error, not a warning. A cycle means
112
+ // the user can be trapped in an infinite loop ("Yes → q1 → Yes → q1 ..."),
113
+ // which is fundamentally broken UX even though the user CAN escape via Cancel.
114
+ if (errors.length === 0) {
115
+ const cycles = detectCycles(flow);
116
+ for (const cycle of cycles) {
117
+ errors.push({
118
+ code: 'CYCLE_DETECTED',
119
+ message: `Cycle detected: ${cycle.join(' → ')}. Flows must be acyclic — a user navigating the cycle would loop forever (with Cancel as the only escape). If you need a "retry" pattern, model it with explicit nodes that ask "Try again?" before re-entering.`,
120
+ nodeId: cycle[0],
121
+ });
122
+ }
123
+ }
124
+ // 5. Reachability — warn on orphans (only when no errors)
112
125
  if (errors.length === 0 && flow.startNode in flow.nodes) {
113
126
  const reachable = computeReachable(flow);
114
127
  for (const id of nodeIds) {
@@ -270,6 +283,20 @@ function validateQuestion(node, allNodes, errors) {
270
283
  nodeId: node.id,
271
284
  });
272
285
  }
286
+ // Proposal options exist to compare tradeoffs — they MUST have at
287
+ // least one pro or one con. An option with neither is a sign the
288
+ // agent should have used single mode with `choices[].description`
289
+ // (a label + explanation) rather than proposal mode (which is for
290
+ // comparing tradeoffs).
291
+ const hasPros = Array.isArray(opt.pros) && opt.pros.length > 0;
292
+ const hasCons = Array.isArray(opt.cons) && opt.cons.length > 0;
293
+ if (!hasPros && !hasCons) {
294
+ errors.push({
295
+ code: 'INVALID_NODE_TYPE',
296
+ message: `Question '${node.id}' option at index ${String(i)} ('${typeof opt.label === 'string' ? opt.label : opt.id}') has no pros or cons. Proposal mode is for comparing tradeoffs — every option needs at least one pro OR one con. If your options are just label + description (no tradeoffs to compare), use single mode with choices[].description instead.`,
297
+ nodeId: node.id,
298
+ });
299
+ }
273
300
  if (opt.icon !== undefined)
274
301
  checkIcon(opt.icon, node.id, errors);
275
302
  }
@@ -437,6 +464,120 @@ function checkIcon(icon, nodeId, errors) {
437
464
  });
438
465
  }
439
466
  }
467
+ /**
468
+ * Detect cycles in the flow graph. Returns an array of cycles, where each
469
+ * cycle is an array of node IDs representing the loop (first node repeated
470
+ * implicitly — `[a, b, c]` means `a → b → c → a`).
471
+ *
472
+ * Uses iterative DFS with explicit recursion-stack tracking ("color marking"):
473
+ * - WHITE = not visited yet
474
+ * - GRAY = currently in the recursion stack (on the active path)
475
+ * - BLACK = fully explored
476
+ * If we encounter a GRAY node, that's a back-edge → cycle.
477
+ *
478
+ * Walks every outgoing edge (next, byAnswer, branch routes, branch default)
479
+ * so cycles through any path are caught.
480
+ */
481
+ function detectCycles(flow) {
482
+ const cycles = [];
483
+ const seenCycles = new Set(); // dedup by sorted member set
484
+ const color = new Map();
485
+ const allIds = Object.keys(flow.nodes);
486
+ function getOutgoing(node) {
487
+ if (node.type === 'summary')
488
+ return [];
489
+ if (node.type === 'branch') {
490
+ const t = [];
491
+ const routes = node.routes;
492
+ if (Array.isArray(routes)) {
493
+ for (const r of routes) {
494
+ if (r && typeof r.goto === 'string')
495
+ t.push(r.goto);
496
+ }
497
+ }
498
+ if (typeof node.default === 'string')
499
+ t.push(node.default);
500
+ return t;
501
+ }
502
+ // question | info — extract from next
503
+ const next = node.next;
504
+ if (typeof next === 'string')
505
+ return [next];
506
+ if (!next || typeof next !== 'object')
507
+ return [];
508
+ const t = [];
509
+ if (next.byAnswer && typeof next.byAnswer === 'object') {
510
+ for (const v of Object.values(next.byAnswer)) {
511
+ if (typeof v === 'string')
512
+ t.push(v);
513
+ }
514
+ }
515
+ if (typeof next.default === 'string')
516
+ t.push(next.default);
517
+ return t;
518
+ }
519
+ function recordCycle(path, backEdgeTarget) {
520
+ const startIdx = path.indexOf(backEdgeTarget);
521
+ if (startIdx === -1)
522
+ return;
523
+ const cycle = path.slice(startIdx);
524
+ // Dedup by canonicalizing — rotate to smallest member first, then stringify
525
+ const canonical = canonicalCycle(cycle);
526
+ if (seenCycles.has(canonical))
527
+ return;
528
+ seenCycles.add(canonical);
529
+ cycles.push(cycle);
530
+ }
531
+ for (const startId of allIds) {
532
+ if (color.get(startId))
533
+ continue;
534
+ const stack = [
535
+ { nodeId: startId, targets: getOutgoing(flow.nodes[startId]), cursor: 0 },
536
+ ];
537
+ color.set(startId, 'gray');
538
+ while (stack.length > 0) {
539
+ const frame = stack[stack.length - 1];
540
+ if (frame.cursor >= frame.targets.length) {
541
+ // Done with this node — mark black, pop
542
+ color.set(frame.nodeId, 'black');
543
+ stack.pop();
544
+ continue;
545
+ }
546
+ const target = frame.targets[frame.cursor++];
547
+ if (!(target in flow.nodes))
548
+ continue; // missing node (UNRESOLVED_NEXT already flagged)
549
+ const status = color.get(target);
550
+ if (status === 'gray') {
551
+ // Back-edge → cycle
552
+ const activePath = stack.map((f) => f.nodeId);
553
+ recordCycle(activePath, target);
554
+ continue;
555
+ }
556
+ if (status === 'black')
557
+ continue; // already fully explored
558
+ // WHITE — recurse
559
+ color.set(target, 'gray');
560
+ stack.push({ nodeId: target, targets: getOutgoing(flow.nodes[target]), cursor: 0 });
561
+ }
562
+ }
563
+ return cycles;
564
+ }
565
+ /**
566
+ * Canonical-form a cycle so `[a,b,c]`, `[b,c,a]`, `[c,a,b]` all collapse to
567
+ * the same string. Used for dedup.
568
+ */
569
+ function canonicalCycle(cycle) {
570
+ if (cycle.length === 0)
571
+ return '';
572
+ // Find rotation starting with lexicographically-smallest node
573
+ let minIdx = 0;
574
+ for (let i = 1; i < cycle.length; i++) {
575
+ if (cycle[i] < cycle[minIdx])
576
+ minIdx = i;
577
+ }
578
+ const rotated = [...cycle.slice(minIdx), ...cycle.slice(0, minIdx)];
579
+ return rotated.join('→');
580
+ }
440
581
  /** Compute set of node IDs reachable from startNode via any path */
441
582
  function computeReachable(flow) {
442
583
  const reachable = new Set();
@@ -524,9 +665,15 @@ export function createInteractiveFlowTool(handler) {
524
665
  ' "next": "<nodeId>" // OR { "byAnswer": { "yes": "<id>", "no": "<id>" }, "default": "<id>" }\n' +
525
666
  ' }\n' +
526
667
  ' 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' +
668
+ ' - single: choices[] (id + label, optional description + icon).\n' +
669
+ ' PICK THIS for "pick one from a list" even when each option has\n' +
670
+ ' a description. Descriptions belong on choices, not in proposal.\n' +
671
+ ' - multi: choices[] (same shape as single), optional min/max.\n' +
672
+ ' - proposal: options[] (id + label + pros[] + cons[]).\n' +
673
+ ' PICK THIS ONLY when each option has tradeoffs to compare\n' +
674
+ ' (at least one pro OR one con per option). If your options\n' +
675
+ ' are just label + description (no tradeoffs), use single mode.\n' +
676
+ ' - text: placeholder + multiline optional.\n\n' +
530
677
  '2. INFO (show, no input):\n' +
531
678
  ' { "type": "info", "id": "<id>", "title": "<header>", "body": "<markdown>", "next": "<nodeId>" }\n\n' +
532
679
  '3. BRANCH (pure routing, no UI):\n' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/sdk",
3
- "version": "0.10.14",
3
+ "version": "0.10.16",
4
4
  "description": "Universal agent runtime for building AI-powered applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",