@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.
- package/dist/tools/interactive-flow-tool.js +267 -32
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 {
|