voyageai-cli 1.28.0 → 1.29.0
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 +2 -1
- package/src/commands/app.js +15 -0
- package/src/commands/playground.js +638 -7
- package/src/commands/workflow.js +417 -14
- package/src/lib/explanations.js +88 -0
- package/src/lib/npm-utils.js +265 -0
- package/src/lib/workflow-registry.js +416 -0
- package/src/lib/workflow-scaffold.js +319 -0
- package/src/lib/workflow.js +433 -7
- package/src/playground/announcements.md +71 -0
- package/src/playground/icons/V.png +0 -0
- package/src/playground/index.html +2204 -94
- package/src/workflows/consistency-check.json +4 -0
- package/src/workflows/cost-analysis.json +4 -0
- package/src/workflows/enrich-and-ingest.json +56 -0
- package/src/workflows/intelligent-ingest.json +66 -0
- package/src/workflows/kb-health-report.json +45 -0
- package/src/workflows/multi-collection-search.json +4 -0
- package/src/workflows/research-and-summarize.json +4 -0
- package/src/workflows/search-with-fallback.json +66 -0
- package/src/workflows/smart-ingest.json +4 -0
package/src/lib/workflow.js
CHANGED
|
@@ -18,9 +18,13 @@ const VAI_TOOLS = new Set([
|
|
|
18
18
|
'ingest', 'collections', 'models', 'explain', 'estimate',
|
|
19
19
|
]);
|
|
20
20
|
|
|
21
|
-
const CONTROL_FLOW_TOOLS = new Set(['merge', 'filter', 'transform', 'generate']);
|
|
21
|
+
const CONTROL_FLOW_TOOLS = new Set(['merge', 'filter', 'transform', 'generate', 'conditional', 'loop', 'template']);
|
|
22
22
|
|
|
23
|
-
const
|
|
23
|
+
const PROCESSING_TOOLS = new Set(['chunk', 'aggregate']);
|
|
24
|
+
|
|
25
|
+
const INTEGRATION_TOOLS = new Set(['http']);
|
|
26
|
+
|
|
27
|
+
const ALL_TOOLS = new Set([...VAI_TOOLS, ...CONTROL_FLOW_TOOLS, ...PROCESSING_TOOLS, ...INTEGRATION_TOOLS]);
|
|
24
28
|
|
|
25
29
|
// ════════════════════════════════════════════════════════════════════
|
|
26
30
|
// Validation
|
|
@@ -52,8 +56,8 @@ function validateWorkflow(definition) {
|
|
|
52
56
|
// Validate inputs schema
|
|
53
57
|
if (definition.inputs) {
|
|
54
58
|
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
|
|
59
|
+
if (schema.type && !['string', 'number', 'boolean', 'array'].includes(schema.type)) {
|
|
60
|
+
errors.push(`Input "${key}" has invalid type "${schema.type}" (must be string, number, boolean, or array)`);
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
}
|
|
@@ -94,10 +98,22 @@ function validateWorkflow(definition) {
|
|
|
94
98
|
// Check template references point to known step IDs or reserved prefixes
|
|
95
99
|
// "item" and "index" are injected by forEach at runtime
|
|
96
100
|
const forEachVars = step.forEach ? new Set(['item', 'index']) : new Set();
|
|
101
|
+
// For loop nodes, the "as" variable and inline step refs are scoped
|
|
102
|
+
const loopVars = new Set();
|
|
103
|
+
if (step.tool === 'loop' && step.inputs) {
|
|
104
|
+
if (step.inputs.as) loopVars.add(step.inputs.as);
|
|
105
|
+
loopVars.add('item');
|
|
106
|
+
loopVars.add('index');
|
|
107
|
+
}
|
|
97
108
|
if (step.inputs) {
|
|
98
|
-
|
|
109
|
+
// For loop nodes, only check dependencies on top-level inputs (items, as, maxIterations)
|
|
110
|
+
// not on the inline step's inputs which may reference the loop variable
|
|
111
|
+
const inputsToCheck = step.tool === 'loop'
|
|
112
|
+
? { items: step.inputs.items }
|
|
113
|
+
: step.inputs;
|
|
114
|
+
const deps = extractDependencies(inputsToCheck);
|
|
99
115
|
for (const dep of deps) {
|
|
100
|
-
if (!forEachVars.has(dep) && !stepIds.has(dep) && !definition.steps.some(s => s.id === dep)) {
|
|
116
|
+
if (!forEachVars.has(dep) && !loopVars.has(dep) && !stepIds.has(dep) && !definition.steps.some(s => s.id === dep)) {
|
|
101
117
|
errors.push(`${stepPrefix}: references unknown step "${dep}"`);
|
|
102
118
|
}
|
|
103
119
|
}
|
|
@@ -119,6 +135,44 @@ function validateWorkflow(definition) {
|
|
|
119
135
|
errors.push(`Duplicate step id: "${id}"`);
|
|
120
136
|
}
|
|
121
137
|
|
|
138
|
+
// Validate conditional branch references
|
|
139
|
+
for (const step of definition.steps) {
|
|
140
|
+
if (step.tool === 'conditional' && step.inputs) {
|
|
141
|
+
const branches = ['then', 'else'];
|
|
142
|
+
for (const branch of branches) {
|
|
143
|
+
const refs = step.inputs[branch];
|
|
144
|
+
if (refs && Array.isArray(refs)) {
|
|
145
|
+
for (const ref of refs) {
|
|
146
|
+
if (!stepIds.has(ref)) {
|
|
147
|
+
errors.push(`Step "${step.id}": conditional ${branch} references unknown step "${ref}"`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!step.inputs.condition) {
|
|
153
|
+
errors.push(`Step "${step.id}": conditional must have a "condition" input`);
|
|
154
|
+
}
|
|
155
|
+
if (!step.inputs.then || !Array.isArray(step.inputs.then)) {
|
|
156
|
+
errors.push(`Step "${step.id}": conditional must have a "then" array`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Validate loop inline step
|
|
161
|
+
if (step.tool === 'loop' && step.inputs) {
|
|
162
|
+
if (!step.inputs.items) {
|
|
163
|
+
errors.push(`Step "${step.id}": loop must have an "items" input`);
|
|
164
|
+
}
|
|
165
|
+
if (!step.inputs.as || typeof step.inputs.as !== 'string') {
|
|
166
|
+
errors.push(`Step "${step.id}": loop must have a string "as" input`);
|
|
167
|
+
}
|
|
168
|
+
if (!step.inputs.step || typeof step.inputs.step !== 'object') {
|
|
169
|
+
errors.push(`Step "${step.id}": loop must have a "step" object`);
|
|
170
|
+
} else if (step.inputs.step.tool && !ALL_TOOLS.has(step.inputs.step.tool)) {
|
|
171
|
+
errors.push(`Step "${step.id}": loop sub-step has unknown tool "${step.inputs.step.tool}"`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
122
176
|
// Check for circular dependencies
|
|
123
177
|
const cycleErrors = detectCycles(definition.steps);
|
|
124
178
|
errors.push(...cycleErrors);
|
|
@@ -197,6 +251,18 @@ function detectCycles(steps) {
|
|
|
197
251
|
function buildDependencyGraph(steps) {
|
|
198
252
|
const graph = new Map();
|
|
199
253
|
|
|
254
|
+
// First pass: build index of conditional branches
|
|
255
|
+
// Steps referenced in then/else of a conditional depend on that conditional
|
|
256
|
+
const conditionalDeps = new Map(); // stepId -> conditionalStepId
|
|
257
|
+
for (const step of steps) {
|
|
258
|
+
if (step.tool === 'conditional' && step.inputs) {
|
|
259
|
+
const branches = [...(step.inputs.then || []), ...(step.inputs.else || [])];
|
|
260
|
+
for (const ref of branches) {
|
|
261
|
+
conditionalDeps.set(ref, step.id);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
200
266
|
for (const step of steps) {
|
|
201
267
|
const deps = extractDependencies(step.inputs || {});
|
|
202
268
|
if (step.condition) {
|
|
@@ -207,6 +273,10 @@ function buildDependencyGraph(steps) {
|
|
|
207
273
|
const forDeps = extractDependencies(step.forEach);
|
|
208
274
|
for (const d of forDeps) deps.add(d);
|
|
209
275
|
}
|
|
276
|
+
// If this step is referenced by a conditional, it depends on that conditional
|
|
277
|
+
if (conditionalDeps.has(step.id)) {
|
|
278
|
+
deps.add(conditionalDeps.get(step.id));
|
|
279
|
+
}
|
|
210
280
|
graph.set(step.id, deps);
|
|
211
281
|
}
|
|
212
282
|
|
|
@@ -554,6 +624,301 @@ function executeTransform(inputs) {
|
|
|
554
624
|
return { results, resultCount: results.length };
|
|
555
625
|
}
|
|
556
626
|
|
|
627
|
+
/**
|
|
628
|
+
* Execute a conditional step: evaluate condition and determine branch.
|
|
629
|
+
*
|
|
630
|
+
* NOTE: The actual branch enforcement (skipping steps) is handled by
|
|
631
|
+
* the main execution loop, not here. This just evaluates and returns
|
|
632
|
+
* which branch was taken.
|
|
633
|
+
*
|
|
634
|
+
* @param {object} inputs - { condition: string, then: string[], else?: string[] }
|
|
635
|
+
* @param {object} context - workflow context
|
|
636
|
+
* @returns {{ conditionResult: boolean, branchTaken: string, enabledSteps: string[], skippedSteps: string[] }}
|
|
637
|
+
*/
|
|
638
|
+
function executeConditional(inputs, context) {
|
|
639
|
+
const { condition } = inputs;
|
|
640
|
+
const thenSteps = inputs.then || [];
|
|
641
|
+
const elseSteps = inputs.else || [];
|
|
642
|
+
|
|
643
|
+
if (!condition && condition !== false && condition !== 0) {
|
|
644
|
+
throw new Error('conditional: "condition" input is required');
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Condition may already be resolved by template engine to a boolean
|
|
648
|
+
let result;
|
|
649
|
+
if (typeof condition === 'boolean') {
|
|
650
|
+
result = condition;
|
|
651
|
+
} else if (typeof condition === 'string') {
|
|
652
|
+
result = evaluateCondition(condition, context);
|
|
653
|
+
} else {
|
|
654
|
+
result = Boolean(condition);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const taken = result ? 'then' : 'else';
|
|
658
|
+
const enabled = result ? thenSteps : elseSteps;
|
|
659
|
+
const skipped = result ? elseSteps : thenSteps;
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
conditionResult: result,
|
|
663
|
+
branchTaken: taken,
|
|
664
|
+
enabledSteps: enabled,
|
|
665
|
+
skippedSteps: skipped,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Execute a template step: compose text from template.
|
|
671
|
+
*
|
|
672
|
+
* @param {object} inputs - { text: string }
|
|
673
|
+
* @returns {{ text: string, charCount: number, referencedSteps: string[] }}
|
|
674
|
+
*/
|
|
675
|
+
function executeTemplate(inputs) {
|
|
676
|
+
const { text } = inputs;
|
|
677
|
+
|
|
678
|
+
if (text === undefined || text === null) {
|
|
679
|
+
throw new Error('template: "text" input is required');
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const textStr = String(text);
|
|
683
|
+
// Extract referenced step IDs from the original template (before resolution)
|
|
684
|
+
// Since inputs are already resolved by this point, we just return the composed text
|
|
685
|
+
return {
|
|
686
|
+
text: textStr,
|
|
687
|
+
charCount: textStr.length,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Execute a loop step: iterate over an array, executing a sub-step per item.
|
|
693
|
+
*
|
|
694
|
+
* @param {object} inputs - { items, as, step, maxIterations? }
|
|
695
|
+
* @param {object} defaults - workflow defaults
|
|
696
|
+
* @param {object} context - workflow context
|
|
697
|
+
* @returns {Promise<{ iterations: number, results: any[], errors: any[] }>}
|
|
698
|
+
*/
|
|
699
|
+
async function executeLoop(inputs, defaults, context) {
|
|
700
|
+
const { items, as, step: subStepDef, maxIterations = 100 } = inputs;
|
|
701
|
+
|
|
702
|
+
if (!Array.isArray(items)) {
|
|
703
|
+
throw new Error('loop: "items" must resolve to an array');
|
|
704
|
+
}
|
|
705
|
+
if (!as || typeof as !== 'string') {
|
|
706
|
+
throw new Error('loop: "as" must be a string variable name');
|
|
707
|
+
}
|
|
708
|
+
if (!subStepDef || typeof subStepDef !== 'object') {
|
|
709
|
+
throw new Error('loop: "step" must be a step definition object');
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const results = [];
|
|
713
|
+
const errors = [];
|
|
714
|
+
|
|
715
|
+
const limit = Math.min(items.length, maxIterations);
|
|
716
|
+
|
|
717
|
+
for (let i = 0; i < limit; i++) {
|
|
718
|
+
const item = items[i];
|
|
719
|
+
// Build scoped context with loop variable
|
|
720
|
+
const scopedContext = { ...context, [as]: item, _loopIndex: i };
|
|
721
|
+
|
|
722
|
+
try {
|
|
723
|
+
// Resolve the sub-step inputs in the scoped context
|
|
724
|
+
const resolvedInputs = resolveTemplate(subStepDef.inputs || {}, scopedContext);
|
|
725
|
+
// Create a temporary step object for the dispatcher
|
|
726
|
+
const tempStep = { id: `_loop_${i}`, tool: subStepDef.tool, inputs: subStepDef.inputs };
|
|
727
|
+
const output = await executeStep(tempStep, resolvedInputs, defaults, scopedContext);
|
|
728
|
+
results.push(output);
|
|
729
|
+
} catch (err) {
|
|
730
|
+
errors.push({ index: i, error: err.message });
|
|
731
|
+
// If the parent loop has continueOnError, we keep going (handled by caller)
|
|
732
|
+
// For now, loop always continues and collects errors
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (items.length > maxIterations) {
|
|
737
|
+
errors.push({ index: maxIterations, error: `Loop truncated at maxIterations (${maxIterations})` });
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
iterations: results.length,
|
|
742
|
+
results,
|
|
743
|
+
errors,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Execute a chunk step: split text using vai's chunking strategies.
|
|
749
|
+
*
|
|
750
|
+
* @param {object} inputs - { text, strategy?, size?, overlap?, source? }
|
|
751
|
+
* @returns {{ chunks: object[], totalChunks: number, strategy: string, avgChunkSize: number }}
|
|
752
|
+
*/
|
|
753
|
+
function executeChunk(inputs) {
|
|
754
|
+
const { chunk: doChunk } = require('./chunker');
|
|
755
|
+
|
|
756
|
+
const { text, strategy = 'recursive', size = 512, overlap = 50, source } = inputs;
|
|
757
|
+
|
|
758
|
+
if (!text && text !== '') {
|
|
759
|
+
throw new Error('chunk: "text" input is required');
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const chunkTexts = doChunk(text, { strategy, size, overlap });
|
|
763
|
+
|
|
764
|
+
const chunks = chunkTexts.map((content, index) => {
|
|
765
|
+
const obj = {
|
|
766
|
+
index,
|
|
767
|
+
content,
|
|
768
|
+
charCount: content.length,
|
|
769
|
+
};
|
|
770
|
+
if (source) obj.source = source;
|
|
771
|
+
obj.metadata = { strategy };
|
|
772
|
+
// For markdown strategy, try to extract heading
|
|
773
|
+
if (strategy === 'markdown') {
|
|
774
|
+
const headingMatch = content.match(/^#+\s+(.+)/m);
|
|
775
|
+
if (headingMatch) obj.metadata.heading = headingMatch[1];
|
|
776
|
+
}
|
|
777
|
+
return obj;
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
const totalChars = chunks.reduce((sum, c) => sum + c.charCount, 0);
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
chunks,
|
|
784
|
+
totalChunks: chunks.length,
|
|
785
|
+
strategy,
|
|
786
|
+
avgChunkSize: chunks.length > 0 ? Math.round(totalChars / chunks.length) : 0,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Execute an HTTP request step.
|
|
792
|
+
*
|
|
793
|
+
* @param {object} inputs - { url, method?, headers?, body?, timeout?, responseType?, followRedirects? }
|
|
794
|
+
* @returns {Promise<{ status: number, statusText: string, headers: object, body: any, durationMs: number }>}
|
|
795
|
+
*/
|
|
796
|
+
async function executeHttp(inputs) {
|
|
797
|
+
const { url, method = 'GET', headers = {}, body, timeout = 30000, responseType = 'json', followRedirects = false } = inputs;
|
|
798
|
+
|
|
799
|
+
if (!url || typeof url !== 'string') {
|
|
800
|
+
throw new Error('http: "url" input is required');
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// URL allowlisting check
|
|
804
|
+
try {
|
|
805
|
+
const { loadProject } = require('./project');
|
|
806
|
+
const { config: proj } = loadProject();
|
|
807
|
+
if (proj && proj.allowedHosts && Array.isArray(proj.allowedHosts)) {
|
|
808
|
+
const parsed = new URL(url);
|
|
809
|
+
if (!proj.allowedHosts.includes(parsed.hostname)) {
|
|
810
|
+
throw new Error(`http: host "${parsed.hostname}" is not in allowedHosts. Allowed: ${proj.allowedHosts.join(', ')}`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
} catch (e) {
|
|
814
|
+
if (e.message.includes('allowedHosts')) throw e;
|
|
815
|
+
// If project config can't be loaded, allow all hosts
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const startTime = Date.now();
|
|
819
|
+
|
|
820
|
+
// Build fetch options
|
|
821
|
+
const fetchOpts = {
|
|
822
|
+
method: method.toUpperCase(),
|
|
823
|
+
headers: { ...headers },
|
|
824
|
+
signal: AbortSignal.timeout(timeout),
|
|
825
|
+
redirect: followRedirects ? 'follow' : 'manual',
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
if (body && ['POST', 'PUT', 'PATCH'].includes(fetchOpts.method)) {
|
|
829
|
+
if (typeof body === 'object') {
|
|
830
|
+
fetchOpts.body = JSON.stringify(body);
|
|
831
|
+
if (!fetchOpts.headers['Content-Type'] && !fetchOpts.headers['content-type']) {
|
|
832
|
+
fetchOpts.headers['Content-Type'] = 'application/json';
|
|
833
|
+
}
|
|
834
|
+
} else {
|
|
835
|
+
fetchOpts.body = String(body);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const response = await fetch(url, fetchOpts);
|
|
840
|
+
const durationMs = Date.now() - startTime;
|
|
841
|
+
|
|
842
|
+
// Response size limit: 5MB
|
|
843
|
+
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024;
|
|
844
|
+
const responseText = await response.text();
|
|
845
|
+
const truncated = responseText.length > MAX_RESPONSE_SIZE;
|
|
846
|
+
const rawBody = truncated ? responseText.slice(0, MAX_RESPONSE_SIZE) : responseText;
|
|
847
|
+
|
|
848
|
+
// Parse body
|
|
849
|
+
let parsedBody;
|
|
850
|
+
if (responseType === 'json') {
|
|
851
|
+
try {
|
|
852
|
+
parsedBody = JSON.parse(rawBody);
|
|
853
|
+
} catch {
|
|
854
|
+
parsedBody = rawBody; // Fall back to text
|
|
855
|
+
}
|
|
856
|
+
} else {
|
|
857
|
+
parsedBody = rawBody;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Collect response headers
|
|
861
|
+
const respHeaders = {};
|
|
862
|
+
response.headers.forEach((value, key) => {
|
|
863
|
+
respHeaders[key] = value;
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
return {
|
|
867
|
+
status: response.status,
|
|
868
|
+
statusText: response.statusText,
|
|
869
|
+
headers: respHeaders,
|
|
870
|
+
body: parsedBody,
|
|
871
|
+
durationMs,
|
|
872
|
+
...(truncated && { warning: 'Response truncated at 5MB' }),
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Execute a MongoDB aggregation pipeline step.
|
|
878
|
+
*
|
|
879
|
+
* @param {object} inputs - { db?, collection?, pipeline, allowWrites? }
|
|
880
|
+
* @param {object} defaults - workflow defaults
|
|
881
|
+
* @returns {Promise<{ results: any[], count: number, durationMs: number }>}
|
|
882
|
+
*/
|
|
883
|
+
async function executeAggregate(inputs, defaults) {
|
|
884
|
+
const { getMongoCollection } = require('./mongo');
|
|
885
|
+
const { loadProject } = require('./project');
|
|
886
|
+
const { config: proj } = loadProject();
|
|
887
|
+
|
|
888
|
+
const db = inputs.db || defaults.db || proj.db;
|
|
889
|
+
const collection = inputs.collection || defaults.collection || proj.collection;
|
|
890
|
+
const pipeline = inputs.pipeline;
|
|
891
|
+
const allowWrites = inputs.allowWrites || false;
|
|
892
|
+
|
|
893
|
+
if (!db) throw new Error('aggregate: database not specified');
|
|
894
|
+
if (!collection) throw new Error('aggregate: collection not specified');
|
|
895
|
+
if (!Array.isArray(pipeline)) throw new Error('aggregate: "pipeline" must be an array');
|
|
896
|
+
if (pipeline.length > 20) throw new Error('aggregate: pipeline limited to 20 stages');
|
|
897
|
+
|
|
898
|
+
// Block write stages unless explicitly allowed
|
|
899
|
+
if (!allowWrites) {
|
|
900
|
+
for (const stage of pipeline) {
|
|
901
|
+
const stageKey = Object.keys(stage)[0];
|
|
902
|
+
if (stageKey === '$out' || stageKey === '$merge') {
|
|
903
|
+
throw new Error(`aggregate: "${stageKey}" stage is not allowed without allowWrites: true`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const startTime = Date.now();
|
|
909
|
+
const { client, collection: col } = await getMongoCollection(db, collection);
|
|
910
|
+
try {
|
|
911
|
+
const results = await col.aggregate(pipeline).toArray();
|
|
912
|
+
return {
|
|
913
|
+
results,
|
|
914
|
+
count: results.length,
|
|
915
|
+
durationMs: Date.now() - startTime,
|
|
916
|
+
};
|
|
917
|
+
} finally {
|
|
918
|
+
await client.close();
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
557
922
|
// ════════════════════════════════════════════════════════════════════
|
|
558
923
|
// VAI Tool Executors
|
|
559
924
|
// ════════════════════════════════════════════════════════════════════
|
|
@@ -938,6 +1303,18 @@ async function executeStep(step, resolvedInputs, defaults, context) {
|
|
|
938
1303
|
return executeTransform(resolvedInputs);
|
|
939
1304
|
case 'generate':
|
|
940
1305
|
return executeGenerate(resolvedInputs);
|
|
1306
|
+
case 'conditional':
|
|
1307
|
+
return executeConditional(resolvedInputs, context);
|
|
1308
|
+
case 'template':
|
|
1309
|
+
return executeTemplate(resolvedInputs);
|
|
1310
|
+
case 'loop':
|
|
1311
|
+
return executeLoop(resolvedInputs, defaults, context);
|
|
1312
|
+
case 'chunk':
|
|
1313
|
+
return executeChunk(resolvedInputs);
|
|
1314
|
+
case 'http':
|
|
1315
|
+
return executeHttp(resolvedInputs);
|
|
1316
|
+
case 'aggregate':
|
|
1317
|
+
return executeAggregate(resolvedInputs, defaults);
|
|
941
1318
|
|
|
942
1319
|
// VAI tools
|
|
943
1320
|
case 'query':
|
|
@@ -1056,12 +1433,27 @@ async function executeWorkflow(definition, opts = {}) {
|
|
|
1056
1433
|
|
|
1057
1434
|
// Execute layer by layer
|
|
1058
1435
|
const stepResults = [];
|
|
1436
|
+
const skippedByConditional = new Set(); // Steps skipped by conditional branches
|
|
1059
1437
|
|
|
1060
1438
|
for (const layer of layers) {
|
|
1061
1439
|
const layerPromises = layer.map(async (stepId) => {
|
|
1062
1440
|
const step = stepMap.get(stepId);
|
|
1063
1441
|
const stepStart = Date.now();
|
|
1064
1442
|
|
|
1443
|
+
// Check if this step was skipped by a conditional branch
|
|
1444
|
+
if (skippedByConditional.has(stepId)) {
|
|
1445
|
+
if (opts.onStepSkip) opts.onStepSkip(stepId, 'conditional branch not taken');
|
|
1446
|
+
context[stepId] = { output: null, skipped: true };
|
|
1447
|
+
stepResults.push({
|
|
1448
|
+
id: stepId,
|
|
1449
|
+
tool: step.tool,
|
|
1450
|
+
skipped: true,
|
|
1451
|
+
reason: 'conditional branch not taken',
|
|
1452
|
+
durationMs: Date.now() - stepStart,
|
|
1453
|
+
});
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1065
1457
|
// Evaluate condition
|
|
1066
1458
|
if (step.condition) {
|
|
1067
1459
|
const conditionMet = evaluateCondition(step.condition, context);
|
|
@@ -1083,7 +1475,26 @@ async function executeWorkflow(definition, opts = {}) {
|
|
|
1083
1475
|
try {
|
|
1084
1476
|
let output;
|
|
1085
1477
|
|
|
1086
|
-
if (step.
|
|
1478
|
+
if (step.tool === 'conditional') {
|
|
1479
|
+
// Special handling: resolve then/else but pass raw condition to evaluator
|
|
1480
|
+
const rawCondition = step.inputs.condition;
|
|
1481
|
+
const resolvedInputs = {
|
|
1482
|
+
condition: rawCondition, // Keep raw for condition evaluator
|
|
1483
|
+
then: step.inputs.then || [],
|
|
1484
|
+
else: step.inputs.else || [],
|
|
1485
|
+
};
|
|
1486
|
+
output = await executeStep(step, resolvedInputs, defaults, context);
|
|
1487
|
+
} else if (step.tool === 'loop') {
|
|
1488
|
+
// Special handling: resolve items but pass raw step def for per-iteration resolution
|
|
1489
|
+
const resolvedItems = resolveTemplate(step.inputs.items, context);
|
|
1490
|
+
const resolvedInputs = {
|
|
1491
|
+
items: resolvedItems,
|
|
1492
|
+
as: step.inputs.as,
|
|
1493
|
+
step: step.inputs.step, // Raw — resolved per iteration inside executeLoop
|
|
1494
|
+
maxIterations: step.inputs.maxIterations,
|
|
1495
|
+
};
|
|
1496
|
+
output = await executeStep(step, resolvedInputs, defaults, context);
|
|
1497
|
+
} else if (step.forEach) {
|
|
1087
1498
|
// Iterate over an array
|
|
1088
1499
|
const iterArray = resolveTemplate(step.forEach, context);
|
|
1089
1500
|
if (!Array.isArray(iterArray)) {
|
|
@@ -1104,6 +1515,13 @@ async function executeWorkflow(definition, opts = {}) {
|
|
|
1104
1515
|
output = await executeStep(step, resolvedInputs, defaults, context);
|
|
1105
1516
|
}
|
|
1106
1517
|
|
|
1518
|
+
// If this was a conditional node, mark skipped branch steps
|
|
1519
|
+
if (step.tool === 'conditional' && output.skippedSteps) {
|
|
1520
|
+
for (const skippedId of output.skippedSteps) {
|
|
1521
|
+
skippedByConditional.add(skippedId);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1107
1525
|
const durationMs = Date.now() - stepStart;
|
|
1108
1526
|
context[stepId] = { output };
|
|
1109
1527
|
|
|
@@ -1333,6 +1751,12 @@ module.exports = {
|
|
|
1333
1751
|
executeMerge,
|
|
1334
1752
|
executeFilter,
|
|
1335
1753
|
executeTransform,
|
|
1754
|
+
executeConditional,
|
|
1755
|
+
executeTemplate,
|
|
1756
|
+
executeLoop,
|
|
1757
|
+
executeChunk,
|
|
1758
|
+
executeHttp,
|
|
1759
|
+
executeAggregate,
|
|
1336
1760
|
|
|
1337
1761
|
// Main execution
|
|
1338
1762
|
executeStep,
|
|
@@ -1351,5 +1775,7 @@ module.exports = {
|
|
|
1351
1775
|
// Constants
|
|
1352
1776
|
VAI_TOOLS,
|
|
1353
1777
|
CONTROL_FLOW_TOOLS,
|
|
1778
|
+
PROCESSING_TOOLS,
|
|
1779
|
+
INTEGRATION_TOOLS,
|
|
1354
1780
|
ALL_TOOLS,
|
|
1355
1781
|
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# VAI Announcements
|
|
2
|
+
|
|
3
|
+
This file contains the announcements displayed on the VAI home page carousel.
|
|
4
|
+
Each announcement is separated by `---` and uses YAML frontmatter for metadata.
|
|
5
|
+
|
|
6
|
+
Format:
|
|
7
|
+
- `id`: Unique identifier (used to track dismissals)
|
|
8
|
+
- `badge`: Label shown on the card (e.g., "New", "Update", "New Model")
|
|
9
|
+
- `published`: Date published (YYYY-MM-DD)
|
|
10
|
+
- `expires`: Date to stop showing (YYYY-MM-DD)
|
|
11
|
+
- `cta_label`: Button text
|
|
12
|
+
- `cta_action`: Either "navigate" (internal tab) or "link" (external URL)
|
|
13
|
+
- `cta_target`: Tab name (e.g., "/benchmark") or full URL
|
|
14
|
+
|
|
15
|
+
The title is the first `## ` heading, and the description is the paragraph below it.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
id: ann-voyage-4
|
|
20
|
+
badge: New Model
|
|
21
|
+
published: 2026-02-14
|
|
22
|
+
expires: 2026-03-15
|
|
23
|
+
cta_label: Try It Now
|
|
24
|
+
cta_action: navigate
|
|
25
|
+
cta_target: /benchmark
|
|
26
|
+
|
|
27
|
+
## Voyage AI 4 Large Now Available
|
|
28
|
+
|
|
29
|
+
The latest embedding model from Voyage AI is ready to benchmark in VAI. Experience improved accuracy and performance across all embedding tasks.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
id: ann-marketplace-workflows
|
|
34
|
+
badge: New
|
|
35
|
+
published: 2026-02-12
|
|
36
|
+
expires: 2026-03-01
|
|
37
|
+
cta_label: Explore Marketplace
|
|
38
|
+
cta_action: navigate
|
|
39
|
+
cta_target: /workflows
|
|
40
|
+
|
|
41
|
+
## New Marketplace Workflows
|
|
42
|
+
|
|
43
|
+
HIPAA-compliant document search, legal contract analysis, and more workflows are now available in the VAI Marketplace.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
id: ann-csv-ingestion
|
|
48
|
+
badge: Update
|
|
49
|
+
published: 2026-02-10
|
|
50
|
+
expires: 2026-04-01
|
|
51
|
+
cta_label: Learn More
|
|
52
|
+
cta_action: link
|
|
53
|
+
cta_target: https://docs.vaicli.com/csv-import
|
|
54
|
+
|
|
55
|
+
## VAI v1.3 Adds Bulk CSV Ingestion
|
|
56
|
+
|
|
57
|
+
Import large datasets efficiently with the new bulk CSV ingestion feature. Process thousands of documents in a single operation.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
id: ann-workflow-store
|
|
62
|
+
badge: New
|
|
63
|
+
published: 2026-02-13
|
|
64
|
+
expires: 2026-03-15
|
|
65
|
+
cta_label: Browse Store
|
|
66
|
+
cta_action: navigate
|
|
67
|
+
cta_target: /workflows
|
|
68
|
+
|
|
69
|
+
## Workflow Store Now Live
|
|
70
|
+
|
|
71
|
+
Discover, install, and share VAI workflows with the new integrated Workflow Store. Browse featured picks, community contributions, and install with one click.
|
|
Binary file
|