voyageai-cli 1.27.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/config.js +33 -0
- package/src/commands/mcp-server.js +4 -1
- package/src/commands/playground.js +657 -9
- package/src/commands/workflow.js +461 -13
- package/src/lib/api.js +40 -2
- 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 +530 -8
- package/src/mcp/server.js +15 -2
- package/src/mcp/sse-transport.js +112 -0
- package/src/playground/announcements.md +71 -0
- package/src/playground/icons/V.png +0 -0
- package/src/playground/index.html +3536 -461
- 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
|
}
|
|
@@ -92,10 +96,24 @@ function validateWorkflow(definition) {
|
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
// Check template references point to known step IDs or reserved prefixes
|
|
99
|
+
// "item" and "index" are injected by forEach at runtime
|
|
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
|
+
}
|
|
95
108
|
if (step.inputs) {
|
|
96
|
-
|
|
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);
|
|
97
115
|
for (const dep of deps) {
|
|
98
|
-
if (!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)) {
|
|
99
117
|
errors.push(`${stepPrefix}: references unknown step "${dep}"`);
|
|
100
118
|
}
|
|
101
119
|
}
|
|
@@ -117,6 +135,44 @@ function validateWorkflow(definition) {
|
|
|
117
135
|
errors.push(`Duplicate step id: "${id}"`);
|
|
118
136
|
}
|
|
119
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
|
+
|
|
120
176
|
// Check for circular dependencies
|
|
121
177
|
const cycleErrors = detectCycles(definition.steps);
|
|
122
178
|
errors.push(...cycleErrors);
|
|
@@ -195,6 +251,18 @@ function detectCycles(steps) {
|
|
|
195
251
|
function buildDependencyGraph(steps) {
|
|
196
252
|
const graph = new Map();
|
|
197
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
|
+
|
|
198
266
|
for (const step of steps) {
|
|
199
267
|
const deps = extractDependencies(step.inputs || {});
|
|
200
268
|
if (step.condition) {
|
|
@@ -205,6 +273,10 @@ function buildDependencyGraph(steps) {
|
|
|
205
273
|
const forDeps = extractDependencies(step.forEach);
|
|
206
274
|
for (const d of forDeps) deps.add(d);
|
|
207
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
|
+
}
|
|
208
280
|
graph.set(step.id, deps);
|
|
209
281
|
}
|
|
210
282
|
|
|
@@ -552,6 +624,301 @@ function executeTransform(inputs) {
|
|
|
552
624
|
return { results, resultCount: results.length };
|
|
553
625
|
}
|
|
554
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
|
+
|
|
555
922
|
// ════════════════════════════════════════════════════════════════════
|
|
556
923
|
// VAI Tool Executors
|
|
557
924
|
// ════════════════════════════════════════════════════════════════════
|
|
@@ -936,6 +1303,18 @@ async function executeStep(step, resolvedInputs, defaults, context) {
|
|
|
936
1303
|
return executeTransform(resolvedInputs);
|
|
937
1304
|
case 'generate':
|
|
938
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);
|
|
939
1318
|
|
|
940
1319
|
// VAI tools
|
|
941
1320
|
case 'query':
|
|
@@ -1054,12 +1433,27 @@ async function executeWorkflow(definition, opts = {}) {
|
|
|
1054
1433
|
|
|
1055
1434
|
// Execute layer by layer
|
|
1056
1435
|
const stepResults = [];
|
|
1436
|
+
const skippedByConditional = new Set(); // Steps skipped by conditional branches
|
|
1057
1437
|
|
|
1058
1438
|
for (const layer of layers) {
|
|
1059
1439
|
const layerPromises = layer.map(async (stepId) => {
|
|
1060
1440
|
const step = stepMap.get(stepId);
|
|
1061
1441
|
const stepStart = Date.now();
|
|
1062
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
|
+
|
|
1063
1457
|
// Evaluate condition
|
|
1064
1458
|
if (step.condition) {
|
|
1065
1459
|
const conditionMet = evaluateCondition(step.condition, context);
|
|
@@ -1081,7 +1475,26 @@ async function executeWorkflow(definition, opts = {}) {
|
|
|
1081
1475
|
try {
|
|
1082
1476
|
let output;
|
|
1083
1477
|
|
|
1084
|
-
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) {
|
|
1085
1498
|
// Iterate over an array
|
|
1086
1499
|
const iterArray = resolveTemplate(step.forEach, context);
|
|
1087
1500
|
if (!Array.isArray(iterArray)) {
|
|
@@ -1102,6 +1515,13 @@ async function executeWorkflow(definition, opts = {}) {
|
|
|
1102
1515
|
output = await executeStep(step, resolvedInputs, defaults, context);
|
|
1103
1516
|
}
|
|
1104
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
|
+
|
|
1105
1525
|
const durationMs = Date.now() - stepStart;
|
|
1106
1526
|
context[stepId] = { output };
|
|
1107
1527
|
|
|
@@ -1162,6 +1582,36 @@ function coerceInput(value, type) {
|
|
|
1162
1582
|
return value;
|
|
1163
1583
|
}
|
|
1164
1584
|
|
|
1585
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1586
|
+
// Input Schema Helpers
|
|
1587
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* Convert a workflow's `inputs` object into wizard-engine-compatible step definitions.
|
|
1591
|
+
* Used by both CLI (via @clack/prompts) and playground (via input modal) to
|
|
1592
|
+
* prompt users for missing inputs before execution.
|
|
1593
|
+
*
|
|
1594
|
+
* @param {object} definition - Workflow definition with an `inputs` property
|
|
1595
|
+
* @returns {import('./wizard').Step[]}
|
|
1596
|
+
*/
|
|
1597
|
+
function buildInputSteps(definition) {
|
|
1598
|
+
if (!definition.inputs) return [];
|
|
1599
|
+
return Object.entries(definition.inputs).map(([key, spec]) => ({
|
|
1600
|
+
id: key,
|
|
1601
|
+
label: spec.description || key,
|
|
1602
|
+
type: 'text',
|
|
1603
|
+
required: !!spec.required,
|
|
1604
|
+
placeholder: spec.type === 'number' ? 'number' : (spec.type || 'string'),
|
|
1605
|
+
defaultValue: spec.default !== undefined ? String(spec.default) : undefined,
|
|
1606
|
+
validate: (val) => {
|
|
1607
|
+
if (spec.type === 'number' && val && isNaN(Number(val))) {
|
|
1608
|
+
return 'Must be a number';
|
|
1609
|
+
}
|
|
1610
|
+
return true;
|
|
1611
|
+
},
|
|
1612
|
+
}));
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1165
1615
|
// ════════════════════════════════════════════════════════════════════
|
|
1166
1616
|
// Built-in Templates
|
|
1167
1617
|
// ════════════════════════════════════════════════════════════════════
|
|
@@ -1196,8 +1646,60 @@ function listBuiltinWorkflows() {
|
|
|
1196
1646
|
});
|
|
1197
1647
|
}
|
|
1198
1648
|
|
|
1649
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1650
|
+
// Example Workflows
|
|
1651
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1652
|
+
|
|
1653
|
+
const EXAMPLE_CATEGORIES = {
|
|
1654
|
+
'search-filter-transform': 'Retrieval',
|
|
1655
|
+
'conditional-fallback-search': 'Retrieval',
|
|
1656
|
+
'multi-query-fusion': 'Retrieval',
|
|
1657
|
+
'rag-with-guardrails': 'RAG',
|
|
1658
|
+
'question-answer-with-citations': 'RAG',
|
|
1659
|
+
'topic-deep-dive': 'RAG',
|
|
1660
|
+
'dedup-and-ingest': 'Ingestion',
|
|
1661
|
+
'content-quality-gate': 'Ingestion',
|
|
1662
|
+
'embedding-model-comparison': 'Analysis',
|
|
1663
|
+
'batch-similarity-check': 'Analysis',
|
|
1664
|
+
'collection-inventory': 'Analysis',
|
|
1665
|
+
};
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* Get the path to the example workflows directory.
|
|
1669
|
+
*/
|
|
1670
|
+
function getExamplesDir() {
|
|
1671
|
+
return path.join(__dirname, '..', '..', 'examples', 'workflows');
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* List example workflow files with category metadata.
|
|
1676
|
+
* @returns {Array<{ name: string, description: string, file: string, category: string, isExample: boolean }>}
|
|
1677
|
+
*/
|
|
1678
|
+
function listExampleWorkflows() {
|
|
1679
|
+
const dir = getExamplesDir();
|
|
1680
|
+
if (!fs.existsSync(dir)) return [];
|
|
1681
|
+
|
|
1682
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
1683
|
+
return files.map(f => {
|
|
1684
|
+
try {
|
|
1685
|
+
const def = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
1686
|
+
const stem = f.replace('.json', '');
|
|
1687
|
+
return {
|
|
1688
|
+
name: stem,
|
|
1689
|
+
description: def.description || def.name || f,
|
|
1690
|
+
file: f,
|
|
1691
|
+
category: EXAMPLE_CATEGORIES[stem] || 'Other',
|
|
1692
|
+
isExample: true,
|
|
1693
|
+
};
|
|
1694
|
+
} catch {
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
}).filter(Boolean);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1199
1700
|
/**
|
|
1200
|
-
* Load a workflow definition from a file path
|
|
1701
|
+
* Load a workflow definition from a file path, built-in template name,
|
|
1702
|
+
* or example workflow name.
|
|
1201
1703
|
*
|
|
1202
1704
|
* @param {string} nameOrPath - File path or template name (e.g., "multi-collection-search")
|
|
1203
1705
|
* @returns {object} Parsed workflow definition
|
|
@@ -1216,6 +1718,13 @@ function loadWorkflow(nameOrPath) {
|
|
|
1216
1718
|
return JSON.parse(content);
|
|
1217
1719
|
}
|
|
1218
1720
|
|
|
1721
|
+
// Try as an example workflow name
|
|
1722
|
+
const examplePath = path.join(getExamplesDir(), `${nameOrPath}.json`);
|
|
1723
|
+
if (fs.existsSync(examplePath)) {
|
|
1724
|
+
const content = fs.readFileSync(examplePath, 'utf8');
|
|
1725
|
+
return JSON.parse(content);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1219
1728
|
// Try with .json extension appended
|
|
1220
1729
|
const withJson = nameOrPath.endsWith('.json') ? nameOrPath : `${nameOrPath}.json`;
|
|
1221
1730
|
if (fs.existsSync(withJson)) {
|
|
@@ -1242,6 +1751,12 @@ module.exports = {
|
|
|
1242
1751
|
executeMerge,
|
|
1243
1752
|
executeFilter,
|
|
1244
1753
|
executeTransform,
|
|
1754
|
+
executeConditional,
|
|
1755
|
+
executeTemplate,
|
|
1756
|
+
executeLoop,
|
|
1757
|
+
executeChunk,
|
|
1758
|
+
executeHttp,
|
|
1759
|
+
executeAggregate,
|
|
1245
1760
|
|
|
1246
1761
|
// Main execution
|
|
1247
1762
|
executeStep,
|
|
@@ -1249,11 +1764,18 @@ module.exports = {
|
|
|
1249
1764
|
|
|
1250
1765
|
// Templates
|
|
1251
1766
|
listBuiltinWorkflows,
|
|
1767
|
+
listExampleWorkflows,
|
|
1252
1768
|
loadWorkflow,
|
|
1253
1769
|
getWorkflowsDir,
|
|
1770
|
+
getExamplesDir,
|
|
1771
|
+
|
|
1772
|
+
// Input helpers
|
|
1773
|
+
buildInputSteps,
|
|
1254
1774
|
|
|
1255
1775
|
// Constants
|
|
1256
1776
|
VAI_TOOLS,
|
|
1257
1777
|
CONTROL_FLOW_TOOLS,
|
|
1778
|
+
PROCESSING_TOOLS,
|
|
1779
|
+
INTEGRATION_TOOLS,
|
|
1258
1780
|
ALL_TOOLS,
|
|
1259
1781
|
};
|
package/src/mcp/server.js
CHANGED
|
@@ -51,7 +51,7 @@ async function runStdioServer() {
|
|
|
51
51
|
* @param {number} options.port
|
|
52
52
|
* @param {string} options.host
|
|
53
53
|
*/
|
|
54
|
-
async function runHttpServer({ port = 3100, host = '127.0.0.1' } = {}) {
|
|
54
|
+
async function runHttpServer({ port = 3100, host = '127.0.0.1', sse = false } = {}) {
|
|
55
55
|
const express = require('express');
|
|
56
56
|
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
|
57
57
|
const { getConfigValue } = require('../lib/config');
|
|
@@ -131,8 +131,21 @@ async function runHttpServer({ port = 3100, host = '127.0.0.1' } = {}) {
|
|
|
131
131
|
res.status(405).json({ error: 'Method not allowed. Stateless server — no sessions to delete.' });
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
// SSE transport (opt-in via --sse flag)
|
|
135
|
+
if (sse) {
|
|
136
|
+
const { setupSSE, getSessionCount } = require('./sse-transport');
|
|
137
|
+
setupSSE(app, authenticateRequest);
|
|
138
|
+
|
|
139
|
+
// Add SSE session count endpoint
|
|
140
|
+
app.get('/health/sse', (_req, res) => {
|
|
141
|
+
res.json({ sseSessions: getSessionCount() });
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
134
145
|
app.listen(port, host, () => {
|
|
135
|
-
const
|
|
146
|
+
const transports = ['Streamable HTTP (POST /mcp)'];
|
|
147
|
+
if (sse) transports.push('SSE (GET /sse)');
|
|
148
|
+
const msg = `vai MCP server v${VERSION} running on http://${host}:${port}\n Transports: ${transports.join(', ')}`;
|
|
136
149
|
if (process.env.VAI_MCP_VERBOSE) {
|
|
137
150
|
process.stderr.write(msg + '\n');
|
|
138
151
|
process.stderr.write(`Authentication: ${requireAuth ? 'enabled' : 'disabled (no keys configured)'}\n`);
|