voyageai-cli
Version:
CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search
1,553 lines (1,336 loc) • 74.2 kB
JavaScript
'use strict';
const path = require('path');
const fs = require('fs');
const {
resolveTemplate,
resolveString,
extractDependencies,
isTemplateString,
} = require('./template-engine');
// ════════════════════════════════════════════════════════════════════
// Valid tool names — vai tools + control flow
// ════════════════════════════════════════════════════════════════════
const VAI_TOOLS = new Set([
'query', 'search', 'rerank', 'embed', 'similarity',
'ingest', 'collections', 'models', 'explain', 'estimate',
'code_index', 'code_search', 'code_query', 'code_find_similar', 'code_status',
]);
const CONTROL_FLOW_TOOLS = new Set(['merge', 'filter', 'transform', 'generate', 'conditional', 'loop', 'template']);
const PROCESSING_TOOLS = new Set(['chunk', 'aggregate']);
const INTEGRATION_TOOLS = new Set(['http']);
const ALL_TOOLS = new Set([...VAI_TOOLS, ...CONTROL_FLOW_TOOLS, ...PROCESSING_TOOLS, ...INTEGRATION_TOOLS]);
// ════════════════════════════════════════════════════════════════════
// Schema Limits (Phase 1 Enhanced Validation)
// ════════════════════════════════════════════════════════════════════
const SCHEMA_LIMITS = {
maxSteps: 50,
maxInputs: 20,
maxTemplateDepth: 5,
maxNameLength: 64,
maxDescriptionLength: 500,
maxStepNameLength: 100,
};
// ════════════════════════════════════════════════════════════════════
// Validation
// ════════════════════════════════════════════════════════════════════
/**
* Validate a workflow definition object.
* Two modes: strict (default) returns error strings for backward compatibility,
* draft mode returns structured issue objects with severity levels.
*
* @param {object} definition - Parsed workflow JSON
* @param {object} [options]
* @param {'strict'|'draft'} [options.mode='strict'] - Validation mode
* @returns {string[]|object} In strict: string[] errors. In draft: { valid, mode, issues, stats }
*/
function validateWorkflow(definition, { mode = 'strict' } = {}) {
const issues = [];
// Helper to add issue
function addIssue(severity, stepId, code, message, field = null, referencedStep = null) {
issues.push({
severity,
stepId,
code,
message,
...(field && { field }),
...(referencedStep && { referencedStep })
});
}
// Top-level required fields
if (!definition || typeof definition !== 'object') {
addIssue('error', null, 'INVALID_DEFINITION', 'Workflow definition must be a JSON object');
return mode === 'strict' ? ['Workflow definition must be a JSON object'] : { valid: false, mode, issues, stats: { totalSteps: 0, errors: 1, warnings: 0, info: 0 } };
}
if (!definition.name || typeof definition.name !== 'string') {
const severity = mode === 'draft' ? 'info' : 'error';
addIssue(severity, null, 'MISSING_WORKFLOW_NAME', 'Workflow must have a "name" string');
}
if (!Array.isArray(definition.steps) || definition.steps.length === 0) {
addIssue('error', null, 'MISSING_STEPS', 'Workflow must have a non-empty "steps" array');
}
const structuralErrors = issues.filter(i => i.severity === 'error');
if (structuralErrors.length > 0 && !Array.isArray(definition.steps)) {
// Can't validate steps without them
return formatResponse(mode, issues, 0);
}
// Validate inputs schema
if (definition.inputs) {
for (const [key, schema] of Object.entries(definition.inputs)) {
if (schema.type && !['string', 'number', 'boolean', 'array'].includes(schema.type)) {
addIssue('error', null, 'INVALID_INPUT_TYPE', `Input "${key}" has invalid type "${schema.type}" (must be string, number, boolean, or array)`);
}
}
}
// Validate formatters section (optional)
if (definition.formatters) {
if (typeof definition.formatters !== 'object' || Array.isArray(definition.formatters)) {
addIssue('error', null, 'INVALID_FORMATTERS', '"formatters" must be a plain object');
} else {
const f = definition.formatters;
const validFormats = ['json', 'table', 'markdown', 'text', 'csv'];
if (f.default && !validFormats.includes(f.default)) {
addIssue('warning', null, 'INVALID_FORMATTER_DEFAULT', `formatters.default "${f.default}" is not a recognized format (${validFormats.join(', ')})`);
}
if (f.columns && !Array.isArray(f.columns)) {
addIssue('error', null, 'INVALID_FORMATTER_COLUMNS', 'formatters.columns must be an array of strings');
}
if (f.title && typeof f.title !== 'string') {
addIssue('error', null, 'INVALID_FORMATTER_TITLE', 'formatters.title must be a string');
}
}
}
// Step-level validation
const stepIds = new Set();
const duplicateIds = new Set();
for (let i = 0; i < definition.steps.length; i++) {
const step = definition.steps[i];
const prefix = `Step ${i}`;
if (!step.id || typeof step.id !== 'string') {
addIssue('error', step.id || `step${i}`, 'MISSING_STEP_ID', `${prefix}: must have a string "id"`);
continue;
}
const stepPrefix = `Step "${step.id}"`;
// Duplicate check
if (stepIds.has(step.id)) {
duplicateIds.add(step.id);
}
stepIds.add(step.id);
// Tool validation
if (!step.tool || typeof step.tool !== 'string') {
addIssue('error', step.id, 'MISSING_TOOL', `${stepPrefix}: must have a string "tool"`);
} else if (!ALL_TOOLS.has(step.tool)) {
addIssue('error', step.id, 'INVALID_TOOL', `${stepPrefix}: unknown tool "${step.tool}" (available: ${[...ALL_TOOLS].join(', ')})`);
}
// Inputs validation
if (step.tool !== 'generate' && (!step.inputs || typeof step.inputs !== 'object')) {
const severity = mode === 'draft' ? 'info' : 'error';
addIssue(severity, step.id, 'MISSING_INPUTS', `${stepPrefix}: must have an "inputs" object`);
}
// Step name validation (only in draft mode for better UX)
if (!step.name && mode === 'draft') {
addIssue('info', step.id, 'MISSING_STEP_NAME', `${stepPrefix}: missing "name" field`);
}
// Check template references point to known step IDs or reserved prefixes
// "item" and "index" are injected by forEach at runtime
const forEachVars = step.forEach ? new Set(['item', 'index']) : new Set();
// For loop nodes, the "as" variable and inline step refs are scoped
const loopVars = new Set();
if (step.tool === 'loop' && step.inputs) {
if (step.inputs.as) loopVars.add(step.inputs.as);
loopVars.add('item');
loopVars.add('index');
}
if (step.inputs) {
// For loop nodes, only check dependencies on top-level inputs (items, as, maxIterations)
// not on the inline step's inputs which may reference the loop variable
const inputsToCheck = step.tool === 'loop'
? { items: step.inputs.items }
: step.inputs;
const deps = extractDependencies(inputsToCheck);
for (const dep of deps) {
if (!forEachVars.has(dep) && !loopVars.has(dep) && !stepIds.has(dep) && !definition.steps.some(s => s.id === dep)) {
const severity = mode === 'draft' ? 'warning' : 'error';
addIssue(severity, step.id, 'UNKNOWN_STEP_REF', `${stepPrefix}: references unknown step "${dep}"`, null, dep);
}
}
}
// Condition validation (if present, should be a string)
if (step.condition !== undefined && typeof step.condition !== 'string') {
addIssue('error', step.id, 'INVALID_CONDITION', `${stepPrefix}: "condition" must be a string`);
}
// Empty condition validation
if (step.condition !== undefined && typeof step.condition === 'string' && step.condition.trim() === '') {
const severity = mode === 'draft' ? 'info' : 'error';
addIssue(severity, step.id, 'EMPTY_CONDITION', `${stepPrefix}: "condition" is empty`);
}
// forEach validation (if present, should be a template string)
if (step.forEach !== undefined && typeof step.forEach !== 'string') {
addIssue('error', step.id, 'INVALID_FOREACH', `${stepPrefix}: "forEach" must be a string`);
}
}
// Report duplicates
for (const id of duplicateIds) {
addIssue('error', id, 'DUPLICATE_ID', `Duplicate step id: "${id}"`);
}
// Validate conditional branch references
for (const step of definition.steps) {
if (step.tool === 'conditional' && step.inputs) {
const branches = ['then', 'else'];
for (const branch of branches) {
const refs = step.inputs[branch];
if (refs && Array.isArray(refs)) {
for (const ref of refs) {
if (!stepIds.has(ref)) {
const severity = mode === 'draft' ? 'warning' : 'error';
addIssue(severity, step.id, 'UNKNOWN_STEP_REF', `Step "${step.id}": conditional ${branch} references unknown step "${ref}"`, null, ref);
}
}
}
}
if (!step.inputs.condition) {
const severity = mode === 'draft' ? 'info' : 'error';
addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": conditional must have a "condition" input`, 'inputs.condition');
}
if (!step.inputs.then || !Array.isArray(step.inputs.then)) {
const severity = mode === 'draft' ? 'info' : 'error';
addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": conditional must have a "then" array`, 'inputs.then');
}
}
// Validate loop inline step
if (step.tool === 'loop' && step.inputs) {
if (!step.inputs.items) {
const severity = mode === 'draft' ? 'info' : 'error';
addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": loop must have an "items" input`, 'inputs.items');
}
if (!step.inputs.as || typeof step.inputs.as !== 'string') {
const severity = mode === 'draft' ? 'info' : 'error';
addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": loop must have a string "as" input`, 'inputs.as');
}
if (!step.inputs.step || typeof step.inputs.step !== 'object') {
const severity = mode === 'draft' ? 'info' : 'error';
addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": loop must have a "step" object`, 'inputs.step');
} else if (step.inputs.step.tool && !ALL_TOOLS.has(step.inputs.step.tool)) {
addIssue('error', step.id, 'INVALID_TOOL', `Step "${step.id}": loop sub-step has unknown tool "${step.inputs.step.tool}"`);
}
}
}
// Check for circular dependencies
const cycleResult = detectCyclesAsIssues(definition.steps);
for (const issue of cycleResult) {
addIssue('error', issue.stepId, 'CIRCULAR_DEPENDENCY', issue.message);
}
// Draft-only checks: orphan nodes
if (mode === 'draft') {
for (const step of definition.steps || []) {
const isReferenced = (definition.steps || []).some(
other => other.id !== step.id &&
extractStepReferences(other).some(ref => ref.stepId === step.id)
);
const hasReferences = extractStepReferences(step).length > 0;
if (!isReferenced && !hasReferences && (definition.steps || []).length > 1) {
addIssue('info', step.id, 'ORPHAN_NODE', `Step "${step.id}" is not connected to any other step`);
}
}
}
return formatResponse(mode, issues, (definition.steps || []).length);
}
/**
* Extract step references from a step for orphan detection.
* @param {object} step
* @returns {Array<{stepId: string, field?: string}>}
*/
function extractStepReferences(step) {
const refs = [];
if (!step.inputs) return refs;
const deps = extractDependencies(step.inputs);
for (const dep of deps) {
if (dep !== 'inputs' && dep !== 'defaults' && dep !== 'item' && dep !== 'index') {
refs.push({ stepId: dep });
}
}
return refs;
}
/**
* Format the response based on validation mode.
* @param {string} mode
* @param {Array} issues
* @param {number} totalSteps
* @returns {string[]|object}
*/
function formatResponse(mode, issues, totalSteps) {
const errors = issues.filter(i => i.severity === 'error');
const warnings = issues.filter(i => i.severity === 'warning');
const info = issues.filter(i => i.severity === 'info');
if (mode === 'draft') {
return {
valid: errors.length === 0,
mode: 'draft',
issues,
stats: {
totalSteps,
errors: errors.length,
warnings: warnings.length,
info: info.length
}
};
}
// Strict mode: backward-compatible format
if (errors.length > 0) {
return errors.map(e => e.message);
}
// In strict mode, warnings and info are also treated as errors
const allIssues = issues.filter(i => i.severity !== 'info' || mode === 'strict');
if (allIssues.length > 0) {
return allIssues.map(e => e.message);
}
return [];
}
/**
* Detect circular dependencies and return as issue objects.
* @param {Array} steps
* @returns {Array<{stepId: string, message: string}>}
*/
function detectCyclesAsIssues(steps) {
const issues = [];
const stepMap = new Map(steps.map(s => [s.id, s]));
const adjList = new Map();
// Build adjacency list from template dependencies
for (const step of steps) {
const deps = extractDependencies(step.inputs || {});
if (step.condition) {
const condDeps = extractDependencies(step.condition);
for (const d of condDeps) deps.add(d);
}
if (step.forEach) {
const forDeps = extractDependencies(step.forEach);
for (const d of forDeps) deps.add(d);
}
adjList.set(step.id, deps);
}
// DFS cycle detection
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map(steps.map(s => [s.id, WHITE]));
function dfs(nodeId, path) {
color.set(nodeId, GRAY);
path.push(nodeId);
const neighbors = adjList.get(nodeId) || new Set();
for (const dep of neighbors) {
if (!stepMap.has(dep)) continue; // Unknown deps caught by other checks
if (color.get(dep) === GRAY) {
const cycleStart = path.indexOf(dep);
const cycle = path.slice(cycleStart).concat(dep);
issues.push({
stepId: cycle[0],
message: `Circular dependency: ${cycle.join(' -> ')}`
});
return;
}
if (color.get(dep) === WHITE) {
dfs(dep, path);
}
}
path.pop();
color.set(nodeId, BLACK);
}
for (const step of steps) {
if (color.get(step.id) === WHITE) {
dfs(step.id, []);
}
}
return issues;
}
/**
* Enhanced schema validation for publishable workflows.
* Runs all existing validateWorkflow() checks plus additional quality gates.
*
* @param {object} definition - Parsed workflow JSON
* @returns {string[]} errors
*/
function validateSchemaEnhanced(definition) {
const errors = validateWorkflow(definition);
if (!definition || typeof definition !== 'object') return errors;
// Step count limit
if (Array.isArray(definition.steps) && definition.steps.length > SCHEMA_LIMITS.maxSteps) {
errors.push(`Too many steps (${definition.steps.length}/${SCHEMA_LIMITS.maxSteps})`);
}
// Input count limit
if (definition.inputs && Object.keys(definition.inputs).length > SCHEMA_LIMITS.maxInputs) {
errors.push(`Too many inputs (${Object.keys(definition.inputs).length}/${SCHEMA_LIMITS.maxInputs})`);
}
// Name length
if (definition.name && definition.name.length > SCHEMA_LIMITS.maxNameLength) {
errors.push(`Workflow name too long (${definition.name.length}/${SCHEMA_LIMITS.maxNameLength})`);
}
// Description required (min 10 chars) for publishable workflows
if (!definition.description || definition.description.length < 10) {
errors.push('Description must be at least 10 characters');
}
// Description length limit
if (definition.description && definition.description.length > SCHEMA_LIMITS.maxDescriptionLength) {
errors.push(`Description too long (${definition.description.length}/${SCHEMA_LIMITS.maxDescriptionLength})`);
}
// Version must be valid semver
if (definition.version && !/^\d+\.\d+\.\d+/.test(definition.version)) {
errors.push('Version must be valid semver (e.g. 1.0.0)');
}
// Every input should have a description
for (const [key, spec] of Object.entries(definition.inputs || {})) {
if (!spec.description) {
errors.push(`Input "${key}" missing description`);
}
}
// Every step should have a human-readable name
if (Array.isArray(definition.steps)) {
for (const step of definition.steps) {
if (!step.name) {
errors.push(`Step "${step.id}" missing "name" field`);
} else if (step.name.length > SCHEMA_LIMITS.maxStepNameLength) {
errors.push(`Step "${step.id}" name too long (${step.name.length}/${SCHEMA_LIMITS.maxStepNameLength})`);
}
}
}
// Output section should exist
if (!definition.output || Object.keys(definition.output).length === 0) {
errors.push('Workflow should define an "output" section');
}
return errors;
}
/**
* Detect circular dependencies in steps using DFS.
* @param {Array} steps
* @returns {string[]} errors
*/
function detectCycles(steps) {
const errors = [];
const stepMap = new Map(steps.map(s => [s.id, s]));
const adjList = new Map();
// Build adjacency list from template dependencies
for (const step of steps) {
const deps = extractDependencies(step.inputs || {});
if (step.condition) {
const condDeps = extractDependencies(step.condition);
for (const d of condDeps) deps.add(d);
}
if (step.forEach) {
const forDeps = extractDependencies(step.forEach);
for (const d of forDeps) deps.add(d);
}
adjList.set(step.id, deps);
}
// DFS cycle detection
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map(steps.map(s => [s.id, WHITE]));
function dfs(nodeId, path) {
color.set(nodeId, GRAY);
path.push(nodeId);
const neighbors = adjList.get(nodeId) || new Set();
for (const dep of neighbors) {
if (!stepMap.has(dep)) continue; // Unknown deps caught by validateWorkflow
if (color.get(dep) === GRAY) {
const cycleStart = path.indexOf(dep);
const cycle = path.slice(cycleStart).concat(dep);
errors.push(`Circular dependency: ${cycle.join(' -> ')}`);
return;
}
if (color.get(dep) === WHITE) {
dfs(dep, path);
}
}
path.pop();
color.set(nodeId, BLACK);
}
for (const step of steps) {
if (color.get(step.id) === WHITE) {
dfs(step.id, []);
}
}
return errors;
}
// ════════════════════════════════════════════════════════════════════
// Dependency Resolution + Execution Plan
// ════════════════════════════════════════════════════════════════════
/**
* Build a dependency graph: stepId -> Set of step IDs it depends on.
* @param {Array} steps
* @returns {Map<string, Set<string>>}
*/
function buildDependencyGraph(steps) {
const graph = new Map();
// First pass: build index of conditional branches
// Steps referenced in then/else of a conditional depend on that conditional
const conditionalDeps = new Map(); // stepId -> conditionalStepId
for (const step of steps) {
if (step.tool === 'conditional' && step.inputs) {
const branches = [...(step.inputs.then || []), ...(step.inputs.else || [])];
for (const ref of branches) {
conditionalDeps.set(ref, step.id);
}
}
}
for (const step of steps) {
const deps = extractDependencies(step.inputs || {});
if (step.condition) {
const condDeps = extractDependencies(step.condition);
for (const d of condDeps) deps.add(d);
}
if (step.forEach) {
const forDeps = extractDependencies(step.forEach);
for (const d of forDeps) deps.add(d);
}
// If this step is referenced by a conditional, it depends on that conditional
if (conditionalDeps.has(step.id)) {
deps.add(conditionalDeps.get(step.id));
}
graph.set(step.id, deps);
}
return graph;
}
/**
* Topological sort with layer grouping (Kahn's algorithm).
* Returns layers: each layer is an array of step IDs that can run in parallel.
*
* @param {Array} steps
* @returns {string[][]} layers
*/
function buildExecutionPlan(steps) {
const graph = buildDependencyGraph(steps);
const stepIds = new Set(steps.map(s => s.id));
// In-degree: count of dependencies that are actual steps
const inDegree = new Map();
for (const step of steps) {
const deps = graph.get(step.id);
let count = 0;
for (const dep of deps) {
if (stepIds.has(dep)) count++;
}
inDegree.set(step.id, count);
}
const layers = [];
const remaining = new Set(stepIds);
while (remaining.size > 0) {
// Find all nodes with in-degree 0
const ready = [];
for (const id of remaining) {
if (inDegree.get(id) === 0) {
ready.push(id);
}
}
if (ready.length === 0) {
// Should not happen if cycle detection passed, but guard against it
break;
}
layers.push(ready);
// Remove ready nodes and decrease in-degree of dependents
for (const id of ready) {
remaining.delete(id);
// Decrease in-degree of all steps that depend on this id
for (const otherId of remaining) {
const deps = graph.get(otherId);
if (deps.has(id)) {
inDegree.set(otherId, inDegree.get(otherId) - 1);
}
}
}
}
return layers;
}
// ════════════════════════════════════════════════════════════════════
// Condition Evaluator
// ════════════════════════════════════════════════════════════════════
/**
* Evaluate a simple condition expression.
* Supports: comparisons (>, <, >=, <=, ===, !==, ==, !=),
* boolean operators (&&, ||), negation (!), property access, array .length.
*
* This is NOT eval(). It's a very restricted expression evaluator.
*
* @param {string} expr - e.g. "check.output.results.length > 0"
* @param {object} context
* @returns {boolean}
*/
function evaluateCondition(expr, context) {
let resolved = expr;
if (isTemplateString(expr)) {
// Check if the entire expression is wrapped in {{ }}
// If so, extract the inner content and evaluate it as a condition
const soleMatch = expr.match(/^\{\{\s*(.+?)\s*\}\}$/);
if (soleMatch) {
const inner = soleMatch[1];
// If the inner expression contains operators, evaluate as condition
if (/[><=!&|]/.test(inner)) {
try {
return evaluateSimpleExpr(inner.trim(), context);
} catch {
return false;
}
}
// Otherwise resolve as template value and check truthiness
resolved = resolveString(expr, context);
if (typeof resolved !== 'string') {
return Boolean(resolved);
}
} else {
// Mixed text + templates: resolve then evaluate
resolved = resolveString(expr, context);
if (typeof resolved !== 'string') {
return Boolean(resolved);
}
}
}
// Try to evaluate as a simple expression
try {
return evaluateSimpleExpr(resolved.trim(), context);
} catch {
// If evaluation fails, treat as falsy
return false;
}
}
/**
* Simple expression evaluator for conditions.
* Handles: path lookups, comparisons, &&, ||, !, literals.
*/
function evaluateSimpleExpr(expr, context) {
// Handle boolean operators (lowest precedence)
// Split on || first
const orParts = splitOutsideParens(expr, '||');
if (orParts.length > 1) {
return orParts.some(part => evaluateSimpleExpr(part.trim(), context));
}
// Split on &&
const andParts = splitOutsideParens(expr, '&&');
if (andParts.length > 1) {
return andParts.every(part => evaluateSimpleExpr(part.trim(), context));
}
// Handle negation
if (expr.startsWith('!') && !expr.startsWith('!=')) {
return !evaluateSimpleExpr(expr.slice(1).trim(), context);
}
// Handle parentheses
if (expr.startsWith('(') && expr.endsWith(')')) {
return evaluateSimpleExpr(expr.slice(1, -1).trim(), context);
}
// Handle comparisons
const compOps = ['===', '!==', '>=', '<=', '==', '!=', '>', '<'];
for (const op of compOps) {
const idx = expr.indexOf(op);
if (idx !== -1) {
const left = resolveValue(expr.slice(0, idx).trim(), context);
const right = resolveValue(expr.slice(idx + op.length).trim(), context);
switch (op) {
case '===': return left === right;
case '!==': return left !== right;
case '==': return left == right;
case '!=': return left != right;
case '>': return left > right;
case '<': return left < right;
case '>=': return left >= right;
case '<=': return left <= right;
}
}
}
// No operator: evaluate as truthy/falsy
return Boolean(resolveValue(expr, context));
}
/**
* Split a string by an operator, but not inside parentheses.
*/
function splitOutsideParens(str, op) {
const parts = [];
let depth = 0;
let current = '';
for (let i = 0; i < str.length; i++) {
if (str[i] === '(') depth++;
if (str[i] === ')') depth--;
if (depth === 0 && str.slice(i, i + op.length) === op) {
parts.push(current);
current = '';
i += op.length - 1;
} else {
current += str[i];
}
}
parts.push(current);
return parts;
}
/**
* Resolve a value from an expression fragment.
* Handles: number literals, string literals, true/false, null, undefined, path lookups.
*/
function resolveValue(expr, context) {
const trimmed = expr.trim();
// Number
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return parseFloat(trimmed);
}
// String literal (single or double quotes)
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
return trimmed.slice(1, -1);
}
// Boolean / null / undefined
if (trimmed === 'true') return true;
if (trimmed === 'false') return false;
if (trimmed === 'null') return null;
if (trimmed === 'undefined') return undefined;
// Path lookup (e.g., "check.output.results.length")
// Walk the context object
try {
const parts = trimmed.split('.');
let current = context;
for (const part of parts) {
// Handle array indexing
const bracketMatch = part.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]$/);
if (bracketMatch) {
current = current[bracketMatch[1]];
if (current == null) return undefined;
current = current[parseInt(bracketMatch[2], 10)];
} else {
if (current == null) return undefined;
current = current[part];
}
}
return current;
} catch {
return undefined;
}
}
// ════════════════════════════════════════════════════════════════════
// Control Flow Executors
// ════════════════════════════════════════════════════════════════════
/**
* Execute a merge step: concatenate arrays with optional dedup.
*
* @param {object} inputs - { arrays: any[][], dedup?: boolean, dedup_field?: string }
* @returns {{ results: any[], resultCount: number }}
*/
function executeMerge(inputs) {
const { arrays, dedup, dedup_field } = inputs;
if (!Array.isArray(arrays)) {
throw new Error('merge: "arrays" input must be an array of arrays');
}
let merged = [];
for (const arr of arrays) {
if (Array.isArray(arr)) {
merged = merged.concat(arr);
}
}
if (dedup && dedup_field) {
const seen = new Set();
merged = merged.filter(item => {
const key = item && typeof item === 'object' ? item[dedup_field] : item;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
return { results: merged, resultCount: merged.length };
}
/**
* Execute a filter step: filter array by condition.
*
* @param {object} inputs - { array: any[], condition: string }
* @param {object} context - workflow context for evaluating conditions
* @returns {{ results: any[], resultCount: number }}
*/
function executeFilter(inputs, context) {
const { array, condition } = inputs;
if (!Array.isArray(array)) {
throw new Error('filter: "array" input must be an array');
}
if (!condition || typeof condition !== 'string') {
throw new Error('filter: "condition" must be a string expression');
}
const results = array.filter(item => {
// Make "item" available in the condition context
const itemContext = { ...context, item };
return evaluateCondition(condition, itemContext);
});
return { results, resultCount: results.length };
}
/**
* Execute a transform step: map/reshape array items.
*
* @param {object} inputs - { array: any[], fields?: string[], mapping?: object }
* @returns {{ results: any[], resultCount: number }}
*/
function executeTransform(inputs) {
const { array, fields, mapping } = inputs;
if (!Array.isArray(array)) {
throw new Error('transform: "array" input must be an array');
}
let results;
if (fields) {
// Pick specific fields
results = array.map(item => {
if (!item || typeof item !== 'object') return item;
const picked = {};
for (const field of fields) {
if (field in item) picked[field] = item[field];
}
return picked;
});
} else if (mapping) {
// Rename/reshape fields
results = array.map(item => {
if (!item || typeof item !== 'object') return item;
const mapped = {};
for (const [newKey, oldKey] of Object.entries(mapping)) {
mapped[newKey] = typeof oldKey === 'string' && oldKey in item ? item[oldKey] : oldKey;
}
return mapped;
});
} else {
results = array;
}
return { results, resultCount: results.length };
}
/**
* Execute a conditional step: evaluate condition and determine branch.
*
* NOTE: The actual branch enforcement (skipping steps) is handled by
* the main execution loop, not here. This just evaluates and returns
* which branch was taken.
*
* @param {object} inputs - { condition: string, then: string[], else?: string[] }
* @param {object} context - workflow context
* @returns {{ conditionResult: boolean, branchTaken: string, enabledSteps: string[], skippedSteps: string[] }}
*/
function executeConditional(inputs, context) {
const { condition } = inputs;
const thenSteps = inputs.then || [];
const elseSteps = inputs.else || [];
if (!condition && condition !== false && condition !== 0) {
throw new Error('conditional: "condition" input is required');
}
// Condition may already be resolved by template engine to a boolean
let result;
if (typeof condition === 'boolean') {
result = condition;
} else if (typeof condition === 'string') {
result = evaluateCondition(condition, context);
} else {
result = Boolean(condition);
}
const taken = result ? 'then' : 'else';
const enabled = result ? thenSteps : elseSteps;
const skipped = result ? elseSteps : thenSteps;
return {
conditionResult: result,
branchTaken: taken,
enabledSteps: enabled,
skippedSteps: skipped,
};
}
/**
* Execute a template step: compose text from template.
*
* @param {object} inputs - { text: string }
* @returns {{ text: string, charCount: number, referencedSteps: string[] }}
*/
function executeTemplate(inputs) {
const { text } = inputs;
if (text === undefined || text === null) {
throw new Error('template: "text" input is required');
}
const textStr = String(text);
// Extract referenced step IDs from the original template (before resolution)
// Since inputs are already resolved by this point, we just return the composed text
return {
text: textStr,
charCount: textStr.length,
};
}
/**
* Execute a loop step: iterate over an array, executing a sub-step per item.
*
* @param {object} inputs - { items, as, step, maxIterations? }
* @param {object} defaults - workflow defaults
* @param {object} context - workflow context
* @returns {Promise<{ iterations: number, results: any[], errors: any[] }>}
*/
async function executeLoop(inputs, defaults, context) {
const { items, as, step: subStepDef, maxIterations = 100 } = inputs;
if (!Array.isArray(items)) {
throw new Error('loop: "items" must resolve to an array');
}
if (!as || typeof as !== 'string') {
throw new Error('loop: "as" must be a string variable name');
}
if (!subStepDef || typeof subStepDef !== 'object') {
throw new Error('loop: "step" must be a step definition object');
}
const results = [];
const errors = [];
const limit = Math.min(items.length, maxIterations);
for (let i = 0; i < limit; i++) {
const item = items[i];
// Build scoped context with loop variable
const scopedContext = { ...context, [as]: item, _loopIndex: i };
try {
// Resolve the sub-step inputs in the scoped context
const resolvedInputs = resolveTemplate(subStepDef.inputs || {}, scopedContext);
// Create a temporary step object for the dispatcher
const tempStep = { id: `_loop_${i}`, tool: subStepDef.tool, inputs: subStepDef.inputs };
const output = await executeStep(tempStep, resolvedInputs, defaults, scopedContext);
results.push(output);
} catch (err) {
errors.push({ index: i, error: err.message });
// If the parent loop has continueOnError, we keep going (handled by caller)
// For now, loop always continues and collects errors
}
}
if (items.length > maxIterations) {
errors.push({ index: maxIterations, error: `Loop truncated at maxIterations (${maxIterations})` });
}
return {
iterations: results.length,
results,
errors,
};
}
/**
* Execute a chunk step: split text using vai's chunking strategies.
*
* @param {object} inputs - { text, strategy?, size?, overlap?, source? }
* @returns {{ chunks: object[], totalChunks: number, strategy: string, avgChunkSize: number }}
*/
function executeChunk(inputs) {
const { chunk: doChunk } = require('./chunker');
const { text, strategy = 'recursive', size = 512, overlap = 50, source } = inputs;
if (!text && text !== '') {
throw new Error('chunk: "text" input is required');
}
const chunkTexts = doChunk(text, { strategy, size, overlap });
const chunks = chunkTexts.map((content, index) => {
const obj = {
index,
content,
charCount: content.length,
};
if (source) obj.source = source;
obj.metadata = { strategy };
// For markdown strategy, try to extract heading
if (strategy === 'markdown') {
const headingMatch = content.match(/^#+\s+(.+)/m);
if (headingMatch) obj.metadata.heading = headingMatch[1];
}
return obj;
});
const totalChars = chunks.reduce((sum, c) => sum + c.charCount, 0);
return {
chunks,
totalChunks: chunks.length,
strategy,
avgChunkSize: chunks.length > 0 ? Math.round(totalChars / chunks.length) : 0,
};
}
/**
* Execute an HTTP request step.
*
* @param {object} inputs - { url, method?, headers?, body?, timeout?, responseType?, followRedirects? }
* @returns {Promise<{ status: number, statusText: string, headers: object, body: any, durationMs: number }>}
*/
async function executeHttp(inputs) {
const effectiveResponseType = inputs.responseType || (inputs.extract === 'text' ? 'text' : 'json');
const { url, method = 'GET', headers = {}, body, timeout = 30000, followRedirects = false } = inputs;
const responseType = effectiveResponseType;
if (!url || typeof url !== 'string') {
throw new Error('http: "url" input is required');
}
// URL allowlisting check
try {
const { loadProject } = require('./project');
const { config: proj } = loadProject();
if (proj && proj.allowedHosts && Array.isArray(proj.allowedHosts)) {
const parsed = new URL(url);
if (!proj.allowedHosts.includes(parsed.hostname)) {
throw new Error(`http: host "${parsed.hostname}" is not in allowedHosts. Allowed: ${proj.allowedHosts.join(', ')}`);
}
}
} catch (e) {
if (e.message.includes('allowedHosts')) throw e;
// If project config can't be loaded, allow all hosts
}
const startTime = Date.now();
// Build fetch options
const fetchOpts = {
method: method.toUpperCase(),
headers: { ...headers },
signal: AbortSignal.timeout(timeout),
redirect: followRedirects ? 'follow' : 'manual',
};
if (body && ['POST', 'PUT', 'PATCH'].includes(fetchOpts.method)) {
if (typeof body === 'object') {
fetchOpts.body = JSON.stringify(body);
if (!fetchOpts.headers['Content-Type'] && !fetchOpts.headers['content-type']) {
fetchOpts.headers['Content-Type'] = 'application/json';
}
} else {
fetchOpts.body = String(body);
}
}
const response = await fetch(url, fetchOpts);
const durationMs = Date.now() - startTime;
// Response size limit: 5MB
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024;
const responseText = await response.text();
const truncated = responseText.length > MAX_RESPONSE_SIZE;
const rawBody = truncated ? responseText.slice(0, MAX_RESPONSE_SIZE) : responseText;
// Parse body
let parsedBody;
if (responseType === 'json') {
try {
parsedBody = JSON.parse(rawBody);
} catch {
parsedBody = rawBody; // Fall back to text
}
} else {
parsedBody = rawBody;
}
// Strip HTML if extract: "text"
if (inputs.extract === 'text' && typeof parsedBody === 'string') {
const { stripHtml } = require('./readers');
parsedBody = stripHtml(parsedBody);
}
// Collect response headers
const respHeaders = {};
response.headers.forEach((value, key) => {
respHeaders[key] = value;
});
return {
status: response.status,
statusText: response.statusText,
headers: respHeaders,
body: parsedBody,
durationMs,
...(truncated && { warning: 'Response truncated at 5MB' }),
};
}
/**
* Resolve db and collection from inputs → defaults → project → vai config.
*/
function resolveDbAndCollection(inputs, defaults) {
const { loadProject } = require('./project');
const { getConfigValue } = require('./config');
const { config: proj } = loadProject();
return {
db: inputs.db || defaults.db || proj.db || process.env.VAI_DEFAULT_DB || getConfigValue('defaultDb'),
collection: inputs.collection || defaults.collection || proj.collection || process.env.VAI_DEFAULT_COLLECTION || getConfigValue('defaultCollection'),
};
}
/**
* Execute a MongoDB aggregation pipeline step.
*
* @param {object} inputs - { db?, collection?, pipeline, allowWrites? }
* @param {object} defaults - workflow defaults
* @returns {Promise<{ results: any[], count: number, durationMs: number }>}
*/
async function executeAggregate(inputs, defaults) {
const { getMongoCollection } = require('./mongo');
const { db, collection } = resolveDbAndCollection(inputs, defaults);
const pipeline = inputs.pipeline;
const allowWrites = inputs.allowWrites || false;
if (!db) throw new Error('aggregate: database not specified');
if (!collection) throw new Error('aggregate: collection not specified');
if (!Array.isArray(pipeline)) throw new Error('aggregate: "pipeline" must be an array');
if (pipeline.length > 20) throw new Error('aggregate: pipeline limited to 20 stages');
// Block write stages unless explicitly allowed
if (!allowWrites) {
for (const stage of pipeline) {
const stageKey = Object.keys(stage)[0];
if (stageKey === '$out' || stageKey === '$merge') {
throw new Error(`aggregate: "${stageKey}" stage is not allowed without allowWrites: true`);
}
}
}
const startTime = Date.now();
const { client, collection: col } = await getMongoCollection(db, collection);
try {
const results = await col.aggregate(pipeline).toArray();
return {
results,
count: results.length,
durationMs: Date.now() - startTime,
};
} finally {
await client.close();
}
}
// ════════════════════════════════════════════════════════════════════
// VAI Tool Executors
// ════════════════════════════════════════════════════════════════════
/**
* Execute a vai query step (embed + vector search + optional rerank).
*/
async function executeQuery(inputs, defaults) {
const { generateEmbeddings, apiRequest } = require('./api');
const { getMongoCollection } = require('./mongo');
const { db, collection } = resolveDbAndCollection(inputs, defaults);
const model = inputs.model || defaults.model;
const query = inputs.query;
const limit = inputs.limit || 10;
const doRerank = inputs.rerank !== false;
if (!query) throw new Error('query: "query" input is required');
if (!db) throw new Error('query: database not specified (set in inputs, defaults, or vai config)');
if (!collection) throw new Error('query: collection not specified');
// Embed
const embOpts = { inputType: 'query' };
if (model) embOpts.model = model;
const embRes = await generateEmbeddings([query], embOpts);
const embedding = embRes.data[0].embedding;
// Vector search
const { client, collection: col } = await getMongoCollection(db, collection);
try {
const results = await col.aggregate([
{
$vectorSearch: {
index: 'vector_index',
path: 'embedding',
queryVector: embedding,
numCandidates: Math.min(limit * 10, 200),
limit,
},
},
{
$project: {
text: 1, content: 1, source: 1, metadata: 1,
score: { $meta: 'vectorSearchScore' },
},
},
]).toArray();
// Track embed usage
const _usage = [{ op: 'embed', model: embRes.model, tokens: embRes.usage?.total_tokens || 0 }];
// Rerank if requested and results exist
if (doRerank && results.length > 0) {
const documents = results.map(r => r.text || r.content || '');
const { DEFAULT_RERANK_MODEL } = require('./catalog');
const rerankRes = await apiRequest('/rerank', {
model: inputs.rerankModel || DEFAULT_RERANK_MODEL,
query,
documents,
});
_usage.push({ op: 'rerank', model: rerankRes.model || inputs.rerankModel || DEFAULT_RERANK_MODEL, tokens: rerankRes.usage?.total_tokens || 0 });
const reranked = (rerankRes.data || []).map(r => ({
...results[r.index],
score: r.relevance_score,
}));
return { results: reranked, resultCount: reranked.length, _usage };
}
return { results, resultCount: results.length, _usage };
} finally {
await client.close();
}
}
/**
* Execute a vai search step (embed + vector search, no rerank).
*/
async function executeSearch(inputs, defaults) {
return executeQuery({ ...inputs, rerank: false }, defaults);
}
/**
* Execute a vai rerank step.
*/
async function executeRerank(inputs) {
const { apiRequest } = require('./api');
const { DEFAULT_RERANK_MODEL } = require('./catalog');
const query = inputs.query;
const documents = inputs.documents;
const model = inputs.model || DEFAULT_RERANK_MODEL;
if (!query) throw new Error('rerank: "query" input is required');
if (!Array.isArray(documents)) throw new Error('rerank: "documents" must be an array');
// If documents are objects, extract text
const docTexts = documents.map(d =>
typeof d === 'string' ? d : (d.text || d.content || JSON.stringify(d))
);
const res = await apiRequest('/rerank', { model, query, documents: docTexts });
const results = (res.data || []).map(r => ({
...(typeof documents[r.index] === 'object' ? documents[r.index] : { text: documents[r.index] }),
score: r.relevance_score,
}));
const _usage = [{ op: 'rerank', model: res.model || model, tokens: res.usage?.total_tokens || 0 }];
return { results, resultCount: results.length, _usage };
}
/**
* Execute a vai embed step.
*/
async function executeEmbed(inputs, defaults) {
const { generateEmbeddings } = require('./api');
const text = inputs.text;
const model = inputs.model || defaults.model;
const inputType = inputs.inputType || 'query';
if (!text) throw new Error('embed: "text" input is required');
const opts = { inputType };
if (model) opts.model = model;
if (inputs.dimensions) opts.dimensions = inputs.dimensions;
const res = await generateEmbeddings([text], opts);
return {
embedding: res.data[0].embedding,
model: res.model,
dimensions: res.data[0].embedding.length,
_usage: [{ op: 'embed', model: res.model, tokens: res.usage?.total_tokens || 0 }],
};
}
/**
* Execute a vai similarity step.
*/
async function executeSimilarity(inputs, defaults) {
const { generateEmbeddings } = require('./api');
const { cosineSimilarity } = require('./math');
const { text1, text2 } = inputs;
const model = inputs.model || defaults.model;
if (!text1 || !text2) throw new Error('similarity: "text1" and "text2" are required');
const opts = { inputType: 'document' };
if (model) opts.model = model;
const res = await generateEmbeddings([text1, text2], opts);
const similarity = cosineSimilarity(res.data[0].embedding, res.data[1].embedding);
return {
similarity,
model: res.model,
_usage: [{ op: 'similarity', model: res.model, tokens: res.usage?.total_tokens || 0 }],
};
}
/**
* Execute a vai ingest step.
*/
async function executeIngest(inputs, defaults) {
const { generateEmbeddings } = require('./api');
const { getMongoCollection } = require('./mongo');
const { chunk } = require('./chunker');
const { db, collection } = resolveDbAndCollection(inputs, defaults);
const text = inputs.text;
const source = inputs.source || 'workflow-ingest';
const model = inputs.model || defaults.model;
if (!text) throw new Error('ingest: "text" input is required');
if (!db) throw new Error('ingest: database not specified');
if (!collection) throw new Error('ingest: collection not specified');
// Chunk text
const chunks = chunk(text, {
strategy: inputs.chunkStrategy || 'recursive',
size: inputs.chunkSize || 512,
});
// Embed chunks
const embOpts = { inputType: 'document' };
if (model) embOpts.model = model;
const embRes = await generateEmbeddings(chunks, embOpts);
// Build docs
const docs = chunks.map((chunkText, i) => ({
text: chunkText,
source,
embedding: embRes.data[i].embedding,
metadata: inputs.metadata || {},
createdAt: new Date(),
}));
// Insert
const { client, collection: col } = await getMongoCollection(db, collection);
try {
const result = await col.insertMany(docs);
// Auto-create vector search index if it doesn't exist
// Note: Atlas vector search indexes take a few seconds to become ready after creation.
// The search step may need a brief delay on first run.
let indexCreated = false;
try {
const indexes = await col.listSearchIndexes().toArray();
const hasVectorIndex = indexes.some(idx => idx.name === 'vector_index');
if (!hasVectorIndex) {
await col.createSearchIndex({
name: 'vector_index',
type: 'vectorSearch',
definition: {
fields: [{
type: 'vector',
path: 'embedding',
numDimensions: embRes.data[0].embedding.length,
similarity: 'cosine'
}]
}
});
indexCreated = true;
}
} catch (indexErr) {
// Ignore errors: index may already exist, or createSearchIndex may not be
// available on non-Atlas deployments
}
return {
insertedCount: result.insertedCount,
chunks: chunks.length,
source,
model: embRes.model,
indexCreated,
_usage: [{ op: 'ingest', model: embRes.model, tokens: embRes.usage?.total_tokens || 0 }],
};
} finally {
await client.close();
}
}
/**
* Execute a vai collections step.
*/
async function executeCollections(inputs, defaults) {
const { introspectCollections } = require('./workflow-utils');
const { db } = resolveDbAndCollection(inputs, defaults);
if (!db) throw new Error('collections: database not specified');
const collections = await introspectCollections(db);
return { collections, database: db };
}
/**
* Execute a vai models step.
*/
function executeModels(inputs) {
const { MODEL_CATALOG } = require('./catalog');
let models = MODEL_CATALOG.filter(m => !m.legacy && !m.unreleased);
const category = inputs.category || 'all';
if (category !== 'all') {
models = models.filter(m => m.type === category);
}
return {
models: models.map(m => ({
name: m.name,
type: m.type,
dimensions: m.dimensions,
price: m.price,
bestFor: m.bestFor,
})),
category,
};
}
/**
* Execute a vai explain step.
*/
function executeExplain(inputs) {
const { resolveConcept, getConcept } = require('./explanations');
const topic = inputs.topic;
if (!topic) throw new Error('explain: "topic" input is required');
const conceptKey = resolveConcept(topic);
if (!conceptKey) {
return { found: false, topic, text: `No explanation found for "${topic}"` };
}
const concept = getConcept(conceptKey);
return {
found: true,
topic: concept.title,
text: concept.content,
links: concept.links || [],
};
}
/**
* Execute a vai estimate step.
*/
function executeEstimate(inputs) {
const { MODEL_CATALOG } = require('./catalog');
const docs = inputs.docs || 1000;
const queries = inputs.queries || 0;
const months = inputs.months || 12;
const model = inputs.model || 'voyage-4-large';
const modelInfo = MODEL_CATALOG.find(m => m.name === model);
if (!modelInfo) {
return { error: `Unknown model: ${model}`, model };
}
const pricePerMToken = modelInfo.pricePerMToken || 0;
// Rough estimate: avg 500 tokens per document chunk
const avgTokensPerDoc = 500;
const embeddingCost = (docs * avgTokensPerDoc / 1_000_000) * pricePerMToken;
// Query cost (embedding queries)
const avgQueryTokens = 50;
const monthlyCost = (queries * avgQueryTokens / 1_000_000) * pricePerMToken;
const totalQueryCost = monthlyCost * months;
re