voyageai-cli 1.24.0 → 1.26.1
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/package.json +1 -1
- package/src/cli.js +2 -0
- package/src/commands/about.js +1 -1
- package/src/commands/bug.js +1 -1
- package/src/commands/chat.js +281 -78
- package/src/commands/playground.js +73 -19
- package/src/commands/scaffold.js +23 -1
- package/src/commands/workflow.js +336 -0
- package/src/lib/chat.js +170 -4
- package/src/lib/explanations.js +53 -0
- package/src/lib/llm.js +304 -2
- package/src/lib/mongo.js +6 -6
- package/src/lib/prompt.js +60 -1
- package/src/lib/scaffold-structure.js +8 -9
- package/src/lib/telemetry.js +1 -1
- package/src/lib/template-engine.js +240 -0
- package/src/lib/templates/nextjs/README.md.tpl +78 -55
- package/src/lib/templates/nextjs/favicon.svg.tpl +11 -0
- package/src/lib/templates/nextjs/footer.jsx.tpl +49 -0
- package/src/lib/templates/nextjs/layout.jsx.tpl +16 -10
- package/src/lib/templates/nextjs/lib-mongo.js.tpl +5 -5
- package/src/lib/templates/nextjs/lib-voyage.js.tpl +13 -8
- package/src/lib/templates/nextjs/navbar.jsx.tpl +98 -0
- package/src/lib/templates/nextjs/page-home.jsx.tpl +201 -0
- package/src/lib/templates/nextjs/page-search.jsx.tpl +184 -82
- package/src/lib/templates/nextjs/theme-registry.jsx.tpl +51 -0
- package/src/lib/templates/nextjs/theme.js.tpl +138 -65
- package/src/lib/templates/nextjs/vai-logo-256.png +0 -0
- package/src/lib/tool-registry.js +194 -0
- package/src/lib/workflow-utils.js +65 -0
- package/src/lib/workflow.js +1259 -0
- package/src/mcp/tools/embedding.js +55 -43
- package/src/mcp/tools/ingest.js +74 -67
- package/src/mcp/tools/management.js +54 -101
- package/src/mcp/tools/retrieval.js +181 -163
- package/src/mcp/tools/utility.js +171 -153
- package/src/playground/icons/dark/128.png +0 -0
- package/src/playground/icons/dark/16.png +0 -0
- package/src/playground/icons/dark/256.png +0 -0
- package/src/playground/icons/dark/32.png +0 -0
- package/src/playground/icons/dark/64.png +0 -0
- package/src/playground/icons/light/128.png +0 -0
- package/src/playground/icons/light/16.png +0 -0
- package/src/playground/icons/light/256.png +0 -0
- package/src/playground/icons/light/32.png +0 -0
- package/src/playground/icons/light/64.png +0 -0
- package/src/playground/icons/watermark.png +0 -0
- package/src/playground/index.html +633 -83
- package/src/workflows/consistency-check.json +64 -0
- package/src/workflows/cost-analysis.json +69 -0
- package/src/workflows/multi-collection-search.json +80 -0
- package/src/workflows/research-and-summarize.json +46 -0
- package/src/workflows/smart-ingest.json +63 -0
|
@@ -0,0 +1,1259 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const {
|
|
6
|
+
resolveTemplate,
|
|
7
|
+
resolveString,
|
|
8
|
+
extractDependencies,
|
|
9
|
+
isTemplateString,
|
|
10
|
+
} = require('./template-engine');
|
|
11
|
+
|
|
12
|
+
// ════════════════════════════════════════════════════════════════════
|
|
13
|
+
// Valid tool names — vai tools + control flow
|
|
14
|
+
// ════════════════════════════════════════════════════════════════════
|
|
15
|
+
|
|
16
|
+
const VAI_TOOLS = new Set([
|
|
17
|
+
'query', 'search', 'rerank', 'embed', 'similarity',
|
|
18
|
+
'ingest', 'collections', 'models', 'explain', 'estimate',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const CONTROL_FLOW_TOOLS = new Set(['merge', 'filter', 'transform', 'generate']);
|
|
22
|
+
|
|
23
|
+
const ALL_TOOLS = new Set([...VAI_TOOLS, ...CONTROL_FLOW_TOOLS]);
|
|
24
|
+
|
|
25
|
+
// ════════════════════════════════════════════════════════════════════
|
|
26
|
+
// Validation
|
|
27
|
+
// ════════════════════════════════════════════════════════════════════
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Validate a workflow definition object.
|
|
31
|
+
* Returns an array of error strings (empty = valid).
|
|
32
|
+
*
|
|
33
|
+
* @param {object} definition - Parsed workflow JSON
|
|
34
|
+
* @returns {string[]} errors
|
|
35
|
+
*/
|
|
36
|
+
function validateWorkflow(definition) {
|
|
37
|
+
const errors = [];
|
|
38
|
+
|
|
39
|
+
// Top-level required fields
|
|
40
|
+
if (!definition || typeof definition !== 'object') {
|
|
41
|
+
return ['Workflow definition must be a JSON object'];
|
|
42
|
+
}
|
|
43
|
+
if (!definition.name || typeof definition.name !== 'string') {
|
|
44
|
+
errors.push('Workflow must have a "name" string');
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(definition.steps) || definition.steps.length === 0) {
|
|
47
|
+
errors.push('Workflow must have a non-empty "steps" array');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (errors.length > 0) return errors; // Can't validate steps without them
|
|
51
|
+
|
|
52
|
+
// Validate inputs schema
|
|
53
|
+
if (definition.inputs) {
|
|
54
|
+
for (const [key, schema] of Object.entries(definition.inputs)) {
|
|
55
|
+
if (schema.type && !['string', 'number', 'boolean'].includes(schema.type)) {
|
|
56
|
+
errors.push(`Input "${key}" has invalid type "${schema.type}" (must be string, number, or boolean)`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Step-level validation
|
|
62
|
+
const stepIds = new Set();
|
|
63
|
+
const duplicateIds = new Set();
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < definition.steps.length; i++) {
|
|
66
|
+
const step = definition.steps[i];
|
|
67
|
+
const prefix = `Step ${i}`;
|
|
68
|
+
|
|
69
|
+
if (!step.id || typeof step.id !== 'string') {
|
|
70
|
+
errors.push(`${prefix}: must have a string "id"`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stepPrefix = `Step "${step.id}"`;
|
|
75
|
+
|
|
76
|
+
// Duplicate check
|
|
77
|
+
if (stepIds.has(step.id)) {
|
|
78
|
+
duplicateIds.add(step.id);
|
|
79
|
+
}
|
|
80
|
+
stepIds.add(step.id);
|
|
81
|
+
|
|
82
|
+
// Tool validation
|
|
83
|
+
if (!step.tool || typeof step.tool !== 'string') {
|
|
84
|
+
errors.push(`${stepPrefix}: must have a string "tool"`);
|
|
85
|
+
} else if (!ALL_TOOLS.has(step.tool)) {
|
|
86
|
+
errors.push(`${stepPrefix}: unknown tool "${step.tool}" (available: ${[...ALL_TOOLS].join(', ')})`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Inputs validation
|
|
90
|
+
if (step.tool !== 'generate' && (!step.inputs || typeof step.inputs !== 'object')) {
|
|
91
|
+
errors.push(`${stepPrefix}: must have an "inputs" object`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check template references point to known step IDs or reserved prefixes
|
|
95
|
+
if (step.inputs) {
|
|
96
|
+
const deps = extractDependencies(step.inputs);
|
|
97
|
+
for (const dep of deps) {
|
|
98
|
+
if (!stepIds.has(dep) && !definition.steps.some(s => s.id === dep)) {
|
|
99
|
+
errors.push(`${stepPrefix}: references unknown step "${dep}"`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Condition validation (if present, should be a string)
|
|
105
|
+
if (step.condition !== undefined && typeof step.condition !== 'string') {
|
|
106
|
+
errors.push(`${stepPrefix}: "condition" must be a string`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// forEach validation (if present, should be a template string)
|
|
110
|
+
if (step.forEach !== undefined && typeof step.forEach !== 'string') {
|
|
111
|
+
errors.push(`${stepPrefix}: "forEach" must be a string`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Report duplicates
|
|
116
|
+
for (const id of duplicateIds) {
|
|
117
|
+
errors.push(`Duplicate step id: "${id}"`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check for circular dependencies
|
|
121
|
+
const cycleErrors = detectCycles(definition.steps);
|
|
122
|
+
errors.push(...cycleErrors);
|
|
123
|
+
|
|
124
|
+
return errors;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Detect circular dependencies in steps using DFS.
|
|
129
|
+
* @param {Array} steps
|
|
130
|
+
* @returns {string[]} errors
|
|
131
|
+
*/
|
|
132
|
+
function detectCycles(steps) {
|
|
133
|
+
const errors = [];
|
|
134
|
+
const stepMap = new Map(steps.map(s => [s.id, s]));
|
|
135
|
+
const adjList = new Map();
|
|
136
|
+
|
|
137
|
+
// Build adjacency list from template dependencies
|
|
138
|
+
for (const step of steps) {
|
|
139
|
+
const deps = extractDependencies(step.inputs || {});
|
|
140
|
+
if (step.condition) {
|
|
141
|
+
const condDeps = extractDependencies(step.condition);
|
|
142
|
+
for (const d of condDeps) deps.add(d);
|
|
143
|
+
}
|
|
144
|
+
if (step.forEach) {
|
|
145
|
+
const forDeps = extractDependencies(step.forEach);
|
|
146
|
+
for (const d of forDeps) deps.add(d);
|
|
147
|
+
}
|
|
148
|
+
adjList.set(step.id, deps);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// DFS cycle detection
|
|
152
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
153
|
+
const color = new Map(steps.map(s => [s.id, WHITE]));
|
|
154
|
+
|
|
155
|
+
function dfs(nodeId, path) {
|
|
156
|
+
color.set(nodeId, GRAY);
|
|
157
|
+
path.push(nodeId);
|
|
158
|
+
|
|
159
|
+
const neighbors = adjList.get(nodeId) || new Set();
|
|
160
|
+
for (const dep of neighbors) {
|
|
161
|
+
if (!stepMap.has(dep)) continue; // Unknown deps caught by validateWorkflow
|
|
162
|
+
if (color.get(dep) === GRAY) {
|
|
163
|
+
const cycleStart = path.indexOf(dep);
|
|
164
|
+
const cycle = path.slice(cycleStart).concat(dep);
|
|
165
|
+
errors.push(`Circular dependency: ${cycle.join(' -> ')}`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (color.get(dep) === WHITE) {
|
|
169
|
+
dfs(dep, path);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
path.pop();
|
|
174
|
+
color.set(nodeId, BLACK);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const step of steps) {
|
|
178
|
+
if (color.get(step.id) === WHITE) {
|
|
179
|
+
dfs(step.id, []);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return errors;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ════════════════════════════════════════════════════════════════════
|
|
187
|
+
// Dependency Resolution + Execution Plan
|
|
188
|
+
// ════════════════════════════════════════════════════════════════════
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build a dependency graph: stepId -> Set of step IDs it depends on.
|
|
192
|
+
* @param {Array} steps
|
|
193
|
+
* @returns {Map<string, Set<string>>}
|
|
194
|
+
*/
|
|
195
|
+
function buildDependencyGraph(steps) {
|
|
196
|
+
const graph = new Map();
|
|
197
|
+
|
|
198
|
+
for (const step of steps) {
|
|
199
|
+
const deps = extractDependencies(step.inputs || {});
|
|
200
|
+
if (step.condition) {
|
|
201
|
+
const condDeps = extractDependencies(step.condition);
|
|
202
|
+
for (const d of condDeps) deps.add(d);
|
|
203
|
+
}
|
|
204
|
+
if (step.forEach) {
|
|
205
|
+
const forDeps = extractDependencies(step.forEach);
|
|
206
|
+
for (const d of forDeps) deps.add(d);
|
|
207
|
+
}
|
|
208
|
+
graph.set(step.id, deps);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return graph;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Topological sort with layer grouping (Kahn's algorithm).
|
|
216
|
+
* Returns layers: each layer is an array of step IDs that can run in parallel.
|
|
217
|
+
*
|
|
218
|
+
* @param {Array} steps
|
|
219
|
+
* @returns {string[][]} layers
|
|
220
|
+
*/
|
|
221
|
+
function buildExecutionPlan(steps) {
|
|
222
|
+
const graph = buildDependencyGraph(steps);
|
|
223
|
+
const stepIds = new Set(steps.map(s => s.id));
|
|
224
|
+
|
|
225
|
+
// In-degree: count of dependencies that are actual steps
|
|
226
|
+
const inDegree = new Map();
|
|
227
|
+
for (const step of steps) {
|
|
228
|
+
const deps = graph.get(step.id);
|
|
229
|
+
let count = 0;
|
|
230
|
+
for (const dep of deps) {
|
|
231
|
+
if (stepIds.has(dep)) count++;
|
|
232
|
+
}
|
|
233
|
+
inDegree.set(step.id, count);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const layers = [];
|
|
237
|
+
const remaining = new Set(stepIds);
|
|
238
|
+
|
|
239
|
+
while (remaining.size > 0) {
|
|
240
|
+
// Find all nodes with in-degree 0
|
|
241
|
+
const ready = [];
|
|
242
|
+
for (const id of remaining) {
|
|
243
|
+
if (inDegree.get(id) === 0) {
|
|
244
|
+
ready.push(id);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (ready.length === 0) {
|
|
249
|
+
// Should not happen if cycle detection passed, but guard against it
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
layers.push(ready);
|
|
254
|
+
|
|
255
|
+
// Remove ready nodes and decrease in-degree of dependents
|
|
256
|
+
for (const id of ready) {
|
|
257
|
+
remaining.delete(id);
|
|
258
|
+
// Decrease in-degree of all steps that depend on this id
|
|
259
|
+
for (const otherId of remaining) {
|
|
260
|
+
const deps = graph.get(otherId);
|
|
261
|
+
if (deps.has(id)) {
|
|
262
|
+
inDegree.set(otherId, inDegree.get(otherId) - 1);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return layers;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ════════════════════════════════════════════════════════════════════
|
|
272
|
+
// Condition Evaluator
|
|
273
|
+
// ════════════════════════════════════════════════════════════════════
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Evaluate a simple condition expression.
|
|
277
|
+
* Supports: comparisons (>, <, >=, <=, ===, !==, ==, !=),
|
|
278
|
+
* boolean operators (&&, ||), negation (!), property access, array .length.
|
|
279
|
+
*
|
|
280
|
+
* This is NOT eval(). It's a very restricted expression evaluator.
|
|
281
|
+
*
|
|
282
|
+
* @param {string} expr - e.g. "check.output.results.length > 0"
|
|
283
|
+
* @param {object} context
|
|
284
|
+
* @returns {boolean}
|
|
285
|
+
*/
|
|
286
|
+
function evaluateCondition(expr, context) {
|
|
287
|
+
let resolved = expr;
|
|
288
|
+
|
|
289
|
+
if (isTemplateString(expr)) {
|
|
290
|
+
// Check if the entire expression is wrapped in {{ }}
|
|
291
|
+
// If so, extract the inner content and evaluate it as a condition
|
|
292
|
+
const soleMatch = expr.match(/^\{\{\s*(.+?)\s*\}\}$/);
|
|
293
|
+
if (soleMatch) {
|
|
294
|
+
const inner = soleMatch[1];
|
|
295
|
+
// If the inner expression contains operators, evaluate as condition
|
|
296
|
+
if (/[><=!&|]/.test(inner)) {
|
|
297
|
+
try {
|
|
298
|
+
return evaluateSimpleExpr(inner.trim(), context);
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Otherwise resolve as template value and check truthiness
|
|
304
|
+
resolved = resolveString(expr, context);
|
|
305
|
+
if (typeof resolved !== 'string') {
|
|
306
|
+
return Boolean(resolved);
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
// Mixed text + templates: resolve then evaluate
|
|
310
|
+
resolved = resolveString(expr, context);
|
|
311
|
+
if (typeof resolved !== 'string') {
|
|
312
|
+
return Boolean(resolved);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Try to evaluate as a simple expression
|
|
318
|
+
try {
|
|
319
|
+
return evaluateSimpleExpr(resolved.trim(), context);
|
|
320
|
+
} catch {
|
|
321
|
+
// If evaluation fails, treat as falsy
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Simple expression evaluator for conditions.
|
|
328
|
+
* Handles: path lookups, comparisons, &&, ||, !, literals.
|
|
329
|
+
*/
|
|
330
|
+
function evaluateSimpleExpr(expr, context) {
|
|
331
|
+
// Handle boolean operators (lowest precedence)
|
|
332
|
+
// Split on || first
|
|
333
|
+
const orParts = splitOutsideParens(expr, '||');
|
|
334
|
+
if (orParts.length > 1) {
|
|
335
|
+
return orParts.some(part => evaluateSimpleExpr(part.trim(), context));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Split on &&
|
|
339
|
+
const andParts = splitOutsideParens(expr, '&&');
|
|
340
|
+
if (andParts.length > 1) {
|
|
341
|
+
return andParts.every(part => evaluateSimpleExpr(part.trim(), context));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Handle negation
|
|
345
|
+
if (expr.startsWith('!') && !expr.startsWith('!=')) {
|
|
346
|
+
return !evaluateSimpleExpr(expr.slice(1).trim(), context);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Handle parentheses
|
|
350
|
+
if (expr.startsWith('(') && expr.endsWith(')')) {
|
|
351
|
+
return evaluateSimpleExpr(expr.slice(1, -1).trim(), context);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Handle comparisons
|
|
355
|
+
const compOps = ['===', '!==', '>=', '<=', '==', '!=', '>', '<'];
|
|
356
|
+
for (const op of compOps) {
|
|
357
|
+
const idx = expr.indexOf(op);
|
|
358
|
+
if (idx !== -1) {
|
|
359
|
+
const left = resolveValue(expr.slice(0, idx).trim(), context);
|
|
360
|
+
const right = resolveValue(expr.slice(idx + op.length).trim(), context);
|
|
361
|
+
switch (op) {
|
|
362
|
+
case '===': return left === right;
|
|
363
|
+
case '!==': return left !== right;
|
|
364
|
+
case '==': return left == right;
|
|
365
|
+
case '!=': return left != right;
|
|
366
|
+
case '>': return left > right;
|
|
367
|
+
case '<': return left < right;
|
|
368
|
+
case '>=': return left >= right;
|
|
369
|
+
case '<=': return left <= right;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// No operator: evaluate as truthy/falsy
|
|
375
|
+
return Boolean(resolveValue(expr, context));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Split a string by an operator, but not inside parentheses.
|
|
380
|
+
*/
|
|
381
|
+
function splitOutsideParens(str, op) {
|
|
382
|
+
const parts = [];
|
|
383
|
+
let depth = 0;
|
|
384
|
+
let current = '';
|
|
385
|
+
|
|
386
|
+
for (let i = 0; i < str.length; i++) {
|
|
387
|
+
if (str[i] === '(') depth++;
|
|
388
|
+
if (str[i] === ')') depth--;
|
|
389
|
+
|
|
390
|
+
if (depth === 0 && str.slice(i, i + op.length) === op) {
|
|
391
|
+
parts.push(current);
|
|
392
|
+
current = '';
|
|
393
|
+
i += op.length - 1;
|
|
394
|
+
} else {
|
|
395
|
+
current += str[i];
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
parts.push(current);
|
|
399
|
+
return parts;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Resolve a value from an expression fragment.
|
|
404
|
+
* Handles: number literals, string literals, true/false, null, undefined, path lookups.
|
|
405
|
+
*/
|
|
406
|
+
function resolveValue(expr, context) {
|
|
407
|
+
const trimmed = expr.trim();
|
|
408
|
+
|
|
409
|
+
// Number
|
|
410
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
|
411
|
+
return parseFloat(trimmed);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// String literal (single or double quotes)
|
|
415
|
+
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
|
416
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
|
|
417
|
+
return trimmed.slice(1, -1);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Boolean / null / undefined
|
|
421
|
+
if (trimmed === 'true') return true;
|
|
422
|
+
if (trimmed === 'false') return false;
|
|
423
|
+
if (trimmed === 'null') return null;
|
|
424
|
+
if (trimmed === 'undefined') return undefined;
|
|
425
|
+
|
|
426
|
+
// Path lookup (e.g., "check.output.results.length")
|
|
427
|
+
// Walk the context object
|
|
428
|
+
try {
|
|
429
|
+
const parts = trimmed.split('.');
|
|
430
|
+
let current = context;
|
|
431
|
+
for (const part of parts) {
|
|
432
|
+
// Handle array indexing
|
|
433
|
+
const bracketMatch = part.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]$/);
|
|
434
|
+
if (bracketMatch) {
|
|
435
|
+
current = current[bracketMatch[1]];
|
|
436
|
+
if (current == null) return undefined;
|
|
437
|
+
current = current[parseInt(bracketMatch[2], 10)];
|
|
438
|
+
} else {
|
|
439
|
+
if (current == null) return undefined;
|
|
440
|
+
current = current[part];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return current;
|
|
444
|
+
} catch {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ════════════════════════════════════════════════════════════════════
|
|
450
|
+
// Control Flow Executors
|
|
451
|
+
// ════════════════════════════════════════════════════════════════════
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Execute a merge step: concatenate arrays with optional dedup.
|
|
455
|
+
*
|
|
456
|
+
* @param {object} inputs - { arrays: any[][], dedup?: boolean, dedup_field?: string }
|
|
457
|
+
* @returns {{ results: any[], resultCount: number }}
|
|
458
|
+
*/
|
|
459
|
+
function executeMerge(inputs) {
|
|
460
|
+
const { arrays, dedup, dedup_field } = inputs;
|
|
461
|
+
|
|
462
|
+
if (!Array.isArray(arrays)) {
|
|
463
|
+
throw new Error('merge: "arrays" input must be an array of arrays');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
let merged = [];
|
|
467
|
+
for (const arr of arrays) {
|
|
468
|
+
if (Array.isArray(arr)) {
|
|
469
|
+
merged = merged.concat(arr);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (dedup && dedup_field) {
|
|
474
|
+
const seen = new Set();
|
|
475
|
+
merged = merged.filter(item => {
|
|
476
|
+
const key = item && typeof item === 'object' ? item[dedup_field] : item;
|
|
477
|
+
if (seen.has(key)) return false;
|
|
478
|
+
seen.add(key);
|
|
479
|
+
return true;
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return { results: merged, resultCount: merged.length };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Execute a filter step: filter array by condition.
|
|
488
|
+
*
|
|
489
|
+
* @param {object} inputs - { array: any[], condition: string }
|
|
490
|
+
* @param {object} context - workflow context for evaluating conditions
|
|
491
|
+
* @returns {{ results: any[], resultCount: number }}
|
|
492
|
+
*/
|
|
493
|
+
function executeFilter(inputs, context) {
|
|
494
|
+
const { array, condition } = inputs;
|
|
495
|
+
|
|
496
|
+
if (!Array.isArray(array)) {
|
|
497
|
+
throw new Error('filter: "array" input must be an array');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (!condition || typeof condition !== 'string') {
|
|
501
|
+
throw new Error('filter: "condition" must be a string expression');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const results = array.filter(item => {
|
|
505
|
+
// Make "item" available in the condition context
|
|
506
|
+
const itemContext = { ...context, item };
|
|
507
|
+
return evaluateCondition(condition, itemContext);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
return { results, resultCount: results.length };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Execute a transform step: map/reshape array items.
|
|
515
|
+
*
|
|
516
|
+
* @param {object} inputs - { array: any[], fields?: string[], mapping?: object }
|
|
517
|
+
* @returns {{ results: any[], resultCount: number }}
|
|
518
|
+
*/
|
|
519
|
+
function executeTransform(inputs) {
|
|
520
|
+
const { array, fields, mapping } = inputs;
|
|
521
|
+
|
|
522
|
+
if (!Array.isArray(array)) {
|
|
523
|
+
throw new Error('transform: "array" input must be an array');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
let results;
|
|
527
|
+
|
|
528
|
+
if (fields) {
|
|
529
|
+
// Pick specific fields
|
|
530
|
+
results = array.map(item => {
|
|
531
|
+
if (!item || typeof item !== 'object') return item;
|
|
532
|
+
const picked = {};
|
|
533
|
+
for (const field of fields) {
|
|
534
|
+
if (field in item) picked[field] = item[field];
|
|
535
|
+
}
|
|
536
|
+
return picked;
|
|
537
|
+
});
|
|
538
|
+
} else if (mapping) {
|
|
539
|
+
// Rename/reshape fields
|
|
540
|
+
results = array.map(item => {
|
|
541
|
+
if (!item || typeof item !== 'object') return item;
|
|
542
|
+
const mapped = {};
|
|
543
|
+
for (const [newKey, oldKey] of Object.entries(mapping)) {
|
|
544
|
+
mapped[newKey] = typeof oldKey === 'string' && oldKey in item ? item[oldKey] : oldKey;
|
|
545
|
+
}
|
|
546
|
+
return mapped;
|
|
547
|
+
});
|
|
548
|
+
} else {
|
|
549
|
+
results = array;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return { results, resultCount: results.length };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ════════════════════════════════════════════════════════════════════
|
|
556
|
+
// VAI Tool Executors
|
|
557
|
+
// ════════════════════════════════════════════════════════════════════
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Execute a vai query step (embed + vector search + optional rerank).
|
|
561
|
+
*/
|
|
562
|
+
async function executeQuery(inputs, defaults) {
|
|
563
|
+
const { generateEmbeddings, apiRequest } = require('./api');
|
|
564
|
+
const { getMongoCollection } = require('./mongo');
|
|
565
|
+
const { loadProject } = require('./project');
|
|
566
|
+
const { config: proj } = loadProject();
|
|
567
|
+
|
|
568
|
+
const db = inputs.db || defaults.db || proj.db;
|
|
569
|
+
const collection = inputs.collection || defaults.collection || proj.collection;
|
|
570
|
+
const model = inputs.model || defaults.model;
|
|
571
|
+
const query = inputs.query;
|
|
572
|
+
const limit = inputs.limit || 10;
|
|
573
|
+
const doRerank = inputs.rerank !== false;
|
|
574
|
+
|
|
575
|
+
if (!query) throw new Error('query: "query" input is required');
|
|
576
|
+
if (!db) throw new Error('query: database not specified (set in inputs, defaults, or vai config)');
|
|
577
|
+
if (!collection) throw new Error('query: collection not specified');
|
|
578
|
+
|
|
579
|
+
// Embed
|
|
580
|
+
const embOpts = { inputType: 'query' };
|
|
581
|
+
if (model) embOpts.model = model;
|
|
582
|
+
const embRes = await generateEmbeddings([query], embOpts);
|
|
583
|
+
const embedding = embRes.data[0].embedding;
|
|
584
|
+
|
|
585
|
+
// Vector search
|
|
586
|
+
const { client, collection: col } = await getMongoCollection(db, collection);
|
|
587
|
+
try {
|
|
588
|
+
const results = await col.aggregate([
|
|
589
|
+
{
|
|
590
|
+
$vectorSearch: {
|
|
591
|
+
index: 'vector_index',
|
|
592
|
+
path: 'embedding',
|
|
593
|
+
queryVector: embedding,
|
|
594
|
+
numCandidates: Math.min(limit * 10, 200),
|
|
595
|
+
limit,
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
$project: {
|
|
600
|
+
text: 1, content: 1, source: 1, metadata: 1,
|
|
601
|
+
score: { $meta: 'vectorSearchScore' },
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
]).toArray();
|
|
605
|
+
|
|
606
|
+
// Rerank if requested and results exist
|
|
607
|
+
if (doRerank && results.length > 0) {
|
|
608
|
+
const documents = results.map(r => r.text || r.content || '');
|
|
609
|
+
const { DEFAULT_RERANK_MODEL } = require('./catalog');
|
|
610
|
+
const rerankRes = await apiRequest('/rerank', {
|
|
611
|
+
model: inputs.rerankModel || DEFAULT_RERANK_MODEL,
|
|
612
|
+
query,
|
|
613
|
+
documents,
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const reranked = (rerankRes.data || []).map(r => ({
|
|
617
|
+
...results[r.index],
|
|
618
|
+
score: r.relevance_score,
|
|
619
|
+
}));
|
|
620
|
+
|
|
621
|
+
return { results: reranked, resultCount: reranked.length };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return { results, resultCount: results.length };
|
|
625
|
+
} finally {
|
|
626
|
+
await client.close();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Execute a vai search step (embed + vector search, no rerank).
|
|
632
|
+
*/
|
|
633
|
+
async function executeSearch(inputs, defaults) {
|
|
634
|
+
return executeQuery({ ...inputs, rerank: false }, defaults);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Execute a vai rerank step.
|
|
639
|
+
*/
|
|
640
|
+
async function executeRerank(inputs) {
|
|
641
|
+
const { apiRequest } = require('./api');
|
|
642
|
+
const { DEFAULT_RERANK_MODEL } = require('./catalog');
|
|
643
|
+
|
|
644
|
+
const query = inputs.query;
|
|
645
|
+
const documents = inputs.documents;
|
|
646
|
+
const model = inputs.model || DEFAULT_RERANK_MODEL;
|
|
647
|
+
|
|
648
|
+
if (!query) throw new Error('rerank: "query" input is required');
|
|
649
|
+
if (!Array.isArray(documents)) throw new Error('rerank: "documents" must be an array');
|
|
650
|
+
|
|
651
|
+
// If documents are objects, extract text
|
|
652
|
+
const docTexts = documents.map(d =>
|
|
653
|
+
typeof d === 'string' ? d : (d.text || d.content || JSON.stringify(d))
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
const res = await apiRequest('/rerank', { model, query, documents: docTexts });
|
|
657
|
+
|
|
658
|
+
const results = (res.data || []).map(r => ({
|
|
659
|
+
...(typeof documents[r.index] === 'object' ? documents[r.index] : { text: documents[r.index] }),
|
|
660
|
+
score: r.relevance_score,
|
|
661
|
+
}));
|
|
662
|
+
|
|
663
|
+
return { results, resultCount: results.length };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Execute a vai embed step.
|
|
668
|
+
*/
|
|
669
|
+
async function executeEmbed(inputs, defaults) {
|
|
670
|
+
const { generateEmbeddings } = require('./api');
|
|
671
|
+
|
|
672
|
+
const text = inputs.text;
|
|
673
|
+
const model = inputs.model || defaults.model;
|
|
674
|
+
const inputType = inputs.inputType || 'query';
|
|
675
|
+
|
|
676
|
+
if (!text) throw new Error('embed: "text" input is required');
|
|
677
|
+
|
|
678
|
+
const opts = { inputType };
|
|
679
|
+
if (model) opts.model = model;
|
|
680
|
+
if (inputs.dimensions) opts.dimensions = inputs.dimensions;
|
|
681
|
+
|
|
682
|
+
const res = await generateEmbeddings([text], opts);
|
|
683
|
+
return {
|
|
684
|
+
embedding: res.data[0].embedding,
|
|
685
|
+
model: res.model,
|
|
686
|
+
dimensions: res.data[0].embedding.length,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Execute a vai similarity step.
|
|
692
|
+
*/
|
|
693
|
+
async function executeSimilarity(inputs, defaults) {
|
|
694
|
+
const { generateEmbeddings } = require('./api');
|
|
695
|
+
const { cosineSimilarity } = require('./math');
|
|
696
|
+
|
|
697
|
+
const { text1, text2 } = inputs;
|
|
698
|
+
const model = inputs.model || defaults.model;
|
|
699
|
+
|
|
700
|
+
if (!text1 || !text2) throw new Error('similarity: "text1" and "text2" are required');
|
|
701
|
+
|
|
702
|
+
const opts = { inputType: 'document' };
|
|
703
|
+
if (model) opts.model = model;
|
|
704
|
+
|
|
705
|
+
const res = await generateEmbeddings([text1, text2], opts);
|
|
706
|
+
const similarity = cosineSimilarity(res.data[0].embedding, res.data[1].embedding);
|
|
707
|
+
|
|
708
|
+
return { similarity, model: res.model };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Execute a vai ingest step.
|
|
713
|
+
*/
|
|
714
|
+
async function executeIngest(inputs, defaults) {
|
|
715
|
+
const { generateEmbeddings } = require('./api');
|
|
716
|
+
const { getMongoCollection } = require('./mongo');
|
|
717
|
+
const { chunk } = require('./chunker');
|
|
718
|
+
const { loadProject } = require('./project');
|
|
719
|
+
const { config: proj } = loadProject();
|
|
720
|
+
|
|
721
|
+
const db = inputs.db || defaults.db || proj.db;
|
|
722
|
+
const collection = inputs.collection || defaults.collection || proj.collection;
|
|
723
|
+
const text = inputs.text;
|
|
724
|
+
const source = inputs.source || 'workflow-ingest';
|
|
725
|
+
const model = inputs.model || defaults.model;
|
|
726
|
+
|
|
727
|
+
if (!text) throw new Error('ingest: "text" input is required');
|
|
728
|
+
if (!db) throw new Error('ingest: database not specified');
|
|
729
|
+
if (!collection) throw new Error('ingest: collection not specified');
|
|
730
|
+
|
|
731
|
+
// Chunk text
|
|
732
|
+
const chunks = chunk(text, {
|
|
733
|
+
strategy: inputs.chunkStrategy || 'recursive',
|
|
734
|
+
size: inputs.chunkSize || 512,
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// Embed chunks
|
|
738
|
+
const embOpts = { inputType: 'document' };
|
|
739
|
+
if (model) embOpts.model = model;
|
|
740
|
+
const embRes = await generateEmbeddings(chunks, embOpts);
|
|
741
|
+
|
|
742
|
+
// Build docs
|
|
743
|
+
const docs = chunks.map((chunkText, i) => ({
|
|
744
|
+
text: chunkText,
|
|
745
|
+
source,
|
|
746
|
+
embedding: embRes.data[i].embedding,
|
|
747
|
+
metadata: inputs.metadata || {},
|
|
748
|
+
createdAt: new Date(),
|
|
749
|
+
}));
|
|
750
|
+
|
|
751
|
+
// Insert
|
|
752
|
+
const { client, collection: col } = await getMongoCollection(db, collection);
|
|
753
|
+
try {
|
|
754
|
+
const result = await col.insertMany(docs);
|
|
755
|
+
return {
|
|
756
|
+
insertedCount: result.insertedCount,
|
|
757
|
+
chunks: chunks.length,
|
|
758
|
+
source,
|
|
759
|
+
model: embRes.model,
|
|
760
|
+
};
|
|
761
|
+
} finally {
|
|
762
|
+
await client.close();
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Execute a vai collections step.
|
|
768
|
+
*/
|
|
769
|
+
async function executeCollections(inputs, defaults) {
|
|
770
|
+
const { introspectCollections } = require('./workflow-utils');
|
|
771
|
+
const { loadProject } = require('./project');
|
|
772
|
+
const { config: proj } = loadProject();
|
|
773
|
+
|
|
774
|
+
const db = inputs.db || defaults.db || proj.db;
|
|
775
|
+
if (!db) throw new Error('collections: database not specified');
|
|
776
|
+
|
|
777
|
+
const collections = await introspectCollections(db);
|
|
778
|
+
return { collections, database: db };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Execute a vai models step.
|
|
783
|
+
*/
|
|
784
|
+
function executeModels(inputs) {
|
|
785
|
+
const { MODEL_CATALOG } = require('./catalog');
|
|
786
|
+
|
|
787
|
+
let models = MODEL_CATALOG.filter(m => !m.legacy && !m.unreleased);
|
|
788
|
+
const category = inputs.category || 'all';
|
|
789
|
+
|
|
790
|
+
if (category !== 'all') {
|
|
791
|
+
models = models.filter(m => m.type === category);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return {
|
|
795
|
+
models: models.map(m => ({
|
|
796
|
+
name: m.name,
|
|
797
|
+
type: m.type,
|
|
798
|
+
dimensions: m.dimensions,
|
|
799
|
+
price: m.price,
|
|
800
|
+
bestFor: m.bestFor,
|
|
801
|
+
})),
|
|
802
|
+
category,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Execute a vai explain step.
|
|
808
|
+
*/
|
|
809
|
+
function executeExplain(inputs) {
|
|
810
|
+
const { resolveConcept, getConcept } = require('./explanations');
|
|
811
|
+
|
|
812
|
+
const topic = inputs.topic;
|
|
813
|
+
if (!topic) throw new Error('explain: "topic" input is required');
|
|
814
|
+
|
|
815
|
+
const conceptKey = resolveConcept(topic);
|
|
816
|
+
if (!conceptKey) {
|
|
817
|
+
return { found: false, topic, text: `No explanation found for "${topic}"` };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const concept = getConcept(conceptKey);
|
|
821
|
+
return {
|
|
822
|
+
found: true,
|
|
823
|
+
topic: concept.title,
|
|
824
|
+
text: concept.content,
|
|
825
|
+
links: concept.links || [],
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Execute a vai estimate step.
|
|
831
|
+
*/
|
|
832
|
+
function executeEstimate(inputs) {
|
|
833
|
+
const { MODEL_CATALOG } = require('./catalog');
|
|
834
|
+
|
|
835
|
+
const docs = inputs.docs || 1000;
|
|
836
|
+
const queries = inputs.queries || 0;
|
|
837
|
+
const months = inputs.months || 12;
|
|
838
|
+
const model = inputs.model || 'voyage-4-large';
|
|
839
|
+
|
|
840
|
+
const modelInfo = MODEL_CATALOG.find(m => m.name === model);
|
|
841
|
+
if (!modelInfo) {
|
|
842
|
+
return { error: `Unknown model: ${model}`, model };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const pricePerMToken = modelInfo.pricePerMToken || 0;
|
|
846
|
+
// Rough estimate: avg 500 tokens per document chunk
|
|
847
|
+
const avgTokensPerDoc = 500;
|
|
848
|
+
const embeddingCost = (docs * avgTokensPerDoc / 1_000_000) * pricePerMToken;
|
|
849
|
+
|
|
850
|
+
// Query cost (embedding queries)
|
|
851
|
+
const avgQueryTokens = 50;
|
|
852
|
+
const monthlyCost = (queries * avgQueryTokens / 1_000_000) * pricePerMToken;
|
|
853
|
+
const totalQueryCost = monthlyCost * months;
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
model,
|
|
857
|
+
docs,
|
|
858
|
+
embeddingCost: Math.round(embeddingCost * 10000) / 10000,
|
|
859
|
+
queriesPerMonth: queries,
|
|
860
|
+
monthlyQueryCost: Math.round(monthlyCost * 10000) / 10000,
|
|
861
|
+
totalCost: Math.round((embeddingCost + totalQueryCost) * 10000) / 10000,
|
|
862
|
+
months,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Execute a generate step (LLM call).
|
|
868
|
+
*/
|
|
869
|
+
async function executeGenerate(inputs) {
|
|
870
|
+
const { createLLMProvider } = require('./llm');
|
|
871
|
+
|
|
872
|
+
const provider = createLLMProvider();
|
|
873
|
+
if (!provider) {
|
|
874
|
+
throw new Error(
|
|
875
|
+
'generate: No LLM provider configured.\n' +
|
|
876
|
+
'Set up with: vai config set llm-provider anthropic\n' +
|
|
877
|
+
' vai config set llm-api-key YOUR_KEY'
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const prompt = inputs.prompt;
|
|
882
|
+
if (!prompt) throw new Error('generate: "prompt" input is required');
|
|
883
|
+
|
|
884
|
+
const messages = [];
|
|
885
|
+
|
|
886
|
+
if (inputs.systemPrompt) {
|
|
887
|
+
messages.push({ role: 'system', content: inputs.systemPrompt });
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Build user message with context if provided
|
|
891
|
+
let userContent = prompt;
|
|
892
|
+
if (inputs.context) {
|
|
893
|
+
const contextStr = Array.isArray(inputs.context)
|
|
894
|
+
? inputs.context.map(item =>
|
|
895
|
+
typeof item === 'string' ? item : (item.text || item.content || JSON.stringify(item))
|
|
896
|
+
).join('\n\n---\n\n')
|
|
897
|
+
: String(inputs.context);
|
|
898
|
+
userContent = `${prompt}\n\nContext:\n${contextStr}`;
|
|
899
|
+
}
|
|
900
|
+
messages.push({ role: 'user', content: userContent });
|
|
901
|
+
|
|
902
|
+
// Collect streaming response
|
|
903
|
+
let text = '';
|
|
904
|
+
for await (const chunk of provider.chat(messages, { stream: true })) {
|
|
905
|
+
text += chunk;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
return {
|
|
909
|
+
text,
|
|
910
|
+
model: provider.model,
|
|
911
|
+
provider: provider.name,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// ════════════════════════════════════════════════════════════════════
|
|
916
|
+
// Step Dispatcher
|
|
917
|
+
// ════════════════════════════════════════════════════════════════════
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Execute a single step with resolved inputs.
|
|
921
|
+
*
|
|
922
|
+
* @param {object} step - The step definition
|
|
923
|
+
* @param {object} resolvedInputs - Inputs with templates already resolved
|
|
924
|
+
* @param {object} defaults - Workflow defaults
|
|
925
|
+
* @param {object} context - Full workflow context
|
|
926
|
+
* @returns {Promise<object>} Step output
|
|
927
|
+
*/
|
|
928
|
+
async function executeStep(step, resolvedInputs, defaults, context) {
|
|
929
|
+
switch (step.tool) {
|
|
930
|
+
// Control flow
|
|
931
|
+
case 'merge':
|
|
932
|
+
return executeMerge(resolvedInputs);
|
|
933
|
+
case 'filter':
|
|
934
|
+
return executeFilter(resolvedInputs, context);
|
|
935
|
+
case 'transform':
|
|
936
|
+
return executeTransform(resolvedInputs);
|
|
937
|
+
case 'generate':
|
|
938
|
+
return executeGenerate(resolvedInputs);
|
|
939
|
+
|
|
940
|
+
// VAI tools
|
|
941
|
+
case 'query':
|
|
942
|
+
return executeQuery(resolvedInputs, defaults);
|
|
943
|
+
case 'search':
|
|
944
|
+
return executeSearch(resolvedInputs, defaults);
|
|
945
|
+
case 'rerank':
|
|
946
|
+
return executeRerank(resolvedInputs);
|
|
947
|
+
case 'embed':
|
|
948
|
+
return executeEmbed(resolvedInputs, defaults);
|
|
949
|
+
case 'similarity':
|
|
950
|
+
return executeSimilarity(resolvedInputs, defaults);
|
|
951
|
+
case 'ingest':
|
|
952
|
+
return executeIngest(resolvedInputs, defaults);
|
|
953
|
+
case 'collections':
|
|
954
|
+
return executeCollections(resolvedInputs, defaults);
|
|
955
|
+
case 'models':
|
|
956
|
+
return executeModels(resolvedInputs);
|
|
957
|
+
case 'explain':
|
|
958
|
+
return executeExplain(resolvedInputs);
|
|
959
|
+
case 'estimate':
|
|
960
|
+
return executeEstimate(resolvedInputs);
|
|
961
|
+
|
|
962
|
+
default:
|
|
963
|
+
throw new Error(`Unknown tool: "${step.tool}"`);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// ════════════════════════════════════════════════════════════════════
|
|
968
|
+
// Main Execution Loop
|
|
969
|
+
// ════════════════════════════════════════════════════════════════════
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Execute a workflow definition.
|
|
973
|
+
*
|
|
974
|
+
* @param {object} definition - Parsed workflow JSON
|
|
975
|
+
* @param {object} opts
|
|
976
|
+
* @param {object} [opts.inputs] - Workflow input values
|
|
977
|
+
* @param {string} [opts.db] - Database override
|
|
978
|
+
* @param {string} [opts.collection] - Collection override
|
|
979
|
+
* @param {boolean} [opts.dryRun] - Show plan without executing
|
|
980
|
+
* @param {boolean} [opts.verbose] - Show step details
|
|
981
|
+
* @param {boolean} [opts.json] - Return JSON output
|
|
982
|
+
* @param {Function} [opts.onStepStart] - Callback(stepId, stepDef)
|
|
983
|
+
* @param {Function} [opts.onStepComplete] - Callback(stepId, output, durationMs)
|
|
984
|
+
* @param {Function} [opts.onStepSkip] - Callback(stepId, reason)
|
|
985
|
+
* @param {Function} [opts.onStepError] - Callback(stepId, error)
|
|
986
|
+
* @returns {Promise<{ output: object, steps: Array, totalTimeMs: number, layers: string[][] }>}
|
|
987
|
+
*/
|
|
988
|
+
async function executeWorkflow(definition, opts = {}) {
|
|
989
|
+
const startTime = Date.now();
|
|
990
|
+
|
|
991
|
+
// Validate
|
|
992
|
+
const errors = validateWorkflow(definition);
|
|
993
|
+
if (errors.length > 0) {
|
|
994
|
+
throw new Error(`Workflow validation failed:\n ${errors.join('\n ')}`);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Validate required inputs
|
|
998
|
+
const inputValues = opts.inputs || {};
|
|
999
|
+
if (definition.inputs) {
|
|
1000
|
+
for (const [key, schema] of Object.entries(definition.inputs)) {
|
|
1001
|
+
if (schema.required && !(key in inputValues) && !('default' in schema)) {
|
|
1002
|
+
throw new Error(`Missing required input: "${key}"`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Build effective inputs (fill defaults)
|
|
1008
|
+
const effectiveInputs = {};
|
|
1009
|
+
if (definition.inputs) {
|
|
1010
|
+
for (const [key, schema] of Object.entries(definition.inputs)) {
|
|
1011
|
+
if (key in inputValues) {
|
|
1012
|
+
effectiveInputs[key] = coerceInput(inputValues[key], schema.type);
|
|
1013
|
+
} else if ('default' in schema) {
|
|
1014
|
+
effectiveInputs[key] = schema.default;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// Also pass through any extra inputs not in schema
|
|
1019
|
+
for (const [key, val] of Object.entries(inputValues)) {
|
|
1020
|
+
if (!(key in effectiveInputs)) {
|
|
1021
|
+
effectiveInputs[key] = val;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Build defaults with CLI overrides
|
|
1026
|
+
const defaults = {
|
|
1027
|
+
...(definition.defaults || {}),
|
|
1028
|
+
...(opts.db && { db: opts.db }),
|
|
1029
|
+
...(opts.collection && { collection: opts.collection }),
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
// Build execution plan
|
|
1033
|
+
const layers = buildExecutionPlan(definition.steps);
|
|
1034
|
+
const stepMap = new Map(definition.steps.map(s => [s.id, s]));
|
|
1035
|
+
|
|
1036
|
+
// Dry run: return plan without executing
|
|
1037
|
+
if (opts.dryRun) {
|
|
1038
|
+
return {
|
|
1039
|
+
output: null,
|
|
1040
|
+
steps: [],
|
|
1041
|
+
totalTimeMs: 0,
|
|
1042
|
+
layers,
|
|
1043
|
+
inputs: effectiveInputs,
|
|
1044
|
+
defaults,
|
|
1045
|
+
dryRun: true,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Initialize context
|
|
1050
|
+
const context = {
|
|
1051
|
+
inputs: effectiveInputs,
|
|
1052
|
+
defaults,
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
// Execute layer by layer
|
|
1056
|
+
const stepResults = [];
|
|
1057
|
+
|
|
1058
|
+
for (const layer of layers) {
|
|
1059
|
+
const layerPromises = layer.map(async (stepId) => {
|
|
1060
|
+
const step = stepMap.get(stepId);
|
|
1061
|
+
const stepStart = Date.now();
|
|
1062
|
+
|
|
1063
|
+
// Evaluate condition
|
|
1064
|
+
if (step.condition) {
|
|
1065
|
+
const conditionMet = evaluateCondition(step.condition, context);
|
|
1066
|
+
if (!conditionMet) {
|
|
1067
|
+
if (opts.onStepSkip) opts.onStepSkip(stepId, 'condition not met');
|
|
1068
|
+
context[stepId] = { output: null, skipped: true };
|
|
1069
|
+
stepResults.push({
|
|
1070
|
+
id: stepId,
|
|
1071
|
+
tool: step.tool,
|
|
1072
|
+
skipped: true,
|
|
1073
|
+
durationMs: Date.now() - stepStart,
|
|
1074
|
+
});
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (opts.onStepStart) opts.onStepStart(stepId, step);
|
|
1080
|
+
|
|
1081
|
+
try {
|
|
1082
|
+
let output;
|
|
1083
|
+
|
|
1084
|
+
if (step.forEach) {
|
|
1085
|
+
// Iterate over an array
|
|
1086
|
+
const iterArray = resolveTemplate(step.forEach, context);
|
|
1087
|
+
if (!Array.isArray(iterArray)) {
|
|
1088
|
+
throw new Error(`forEach in step "${stepId}" did not resolve to an array`);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const iterResults = [];
|
|
1092
|
+
for (let i = 0; i < iterArray.length; i++) {
|
|
1093
|
+
const iterContext = { ...context, item: iterArray[i], index: i };
|
|
1094
|
+
const resolvedInputs = resolveTemplate(step.inputs || {}, iterContext);
|
|
1095
|
+
const iterOutput = await executeStep(step, resolvedInputs, defaults, iterContext);
|
|
1096
|
+
iterResults.push(iterOutput);
|
|
1097
|
+
}
|
|
1098
|
+
output = { results: iterResults, count: iterResults.length };
|
|
1099
|
+
} else {
|
|
1100
|
+
// Normal execution
|
|
1101
|
+
const resolvedInputs = resolveTemplate(step.inputs || {}, context);
|
|
1102
|
+
output = await executeStep(step, resolvedInputs, defaults, context);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const durationMs = Date.now() - stepStart;
|
|
1106
|
+
context[stepId] = { output };
|
|
1107
|
+
|
|
1108
|
+
if (opts.onStepComplete) opts.onStepComplete(stepId, output, durationMs);
|
|
1109
|
+
|
|
1110
|
+
stepResults.push({
|
|
1111
|
+
id: stepId,
|
|
1112
|
+
tool: step.tool,
|
|
1113
|
+
output,
|
|
1114
|
+
durationMs,
|
|
1115
|
+
});
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
const durationMs = Date.now() - stepStart;
|
|
1118
|
+
|
|
1119
|
+
if (step.continueOnError) {
|
|
1120
|
+
context[stepId] = { output: null, error: err.message };
|
|
1121
|
+
if (opts.onStepError) opts.onStepError(stepId, err);
|
|
1122
|
+
stepResults.push({
|
|
1123
|
+
id: stepId,
|
|
1124
|
+
tool: step.tool,
|
|
1125
|
+
error: err.message,
|
|
1126
|
+
durationMs,
|
|
1127
|
+
});
|
|
1128
|
+
} else {
|
|
1129
|
+
if (opts.onStepError) opts.onStepError(stepId, err);
|
|
1130
|
+
throw new Error(`Step "${stepId}" failed: ${err.message}`);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
await Promise.all(layerPromises);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Resolve output templates
|
|
1139
|
+
const output = definition.output
|
|
1140
|
+
? resolveTemplate(definition.output, context)
|
|
1141
|
+
: context;
|
|
1142
|
+
|
|
1143
|
+
return {
|
|
1144
|
+
output,
|
|
1145
|
+
steps: stepResults,
|
|
1146
|
+
totalTimeMs: Date.now() - startTime,
|
|
1147
|
+
layers,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Coerce a string input value to the expected type.
|
|
1153
|
+
*/
|
|
1154
|
+
function coerceInput(value, type) {
|
|
1155
|
+
if (type === 'number' && typeof value === 'string') {
|
|
1156
|
+
const num = parseFloat(value);
|
|
1157
|
+
return isNaN(num) ? value : num;
|
|
1158
|
+
}
|
|
1159
|
+
if (type === 'boolean' && typeof value === 'string') {
|
|
1160
|
+
return value === 'true' || value === '1';
|
|
1161
|
+
}
|
|
1162
|
+
return value;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1166
|
+
// Built-in Templates
|
|
1167
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Get the path to the built-in workflows directory.
|
|
1171
|
+
*/
|
|
1172
|
+
function getWorkflowsDir() {
|
|
1173
|
+
return path.join(__dirname, '..', 'workflows');
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* List built-in workflow templates.
|
|
1178
|
+
* @returns {Array<{ name: string, description: string, file: string }>}
|
|
1179
|
+
*/
|
|
1180
|
+
function listBuiltinWorkflows() {
|
|
1181
|
+
const dir = getWorkflowsDir();
|
|
1182
|
+
if (!fs.existsSync(dir)) return [];
|
|
1183
|
+
|
|
1184
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
1185
|
+
return files.map(f => {
|
|
1186
|
+
try {
|
|
1187
|
+
const def = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
1188
|
+
return {
|
|
1189
|
+
name: f.replace('.json', ''),
|
|
1190
|
+
description: def.description || def.name || f,
|
|
1191
|
+
file: f,
|
|
1192
|
+
};
|
|
1193
|
+
} catch {
|
|
1194
|
+
return { name: f.replace('.json', ''), description: '(error reading)', file: f };
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Load a workflow definition from a file path or built-in template name.
|
|
1201
|
+
*
|
|
1202
|
+
* @param {string} nameOrPath - File path or template name (e.g., "multi-collection-search")
|
|
1203
|
+
* @returns {object} Parsed workflow definition
|
|
1204
|
+
*/
|
|
1205
|
+
function loadWorkflow(nameOrPath) {
|
|
1206
|
+
// Try as a direct file path
|
|
1207
|
+
if (fs.existsSync(nameOrPath)) {
|
|
1208
|
+
const content = fs.readFileSync(nameOrPath, 'utf8');
|
|
1209
|
+
return JSON.parse(content);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Try as a built-in template name
|
|
1213
|
+
const builtinPath = path.join(getWorkflowsDir(), `${nameOrPath}.json`);
|
|
1214
|
+
if (fs.existsSync(builtinPath)) {
|
|
1215
|
+
const content = fs.readFileSync(builtinPath, 'utf8');
|
|
1216
|
+
return JSON.parse(content);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Try with .json extension appended
|
|
1220
|
+
const withJson = nameOrPath.endsWith('.json') ? nameOrPath : `${nameOrPath}.json`;
|
|
1221
|
+
if (fs.existsSync(withJson)) {
|
|
1222
|
+
const content = fs.readFileSync(withJson, 'utf8');
|
|
1223
|
+
return JSON.parse(content);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
throw new Error(`Workflow not found: "${nameOrPath}"\nProvide a file path or built-in template name (see: vai workflow list)`);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
module.exports = {
|
|
1230
|
+
// Validation
|
|
1231
|
+
validateWorkflow,
|
|
1232
|
+
detectCycles,
|
|
1233
|
+
|
|
1234
|
+
// Dependency resolution
|
|
1235
|
+
buildDependencyGraph,
|
|
1236
|
+
buildExecutionPlan,
|
|
1237
|
+
|
|
1238
|
+
// Condition evaluation
|
|
1239
|
+
evaluateCondition,
|
|
1240
|
+
|
|
1241
|
+
// Control flow
|
|
1242
|
+
executeMerge,
|
|
1243
|
+
executeFilter,
|
|
1244
|
+
executeTransform,
|
|
1245
|
+
|
|
1246
|
+
// Main execution
|
|
1247
|
+
executeStep,
|
|
1248
|
+
executeWorkflow,
|
|
1249
|
+
|
|
1250
|
+
// Templates
|
|
1251
|
+
listBuiltinWorkflows,
|
|
1252
|
+
loadWorkflow,
|
|
1253
|
+
getWorkflowsDir,
|
|
1254
|
+
|
|
1255
|
+
// Constants
|
|
1256
|
+
VAI_TOOLS,
|
|
1257
|
+
CONTROL_FLOW_TOOLS,
|
|
1258
|
+
ALL_TOOLS,
|
|
1259
|
+
};
|