Skip to main content
Glama
relay-workflow-validate.ts8.53 kB
/** * relay_workflow_validate Tool * * Validate workflow structure without making any LLM calls (free). * Checks DAG structure (no cycles), dependency references, and model ID format. */ import { z } from 'zod'; import { PRICING } from '../budget/estimator.js'; const workflowStepSchema = z.object({ name: z.string(), model: z.string().optional(), prompt: z.string().optional(), systemPrompt: z.string().optional(), depends: z.array(z.string()).optional(), mcp: z.string().optional(), params: z.object({}).passthrough().optional(), schema: z.object({}).passthrough().optional(), }); export const relayWorkflowValidateSchema = z.object({ steps: z.array(workflowStepSchema).describe('Steps to validate (same format as relay_workflow_run)'), }); export type RelayWorkflowValidateInput = z.infer<typeof relayWorkflowValidateSchema>; export interface ValidationError { step: string; field: string; message: string; } export interface ValidationWarning { step: string; message: string; } export interface RelayWorkflowValidateResponse { valid: boolean; errors: ValidationError[]; warnings: ValidationWarning[]; structure: { totalSteps: number; executionOrder: string[]; parallelGroups: string[][]; }; } /** * Validate model ID format */ function isValidModelFormat(model: string): boolean { const parts = model.split(':'); if (parts.length !== 2) return false; const [provider, modelId] = parts; const validProviders = ['openai', 'anthropic', 'google', 'xai', 'local']; return validProviders.includes(provider) && modelId.length > 0; } /** * Check if model is known (has pricing) */ function isKnownModel(model: string): boolean { return model in PRICING; } /** * Topological sort with cycle detection */ function topologicalSort( steps: Array<{ name: string; depends?: string[] }> ): { order: string[]; hasCycle: boolean; cycleStep?: string } { const stepNames = new Set(steps.map(s => s.name)); const order: string[] = []; const visited = new Set<string>(); const visiting = new Set<string>(); let cycleStep: string | undefined; function visit(stepName: string): boolean { if (visited.has(stepName)) return true; if (visiting.has(stepName)) { cycleStep = stepName; return false; } visiting.add(stepName); const step = steps.find(s => s.name === stepName); if (step?.depends) { for (const dep of step.depends) { if (!visit(dep)) return false; } } visiting.delete(stepName); visited.add(stepName); order.push(stepName); return true; } for (const step of steps) { if (!visit(step.name)) { return { order: [], hasCycle: true, cycleStep }; } } return { order, hasCycle: false }; } /** * Calculate parallel execution groups */ function calculateParallelGroups( steps: Array<{ name: string; depends?: string[] }> ): string[][] { const stepMap = new Map(steps.map(s => [s.name, s])); const levels = new Map<string, number>(); function getLevel(stepName: string): number { if (levels.has(stepName)) return levels.get(stepName)!; const step = stepMap.get(stepName); if (!step?.depends?.length) { levels.set(stepName, 0); return 0; } const maxDepLevel = Math.max(...step.depends.map(d => getLevel(d))); const level = maxDepLevel + 1; levels.set(stepName, level); return level; } // Calculate levels for all steps for (const step of steps) { getLevel(step.name); } // Group by level const groups: string[][] = []; const maxLevel = Math.max(...levels.values(), -1); for (let i = 0; i <= maxLevel; i++) { const group: string[] = []; for (const [name, level] of levels) { if (level === i) group.push(name); } if (group.length > 0) groups.push(group); } return groups; } export async function relayWorkflowValidate( input: RelayWorkflowValidateInput ): Promise<RelayWorkflowValidateResponse> { const errors: ValidationError[] = []; const warnings: ValidationWarning[] = []; const stepNames = new Set(input.steps.map(s => s.name)); // Check for duplicate step names const nameCount = new Map<string, number>(); for (const step of input.steps) { nameCount.set(step.name, (nameCount.get(step.name) || 0) + 1); } for (const [name, count] of nameCount) { if (count > 1) { errors.push({ step: name, field: 'name', message: `Duplicate step name: "${name}" appears ${count} times`, }); } } // Validate each step for (const step of input.steps) { // Check step name if (!step.name || step.name.trim() === '') { errors.push({ step: '(unnamed)', field: 'name', message: 'Step name is required', }); continue; } // Check for valid step configuration const hasModel = !!step.model; const hasPrompt = !!step.prompt; const hasMcp = !!step.mcp; if (hasModel && !hasPrompt) { errors.push({ step: step.name, field: 'prompt', message: 'Steps with a model must have a prompt', }); } if (hasPrompt && !hasModel) { errors.push({ step: step.name, field: 'model', message: 'Steps with a prompt must have a model', }); } if (!hasModel && !hasMcp) { warnings.push({ step: step.name, message: 'Step has no model or MCP tool - it will be a pass-through step', }); } // Validate model format if (hasModel) { if (!isValidModelFormat(step.model!)) { errors.push({ step: step.name, field: 'model', message: `Invalid model format: "${step.model}". Expected "provider:model-id" (e.g., "openai:gpt-4o")`, }); } else if (!isKnownModel(step.model!)) { warnings.push({ step: step.name, message: `Unknown model "${step.model}" - using default pricing estimate`, }); } } // Validate MCP format if (hasMcp) { const parts = step.mcp!.split(':'); if (parts.length !== 2) { errors.push({ step: step.name, field: 'mcp', message: `Invalid MCP tool format: "${step.mcp}". Expected "server:tool" (e.g., "crm:search")`, }); } } // Validate dependencies exist if (step.depends) { for (const dep of step.depends) { if (!stepNames.has(dep)) { errors.push({ step: step.name, field: 'depends', message: `Dependency "${dep}" not found in workflow steps`, }); } if (dep === step.name) { errors.push({ step: step.name, field: 'depends', message: 'Step cannot depend on itself', }); } } } } // Check for cycles const { order, hasCycle, cycleStep } = topologicalSort(input.steps); if (hasCycle) { errors.push({ step: cycleStep || '(unknown)', field: 'depends', message: `Circular dependency detected involving step: ${cycleStep}`, }); } // Calculate parallel groups const parallelGroups = hasCycle ? [] : calculateParallelGroups(input.steps); return { valid: errors.length === 0, errors, warnings, structure: { totalSteps: input.steps.length, executionOrder: hasCycle ? [] : order, parallelGroups, }, }; } export const relayWorkflowValidateDefinition = { name: 'relay_workflow_validate', description: 'Validate workflow structure without making any LLM calls (free). Checks DAG structure (no cycles), dependency references, and model ID format. Does NOT validate schema compatibility between steps or prompt effectiveness - use relay_workflow_run for full validation.', inputSchema: { type: 'object' as const, properties: { steps: { type: 'array', description: 'Steps to validate (same format as relay_workflow_run)', items: { type: 'object', properties: { name: { type: 'string' }, model: { type: 'string' }, prompt: { type: 'string' }, systemPrompt: { type: 'string' }, depends: { type: 'array', items: { type: 'string' } }, mcp: { type: 'string' }, params: { type: 'object' }, schema: { type: 'object' }, }, required: ['name'], }, }, }, required: ['steps'], }, };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/RelayPlane/mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server