voyageai-cli
Version:
CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search
663 lines (596 loc) • 25.8 kB
JavaScript
;
const { validateWorkflow, buildExecutionPlan, buildDependencyGraph, ALL_TOOLS } = require('../../lib/workflow');
// ════════════════════════════════════════════════════════════════════
// Tool catalog: default inputs per tool
// ════════════════════════════════════════════════════════════════════
const TOOL_DEFAULTS = {
query: { query: '{{ inputs.query }}', collection: '{{ inputs.collection }}', limit: '{{ inputs.limit }}' },
search: { query: '{{ inputs.query }}', collection: '{{ inputs.collection }}', limit: '{{ inputs.limit }}' },
rerank: { query: '{{ inputs.query }}', documents: [], model: 'rerank-2.5' },
embed: { text: '{{ inputs.text }}' },
similarity: { text1: '{{ inputs.text1 }}', text2: '{{ inputs.text2 }}' },
ingest: { text: '{{ inputs.text }}', collection: '{{ inputs.collection }}' },
collections: {},
models: { category: 'all' },
explain: { topic: '{{ inputs.topic }}' },
estimate: { docs: '{{ inputs.docs }}', queries: '{{ inputs.queries }}', months: 12 },
generate: { prompt: '{{ inputs.prompt }}' },
template: { text: '' },
merge: { arrays: [], dedup: true },
filter: { array: [], condition: '' },
transform: { array: [], fields: [] },
conditional: { condition: '', then: [], else: [] },
loop: { items: [], as: 'item', step: {} },
chunk: { text: '{{ inputs.text }}', strategy: 'recursive', size: 512 },
aggregate: { pipeline: [] },
http: { url: '{{ inputs.url }}', method: 'GET' },
code_index: { source: '{{ inputs.source }}' },
code_search: { query: '{{ inputs.query }}' },
code_query: { query: '{{ inputs.query }}' },
code_find_similar: { code: '{{ inputs.code }}' },
code_status: {},
};
// ════════════════════════════════════════════════════════════════════
// Common workflow patterns
// ════════════════════════════════════════════════════════════════════
const PATTERNS = {
// Search then generate (RAG)
rag: {
tools: ['query', 'generate'],
inputs: {
query: { type: 'string', required: true, description: 'The question to answer' },
collection: { type: 'string', required: true, description: 'MongoDB collection with embedded documents' },
limit: { type: 'number', default: 5, description: 'Number of results to retrieve' },
},
steps: [
{ id: 'retrieve', tool: 'query', name: 'Retrieve relevant documents', inputs: { query: '{{ inputs.query }}', collection: '{{ inputs.collection }}', limit: '{{ inputs.limit }}' } },
{ id: 'answer', tool: 'generate', name: 'Generate answer from context', inputs: { prompt: 'Answer the following question using the provided context.\n\nQuestion: {{ inputs.query }}', context: '{{ retrieve.output.results }}' } },
],
output: { answer: '{{ answer.output.response }}', sources: '{{ retrieve.output.results }}' },
},
// Search + rerank
search_rerank: {
tools: ['search', 'rerank'],
inputs: {
query: { type: 'string', required: true, description: 'Search query' },
collection: { type: 'string', required: true, description: 'MongoDB collection' },
limit: { type: 'number', default: 10, description: 'Number of results' },
},
steps: [
{ id: 'search_step', tool: 'search', name: 'Vector search', inputs: { query: '{{ inputs.query }}', collection: '{{ inputs.collection }}', limit: 50 } },
{ id: 'rerank_step', tool: 'rerank', name: 'Rerank results', inputs: { query: '{{ inputs.query }}', documents: '{{ search_step.output.results }}', model: 'rerank-2.5' } },
],
output: { results: '{{ rerank_step.output.results }}' },
},
// Ingest pipeline
ingest_pipeline: {
tools: ['chunk', 'ingest'],
inputs: {
text: { type: 'string', required: true, description: 'Text content to ingest' },
collection: { type: 'string', required: true, description: 'Target collection' },
source: { type: 'string', default: 'manual', description: 'Source identifier' },
},
steps: [
{ id: 'chunk_step', tool: 'chunk', name: 'Chunk the text', inputs: { text: '{{ inputs.text }}', strategy: 'recursive', size: 512 } },
{ id: 'ingest_step', tool: 'ingest', name: 'Embed and store chunks', inputs: { text: '{{ inputs.text }}', collection: '{{ inputs.collection }}', source: '{{ inputs.source }}' } },
],
output: { chunks: '{{ chunk_step.output.totalChunks }}', inserted: '{{ ingest_step.output.insertedCount }}' },
},
// Compare embeddings
compare: {
tools: ['similarity'],
inputs: {
text1: { type: 'string', required: true, description: 'First text' },
text2: { type: 'string', required: true, description: 'Second text' },
},
steps: [
{ id: 'compare_step', tool: 'similarity', name: 'Compare text similarity', inputs: { text1: '{{ inputs.text1 }}', text2: '{{ inputs.text2 }}' } },
],
output: { similarity: '{{ compare_step.output.similarity }}' },
},
// Multi-search + merge + rerank
multi_search: {
tools: ['query', 'merge', 'rerank'],
inputs: {
query: { type: 'string', required: true, description: 'Search query' },
collection: { type: 'string', required: true, description: 'MongoDB collection' },
limit: { type: 'number', default: 10, description: 'Number of results' },
},
steps: [
{ id: 'search_broad', tool: 'query', name: 'Broad search', inputs: { query: '{{ inputs.query }}', collection: '{{ inputs.collection }}', limit: 20 } },
{ id: 'merge_results', tool: 'merge', name: 'Merge results', inputs: { arrays: ['{{ search_broad.output.results }}'], dedup: true } },
{ id: 'rerank_merged', tool: 'rerank', name: 'Rerank merged results', inputs: { query: '{{ inputs.query }}', documents: '{{ merge_results.output.results }}' } },
],
output: { results: '{{ rerank_merged.output.results }}' },
},
};
// ════════════════════════════════════════════════════════════════════
// Pattern matching
// ════════════════════════════════════════════════════════════════════
/**
* Match a set of requested tools to the best known pattern.
* @param {string[]} tools
* @returns {string|null} pattern key or null
*/
function matchPattern(tools) {
const toolSet = new Set(tools);
// Score each pattern by how many of its tools are present
let bestKey = null;
let bestScore = 0;
for (const [key, pattern] of Object.entries(PATTERNS)) {
const patternTools = new Set(pattern.tools);
let matches = 0;
for (const t of patternTools) {
if (toolSet.has(t)) matches++;
}
const score = matches / patternTools.size;
if (score > bestScore) {
bestScore = score;
bestKey = key;
}
}
return bestScore >= 0.5 ? bestKey : null;
}
// ════════════════════════════════════════════════════════════════════
// Workflow generation
// ════════════════════════════════════════════════════════════════════
/**
* Generate a workflow definition from a description and optional tools list.
*
* @param {object} params
* @param {string} params.description - Natural language description
* @param {string} [params.category] - Workflow category
* @param {string[]} [params.tools] - Explicit list of tools to use
* @returns {{ workflow: object, validation: object, executionPlan: string[][], dependencyGraph: object }}
*/
function generateWorkflow({ description, category, tools }) {
// Determine tools from description if not provided
const requestedTools = tools && tools.length > 0
? tools.filter(t => ALL_TOOLS.has(t))
: inferToolsFromDescription(description);
// Try to match a known pattern
const patternKey = matchPattern(requestedTools);
let workflow;
if (patternKey) {
workflow = buildFromPattern(patternKey, description, category, requestedTools);
} else {
workflow = buildFromToolList(description, category, requestedTools);
}
// Validate
const validationErrors = validateWorkflow(workflow);
const layers = validationErrors.length === 0
? buildExecutionPlan(workflow.steps)
: [];
// Build dependency info
let dependencyGraph = {};
if (validationErrors.length === 0) {
const graph = buildDependencyGraph(workflow.steps);
for (const [stepId, deps] of graph) {
dependencyGraph[stepId] = [...deps];
}
}
return {
workflow,
validation: {
valid: validationErrors.length === 0,
errors: validationErrors,
},
executionPlan: layers,
dependencyGraph,
};
}
/**
* Infer tools from a natural language description.
* @param {string} description
* @returns {string[]}
*/
function inferToolsFromDescription(description) {
const lower = description.toLowerCase();
const tools = [];
// Keywords to tool mapping
const keywords = {
query: ['search', 'query', 'find', 'retrieve', 'look up', 'lookup', 'rag', 'answer'],
generate: ['generate', 'summarize', 'answer', 'synthesize', 'write', 'compose', 'rag', 'explain answer'],
rerank: ['rerank', 're-rank', 'rank', 'sort by relevance', 'precision'],
embed: ['embed', 'embedding', 'vector', 'vectorize'],
similarity: ['similar', 'similarity', 'compare', 'distance'],
ingest: ['ingest', 'import', 'load', 'store', 'index document'],
chunk: ['chunk', 'split', 'segment', 'partition'],
merge: ['merge', 'combine', 'concat', 'join', 'union'],
filter: ['filter', 'exclude', 'include only', 'where'],
transform: ['transform', 'reshape', 'map', 'rename'],
template: ['template', 'format', 'compose text'],
conditional: ['conditional', 'if', 'branch', 'when'],
loop: ['loop', 'iterate', 'for each', 'batch'],
http: ['http', 'api', 'fetch', 'request', 'url', 'endpoint', 'webhook'],
estimate: ['cost', 'estimate', 'price', 'budget'],
models: ['model', 'models', 'list models', 'catalog'],
collections: ['collection', 'collections', 'list collections'],
aggregate: ['aggregate', 'pipeline', 'mongodb aggregate'],
search: ['full-text search', 'vector search'],
code_search: ['code search', 'search code', 'codebase search'],
code_index: ['code index', 'index code', 'index repo', 'index repository'],
code_query: ['code query', 'ask about code', 'codebase question'],
code_find_similar: ['find similar code', 'similar code', 'code clone'],
};
for (const [tool, kws] of Object.entries(keywords)) {
for (const kw of kws) {
if (lower.includes(kw)) {
tools.push(tool);
break;
}
}
}
// Default: if nothing matched, provide a basic query + generate (RAG) workflow
if (tools.length === 0) {
tools.push('query', 'generate');
}
// Deduplicate
return [...new Set(tools)];
}
/**
* Build a workflow from a known pattern.
*/
function buildFromPattern(patternKey, description, category, requestedTools) {
const pattern = PATTERNS[patternKey];
const slug = slugify(description);
const workflow = {
name: slug,
description,
version: '1.0.0',
inputs: { ...pattern.inputs },
defaults: {},
steps: pattern.steps.map(s => ({ ...s, inputs: { ...s.inputs } })),
output: { ...pattern.output },
};
// Add any extra requested tools not in the pattern as additional steps
const patternTools = new Set(pattern.tools);
const extras = requestedTools.filter(t => !patternTools.has(t));
for (const tool of extras) {
const stepId = `${tool}_step`;
const step = buildStepForTool(tool, stepId, workflow);
workflow.steps.push(step);
}
if (category) {
workflow.category = category;
}
return workflow;
}
/**
* Build a workflow from an explicit tool list with no pattern match.
*/
function buildFromToolList(description, category, requestedTools) {
const slug = slugify(description);
// Build inputs based on what tools need
const inputs = {};
const toolSet = new Set(requestedTools);
if (toolSet.has('query') || toolSet.has('search') || toolSet.has('code_search') || toolSet.has('code_query')) {
inputs.query = { type: 'string', required: true, description: 'Search query or question' };
}
if (toolSet.has('query') || toolSet.has('search') || toolSet.has('ingest')) {
inputs.collection = { type: 'string', required: true, description: 'MongoDB collection name' };
}
if (toolSet.has('query') || toolSet.has('search')) {
inputs.limit = { type: 'number', default: 10, description: 'Maximum results to return' };
}
if (toolSet.has('embed') || toolSet.has('chunk') || toolSet.has('ingest')) {
inputs.text = { type: 'string', required: true, description: 'Text content to process' };
}
if (toolSet.has('similarity')) {
inputs.text1 = { type: 'string', required: true, description: 'First text to compare' };
inputs.text2 = { type: 'string', required: true, description: 'Second text to compare' };
}
if (toolSet.has('http')) {
inputs.url = { type: 'string', required: true, description: 'URL for HTTP request' };
}
if (toolSet.has('estimate')) {
inputs.docs = { type: 'number', required: true, description: 'Number of documents' };
inputs.queries = { type: 'number', default: 0, description: 'Queries per month' };
}
if (toolSet.has('code_index')) {
inputs.source = { type: 'string', required: true, description: 'Path or URL of code to index' };
}
if (toolSet.has('code_find_similar')) {
inputs.code = { type: 'string', required: true, description: 'Code snippet to find similar implementations for' };
}
if (toolSet.has('explain')) {
inputs.topic = { type: 'string', required: true, description: 'Topic to explain' };
}
// Build steps in a sensible order
const steps = [];
const output = {};
// Data retrieval tools first
const orderedTools = orderTools(requestedTools);
let prevArrayStepId = null;
for (let i = 0; i < orderedTools.length; i++) {
const tool = orderedTools[i];
const stepId = orderedTools.filter((t, j) => j < i && t === tool).length > 0
? `${tool}_step_${i}`
: `${tool}_step`;
const step = buildStepForTool(tool, stepId, { inputs, steps }, prevArrayStepId);
steps.push(step);
// Track steps that output arrays for chaining
if (['query', 'search', 'merge', 'filter', 'transform', 'rerank'].includes(tool)) {
prevArrayStepId = stepId;
}
}
// Build output from last step(s)
if (steps.length > 0) {
const lastStep = steps[steps.length - 1];
if (lastStep.tool === 'generate') {
output.response = `{{ ${lastStep.id}.output.response }}`;
} else if (['query', 'search', 'rerank', 'merge', 'filter', 'transform'].includes(lastStep.tool)) {
output.results = `{{ ${lastStep.id}.output.results }}`;
} else {
output.result = `{{ ${lastStep.id}.output }}`;
}
}
const workflow = {
name: slug,
description,
version: '1.0.0',
inputs,
defaults: {},
steps,
output,
};
if (category) {
workflow.category = category;
}
return workflow;
}
/**
* Build a single step definition for a given tool.
*/
function buildStepForTool(tool, stepId, workflowContext, prevArrayStepId) {
const inputs = workflowContext.inputs || {};
const step = {
id: stepId,
tool,
name: humanizeTool(tool),
inputs: {},
};
switch (tool) {
case 'query':
step.inputs = { query: '{{ inputs.query }}', collection: '{{ inputs.collection }}', limit: '{{ inputs.limit }}' };
break;
case 'search':
step.inputs = { query: '{{ inputs.query }}', collection: '{{ inputs.collection }}', limit: '{{ inputs.limit }}' };
break;
case 'rerank':
step.inputs = {
query: '{{ inputs.query }}',
documents: prevArrayStepId ? `{{ ${prevArrayStepId}.output.results }}` : '{{ inputs.documents }}',
model: 'rerank-2.5',
};
break;
case 'embed':
step.inputs = { text: '{{ inputs.text }}' };
break;
case 'similarity':
step.inputs = { text1: '{{ inputs.text1 }}', text2: '{{ inputs.text2 }}' };
break;
case 'ingest':
step.inputs = { text: '{{ inputs.text }}', collection: '{{ inputs.collection }}' };
break;
case 'chunk':
step.inputs = { text: '{{ inputs.text }}', strategy: 'recursive', size: 512 };
break;
case 'generate':
step.inputs = {
prompt: `Based on the provided context, ${workflowContext.inputs?.query ? 'answer the question: {{ inputs.query }}' : 'generate a response.'}`,
};
if (prevArrayStepId) {
step.inputs.context = `{{ ${prevArrayStepId}.output.results }}`;
}
break;
case 'merge':
step.inputs = { arrays: prevArrayStepId ? [`{{ ${prevArrayStepId}.output.results }}`] : [], dedup: true };
break;
case 'filter':
step.inputs = {
array: prevArrayStepId ? `{{ ${prevArrayStepId}.output.results }}` : [],
condition: 'item.score > 0.5',
};
break;
case 'transform':
step.inputs = {
array: prevArrayStepId ? `{{ ${prevArrayStepId}.output.results }}` : [],
fields: ['text', 'score'],
};
break;
case 'template':
step.inputs = { text: 'Workflow result summary' };
break;
case 'conditional':
step.inputs = { condition: 'true', then: [], else: [] };
break;
case 'loop':
step.inputs = {
items: prevArrayStepId ? `{{ ${prevArrayStepId}.output.results }}` : [],
as: 'doc',
step: { tool: 'embed', inputs: { text: '{{ doc.text }}' } },
};
break;
case 'http':
step.inputs = { url: '{{ inputs.url }}', method: 'GET' };
break;
case 'estimate':
step.inputs = { docs: '{{ inputs.docs }}', queries: '{{ inputs.queries }}', months: 12 };
break;
case 'models':
step.inputs = { category: 'all' };
break;
case 'collections':
step.inputs = {};
break;
case 'explain':
step.inputs = { topic: '{{ inputs.topic }}' };
break;
case 'aggregate':
step.inputs = { collection: '{{ inputs.collection }}', pipeline: [] };
break;
case 'code_index':
step.inputs = { source: '{{ inputs.source }}' };
break;
case 'code_search':
step.inputs = { query: '{{ inputs.query }}' };
break;
case 'code_query':
step.inputs = { query: '{{ inputs.query }}' };
break;
case 'code_find_similar':
step.inputs = { code: '{{ inputs.code }}' };
break;
case 'code_status':
step.inputs = {};
break;
default:
step.inputs = TOOL_DEFAULTS[tool] || {};
}
return step;
}
/**
* Order tools in a sensible execution sequence.
*/
function orderTools(tools) {
const order = [
'collections', 'models', 'code_status',
'http',
'chunk', 'code_index',
'ingest',
'embed',
'query', 'search', 'code_search', 'code_query', 'code_find_similar',
'similarity',
'merge',
'filter', 'transform',
'rerank',
'aggregate',
'estimate', 'explain',
'template',
'conditional', 'loop',
'generate',
];
const orderMap = new Map(order.map((t, i) => [t, i]));
return [...tools].sort((a, b) => (orderMap.get(a) || 50) - (orderMap.get(b) || 50));
}
/**
* Create a human-readable name for a tool.
*/
function humanizeTool(tool) {
const names = {
query: 'Query documents', search: 'Vector search', rerank: 'Rerank results',
embed: 'Generate embedding', similarity: 'Compare similarity',
ingest: 'Ingest documents', chunk: 'Chunk text', generate: 'Generate response',
merge: 'Merge results', filter: 'Filter results', transform: 'Transform data',
template: 'Compose text', conditional: 'Conditional branch', loop: 'Loop over items',
http: 'HTTP request', estimate: 'Cost estimate', models: 'List models',
collections: 'List collections', explain: 'Explain topic', aggregate: 'Aggregation pipeline',
code_index: 'Index codebase', code_search: 'Search code', code_query: 'Query codebase',
code_find_similar: 'Find similar code', code_status: 'Code index status',
};
return names[tool] || tool;
}
/**
* Slugify a description into a workflow name.
*/
function slugify(text) {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50);
}
// ════════════════════════════════════════════════════════════════════
// Validate workflow tool
// ════════════════════════════════════════════════════════════════════
/**
* Validate a workflow definition and return structured results.
*
* @param {object} params
* @param {object} params.workflow - The workflow JSON definition
* @returns {{ valid: boolean, errors: string[], warnings: string[], layers: string[][], dependencyGraph: object }}
*/
function validateWorkflowTool({ workflow }) {
const errors = validateWorkflow(workflow);
let layers = [];
let dependencyGraph = {};
if (errors.length === 0 && Array.isArray(workflow.steps) && workflow.steps.length > 0) {
layers = buildExecutionPlan(workflow.steps);
const graph = buildDependencyGraph(workflow.steps);
for (const [stepId, deps] of graph) {
dependencyGraph[stepId] = [...deps];
}
}
// Separate warnings (non-blocking) from errors
// For now, validateWorkflow returns all as errors in strict mode
const warnings = [];
return {
valid: errors.length === 0,
errors,
warnings,
layers,
dependencyGraph,
};
}
// ════════════════════════════════════════════════════════════════════
// MCP Tool Registration
// ════════════════════════════════════════════════════════════════════
/**
* Handler for vai_generate_workflow MCP tool.
*/
async function handleGenerateWorkflow(input) {
const result = generateWorkflow(input);
const summary = result.validation.valid
? `Generated valid workflow "${result.workflow.name}" with ${result.workflow.steps.length} steps across ${result.executionPlan.length} execution layers.`
: `Generated workflow "${result.workflow.name}" with ${result.validation.errors.length} validation error(s). Review and fix the errors before running.`;
return {
structuredContent: result,
content: [{
type: 'text',
text: `${summary}\n\nWorkflow JSON:\n${JSON.stringify(result.workflow, null, 2)}\n\nExecution Plan: ${JSON.stringify(result.executionPlan)}\n\nValidation: ${result.validation.valid ? 'PASSED' : 'FAILED - ' + result.validation.errors.join('; ')}`,
}],
};
}
/**
* Handler for vai_validate_workflow MCP tool.
*/
async function handleValidateWorkflow(input) {
const result = validateWorkflowTool(input);
const summary = result.valid
? `Workflow is valid. ${result.layers.length} execution layer(s), ${Object.keys(result.dependencyGraph).length} step(s).`
: `Workflow has ${result.errors.length} error(s).`;
return {
structuredContent: result,
content: [{
type: 'text',
text: `${summary}\n\nErrors: ${result.errors.length > 0 ? result.errors.join('\n') : 'None'}\nWarnings: ${result.warnings.length > 0 ? result.warnings.join('\n') : 'None'}\nExecution Layers: ${JSON.stringify(result.layers)}\nDependency Graph: ${JSON.stringify(result.dependencyGraph, null, 2)}`,
}],
};
}
/**
* Register authoring tools on the MCP server.
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
* @param {object} schemas
*/
function registerAuthoringTools(server, schemas) {
server.tool(
'vai_generate_workflow',
'Generate a complete, executable vai workflow JSON from a natural language description. Returns the workflow definition, validation results, and execution plan. The generated workflow uses template expressions for step inputs and follows all vai workflow conventions.',
schemas.generateWorkflowSchema,
handleGenerateWorkflow
);
server.tool(
'vai_validate_workflow',
'Validate a vai workflow JSON definition. Checks for structural errors, unknown tools, circular dependencies, and missing references. Returns validation errors, warnings, execution plan layers, and the dependency graph.',
schemas.validateWorkflowSchema,
handleValidateWorkflow
);
}
module.exports = {
registerAuthoringTools,
handleGenerateWorkflow,
handleValidateWorkflow,
generateWorkflow,
validateWorkflowTool,
};