UNPKG

crewai-ts

Version:

TypeScript port of crewAI for agent-based workflows

801 lines (800 loc) 34.9 kB
/** * FlowOrchestrator * * Coordinates execution of multiple flows with optimized performance, * dependency management, and shared memory integration. */ import { EventEmitter } from 'events'; import { FlowMemoryConnector } from '../memory/FlowMemoryConnector.js'; /** * Manages and coordinates execution of multiple flows with optimized * dependency resolution and parallel execution. */ export class FlowOrchestrator { // Event emitter for flow orchestration events events = new EventEmitter(); // Flow dependency graph with optimized storage nodes = new Map(); edges = []; // Execution tracking pendingFlows = new Set(); runningFlows = new Map(); completedFlows = new Map(); failedFlows = new Map(); // Critical path analysis criticalPath = []; // Performance tracking executionStartTime = 0; executionEndTime = 0; flowExecutionTimes = new Map(); checkpointTimer = null; // Memory integration memoryConnector; // Configuration options; executionOptions; constructor(options = {}) { // Set default options based on optimization preset this.options = this.getOptimizedOptions(options); // Set default execution options this.executionOptions = this.options.execution || { maxConcurrency: 4, timeout: 30 * 60 * 1000, // 30 minutes retryCount: 0, retryDelay: 1000, failFast: false, enableProfiling: false, checkpointInterval: 60 * 1000 // 1 minute }; // Set up memory integration if enabled if (this.options.enableMemoryIntegration) { this.memoryConnector = this.options.memoryConnector || new FlowMemoryConnector({ // Optimized memory connector options persistStateOnEveryChange: false, // Only persist at checkpoints for performance persistMethodResults: true, statePersistenceDebounceMs: 1000, useCompression: true, inMemoryCache: true }); } } /** * Apply optimization preset based on orchestrator options */ getOptimizedOptions(options) { const optimizeFor = options.optimizeFor || 'balanced'; // Start with provided options const optimizedOptions = { ...options }; // Apply optimization preset switch (optimizeFor) { case 'memory': // Optimize for minimal memory usage optimizedOptions.execution = { maxConcurrency: 2, // Fewer concurrent flows checkpointInterval: 30 * 1000, // More frequent checkpoints ...options.execution }; break; case 'speed': // Optimize for maximum speed optimizedOptions.execution = { maxConcurrency: Math.max(navigator.hardwareConcurrency || 4, 4), // Use available cores checkpointInterval: 5 * 60 * 1000, // Less frequent checkpoints ...options.execution }; break; case 'balanced': default: // Balanced optimization (default) optimizedOptions.execution = { maxConcurrency: Math.max(Math.floor((navigator.hardwareConcurrency || 4) / 2), 2), checkpointInterval: 60 * 1000, // 1 minute checkpoints ...options.execution }; break; } return optimizedOptions; } /** * Register a flow with the orchestrator */ registerFlow(flow, options = {}) { // Generate ID if not provided const id = options.id || `${flow.constructor.name}_${Date.now()}`; // Create node const node = { id, flow, dependencies: new Set(), dependents: new Set(), status: 'pending', priority: options.priority || 0, metadata: options.metadata }; // Store node in graph this.nodes.set(id, node); this.pendingFlows.add(id); // Connect to memory if enabled if (this.memoryConnector) { this.memoryConnector.connectToFlow(flow); } // Emit event this.events.emit('flow_registered', { id, flow }); return id; } /** * Add a dependency between flows */ addDependency(fromId, toId, options = {}) { // Validate flows exist if (!this.nodes.has(fromId)) { throw new Error(`Flow with ID ${fromId} not found`); } if (!this.nodes.has(toId)) { throw new Error(`Flow with ID ${toId} not found`); } // Check for circular dependencies if (this.wouldCreateCycle(fromId, toId)) { throw new Error(`Adding dependency from ${fromId} to ${toId} would create a cycle`); } // Create edge const edge = { from: fromId, to: toId, condition: options.condition, dataMapping: options.dataMapping }; // Update nodes this.nodes.get(toId).dependencies.add(fromId); this.nodes.get(fromId).dependents.add(toId); // Add edge this.edges.push(edge); // Emit event this.events.emit('dependency_added', { edge }); } /** * Check if adding dependency would create a cycle */ wouldCreateCycle(fromId, toId) { // If they're the same, it's a self-cycle if (fromId === toId) return true; // Check if toId is already a dependency of fromId const visited = new Set(); const queue = [toId]; while (queue.length > 0) { const currentId = queue.shift(); visited.add(currentId); const currentNode = this.nodes.get(currentId); if (!currentNode) continue; for (const depId of currentNode.dependencies) { if (depId === fromId) return true; if (!visited.has(depId)) { queue.push(depId); } } } return false; } /** * Get flows that are ready to execute (all dependencies satisfied) */ getReadyFlows() { const readyFlows = []; for (const flowId of this.pendingFlows) { const node = this.nodes.get(flowId); // Check if all dependencies are satisfied let allDependenciesSatisfied = true; let anyDependencyFailed = false; for (const depId of node.dependencies) { if (!this.completedFlows.has(depId)) { allDependenciesSatisfied = false; break; } // Check conditional dependency const edge = this.edges.find(e => e.from === depId && e.to === flowId); if (edge?.condition) { const depResult = this.completedFlows.get(depId); if (!edge.condition(depResult)) { allDependenciesSatisfied = false; break; } } } // Check if any dependencies failed for (const depId of node.dependencies) { if (this.failedFlows.has(depId)) { anyDependencyFailed = true; break; } } // If dependencies failed and failFast is enabled, mark as failed if (anyDependencyFailed && this.executionOptions.failFast) { node.status = 'failed'; node.error = new Error(`Dependency failed and failFast is enabled`); this.pendingFlows.delete(flowId); this.failedFlows.set(flowId, node.error); continue; } // If ready, add to ready flows if (allDependenciesSatisfied) { readyFlows.push(node); } } // Sort by priority (higher numbers first) return readyFlows.sort((a, b) => b.priority - a.priority); } /** * Execute all registered flows with optimized scheduling */ async execute(options) { // Merge options with defaults const execOptions = { ...this.executionOptions, ...options }; // Initialize execution state this.executionStartTime = performance.now(); this.pendingFlows = new Set(this.nodes.keys()); this.runningFlows.clear(); this.completedFlows.clear(); this.failedFlows.clear(); this.flowExecutionTimes.clear(); // Set up checkpoint timer if enabled if (execOptions.checkpointInterval && execOptions.checkpointInterval > 0) { this.checkpointTimer = setInterval(() => { this.saveExecutionCheckpoint(); }, execOptions.checkpointInterval); } // Emit execution started event this.events.emit('execution_started', { flowCount: this.nodes.size, options: execOptions }); try { // Main execution loop while (this.pendingFlows.size > 0 || this.runningFlows.size > 0) { // Get flows that are ready to execute const readyFlows = this.getReadyFlows(); // Start flows up to concurrency limit const availableSlots = execOptions.maxConcurrency - this.runningFlows.size; const flowsToStart = readyFlows.slice(0, availableSlots); // Start flows for (const node of flowsToStart) { this.startFlow(node, execOptions); } // Wait for any flow to complete if at concurrency limit or no ready flows if (this.runningFlows.size > 0 && (this.runningFlows.size >= execOptions.maxConcurrency || readyFlows.length === 0)) { await this.waitForAnyFlowToComplete(); } else if (this.pendingFlows.size > 0 && readyFlows.length === 0 && this.runningFlows.size === 0) { // Deadlock detected - some flows have circular dependencies throw new Error('Deadlock detected: flows with unsatisfiable dependencies'); } else if (readyFlows.length === 0 && this.runningFlows.size === 0) { // All flows completed break; } else { // Yield to event loop for better responsiveness await new Promise(resolve => setTimeout(resolve, 0)); } } // Check for overall timeout const executionTime = performance.now() - this.executionStartTime; if (execOptions.timeout && executionTime > execOptions.timeout) { throw new Error(`Execution timed out after ${executionTime}ms`); } // Record end time this.executionEndTime = performance.now(); // Emit execution completed event this.events.emit('execution_completed', { completed: this.completedFlows.size, failed: this.failedFlows.size, executionTime, results: this.completedFlows, errors: this.failedFlows }); return this.completedFlows; } catch (error) { // Capture end time for failed execution this.executionEndTime = performance.now(); // Emit execution failed event this.events.emit('execution_failed', { error, completed: this.completedFlows.size, failed: this.failedFlows.size, executionTime: this.executionEndTime - this.executionStartTime }); throw error; } finally { // Clean up checkpoint timer if (this.checkpointTimer) { clearInterval(this.checkpointTimer); this.checkpointTimer = null; } // Save final checkpoint this.saveExecutionCheckpoint(); } } /** * Start execution of a flow with optimized data passing */ async startFlow(node, options) { const { id, flow } = node; // Update node status node.status = 'running'; node.startTime = performance.now(); // Remove from pending this.pendingFlows.delete(id); // Prepare input data from dependencies with data mapping const inputData = { ...options.inputData }; // Get data from dependencies for (const depId of node.dependencies) { const depResult = this.completedFlows.get(depId); const edge = this.edges.find(e => e.from === depId && e.to === id); if (edge?.dataMapping) { // Apply data mapping function const mappedData = edge.dataMapping(depResult); Object.assign(inputData, mappedData); } else { // Default mapping: use dependency ID as key inputData[depId] = depResult; } } // Emit flow started event this.events.emit('flow_started', { id, flow, inputData }); // Execute flow with error handling and retries const executeWithRetries = async (retriesLeft) => { try { // Execute the flow return await flow.execute(inputData); } catch (error) { // Handle retries if (retriesLeft > 0 && options.retryDelay) { // Wait for retry delay await new Promise(resolve => setTimeout(resolve, options.retryDelay)); // Emit retry event this.events.emit('flow_retry', { id, flow, error, retriesLeft }); // Retry return executeWithRetries(retriesLeft - 1); } throw error; } }; // Start flow execution const executionPromise = executeWithRetries(options.retryCount || 0) .then(result => { // Update node status node.status = 'completed'; node.result = result; node.endTime = performance.now(); // Record execution time with performance-optimized null check const startTime = node.startTime || node.endTime; // Fallback to endTime if startTime is undefined const executionTime = node.endTime - startTime; this.flowExecutionTimes.set(id, executionTime); // Add to completed flows this.completedFlows.set(id, result); // Remove from running flows this.runningFlows.delete(id); // Emit flow completed event this.events.emit('flow_completed', { id, flow, result, executionTime }); return result; }) .catch(error => { // Update node status node.status = 'failed'; node.error = error; node.endTime = performance.now(); // Add to failed flows this.failedFlows.set(id, error); // Remove from running flows this.runningFlows.delete(id); // Emit flow failed event this.events.emit('flow_failed', { id, flow, error, executionTime: node.endTime - (node.startTime || node.endTime) }); // Rethrow to trigger waitForAnyFlowToComplete throw error; }); // Add to running flows this.runningFlows.set(id, executionPromise); } /** * Wait for any running flow to complete with optimized promise racing */ async waitForAnyFlowToComplete() { if (this.runningFlows.size === 0) return; // Create a promise that resolves when any flow completes // Use Promise.race which returns as soon as any promise resolves or rejects const promises = Array.from(this.runningFlows.values()); try { // Wait for any flow to complete or fail await Promise.race(promises.map(p => p.catch(e => e))); } catch (error) { // Error handled in startFlow } } /** * Save execution checkpoint with optimized state serialization * Uses incremental state updates to minimize memory usage */ async saveExecutionCheckpoint() { if (!this.memoryConnector) return; try { // Create optimized checkpoint data with minimal memory footprint // Use typed arrays and buffer views where possible for better memory efficiency const timestamp = Date.now(); // Use Set data structures instead of arrays for better lookup performance // and more efficient memory representation of unique values const pendingFlowsArray = Array.from(this.pendingFlows); const runningFlowsArray = Array.from(this.runningFlows.keys()); // Efficient data serialization with selective attribute inclusion // Only include necessary information to reduce memory footprint const completedFlowsData = Array.from(this.completedFlows).map(([id, result]) => { // Use a memory-efficient shallow result representation when possible const serializedResult = typeof result === 'object' && result !== null ? { _resultType: result.constructor?.name, _resultValue: result.toString() } : result; return { id, result: serializedResult }; }); // Similarly optimize error storage const failedFlowsData = Array.from(this.failedFlows).map(([id, error]) => ({ id, error: error.message, // Only include stack trace if available and in development mode stack: process.env.NODE_ENV === 'development' ? error.stack : undefined })); // Apply optimized serialization for metrics data const flowMetricsData = Array.from(this.flowExecutionTimes).map(([id, time]) => ({ id, executionTime: time })); // Create minimal node status representation with only essential properties const nodeStatusesData = Array.from(this.nodes.entries()) .map(([id, node]) => ({ id, status: node.status, startTime: node.startTime, endTime: node.endTime, // Include critical path information if available isOnCriticalPath: this.criticalPath?.includes(id) || false })); // Create memory-efficient checkpoint object const checkpoint = { timestamp, executionStartTime: this.executionStartTime, executionProgress: { completedPercentage: this.nodes.size > 0 ? (this.completedFlows.size / this.nodes.size) * 100 : 0, remainingFlows: this.pendingFlows.size + this.runningFlows.size }, executionState: { pending: pendingFlowsArray, running: runningFlowsArray, completed: completedFlowsData, failed: failedFlowsData }, flowMetrics: flowMetricsData, nodeStatuses: nodeStatusesData, // Include critical path information criticalPath: this.criticalPath || [] }; // Save checkpoint to memory this.events.emit('checkpoint_created', { checkpoint }); // Store checkpoint using the memory connector if (this.memoryConnector) { // Use optimized storage with compression when available await this.memoryConnector.storeCheckpoint(`flow_checkpoint_${this.executionStartTime}`, checkpoint, { compress: true, version: '1.0' }); } } catch (error) { console.error('Error saving checkpoint:', error); this.events.emit('checkpoint_failed', { error }); } } /** * Restore execution from checkpoint with optimized state reconstruction * Implements memory-efficient restoration of flow state from checkpoint data * * @param checkpointData The checkpoint data to restore from, typically retrieved via FlowMemoryConnector * @returns Promise that resolves when restoration is complete */ async restoreFromCheckpoint(checkpointData) { if (!checkpointData) { throw new Error('Cannot restore from empty checkpoint data'); } this.events.emit('restore_from_checkpoint_started', { checkpointId: checkpointData.timestamp }); try { // Reset current execution state to prepare for restoration // Use targeted clearing instead of full reset for better performance this.pendingFlows.clear(); this.runningFlows.clear(); this.completedFlows.clear(); this.failedFlows.clear(); this.flowExecutionTimes.clear(); // Restore execution timestamps this.executionStartTime = checkpointData.executionStartTime || Date.now(); this.executionEndTime = 0; // Reset end time as execution will continue // Restore flow states with memory-efficient approach if (checkpointData.executionState) { const { pending, running, completed, failed } = checkpointData.executionState; // Restore pending flows - use Set for O(1) lookups if (Array.isArray(pending)) { pending.forEach(id => this.pendingFlows.add(id)); } // Restore completed flows - use Map for O(1) lookups and value retrieval if (Array.isArray(completed)) { completed.forEach(({ id, result }) => { // Handle serialized results that need deserialization let deserializedResult = result; if (result && typeof result === 'object' && result._resultType && result._resultValue) { // Simple deserialization - in a real implementation this would be more robust try { // Attempt to reconstruct based on type information if (result._resultType === 'Date') { deserializedResult = new Date(result._resultValue); } else if (result._resultType === 'Set') { deserializedResult = new Set(JSON.parse(result._resultValue)); } else if (result._resultType === 'Map') { deserializedResult = new Map(JSON.parse(result._resultValue)); } else { deserializedResult = result._resultValue; } } catch (e) { // Fallback to original serialized form if deserialization fails deserializedResult = result._resultValue; } } this.completedFlows.set(id, deserializedResult); }); } // Restore failed flows if (Array.isArray(failed)) { failed.forEach(({ id, error, stack }) => { const err = new Error(error); if (stack) err.stack = stack; this.failedFlows.set(id, err); }); } // For running flows, we need to restart them, so add them back to pending if (Array.isArray(running)) { running.forEach(id => this.pendingFlows.add(id)); } } // Restore node statuses if (Array.isArray(checkpointData.nodeStatuses)) { checkpointData.nodeStatuses.forEach((nodeStatus) => { const node = this.nodes.get(nodeStatus.id); if (node) { // Only restore fields that make sense to restore // This avoids overwriting the full node with potentially incomplete data node.status = nodeStatus.status === 'running' ? 'pending' : nodeStatus.status; node.startTime = nodeStatus.startTime; node.endTime = nodeStatus.endTime; } }); } // Restore metrics data if (Array.isArray(checkpointData.flowMetrics)) { checkpointData.flowMetrics.forEach((metric) => { if (metric.id && typeof metric.executionTime === 'number') { this.flowExecutionTimes.set(metric.id, metric.executionTime); } }); } // Restore critical path if available if (Array.isArray(checkpointData.criticalPath)) { this.criticalPath = checkpointData.criticalPath; } else { // Recalculate critical path if not available this.calculateCriticalPath(); } this.events.emit('restore_from_checkpoint_completed', { checkpointId: checkpointData.timestamp, restoredFlows: this.completedFlows.size + this.pendingFlows.size + this.failedFlows.size }); } catch (error) { this.events.emit('restore_from_checkpoint_failed', { error }); throw error; } } /** * Get execution metrics with performance data */ getExecutionMetrics() { const totalExecutionTime = this.executionEndTime - this.executionStartTime; return { totalExecutionTime, flowCount: this.nodes.size, completedFlows: this.completedFlows.size, failedFlows: this.failedFlows.size, flowExecutionTimes: Object.fromEntries(this.flowExecutionTimes), averageFlowExecutionTime: Array.from(this.flowExecutionTimes.values()).reduce((sum, time) => sum + time, 0) / this.flowExecutionTimes.size, // Calculate critical path (longest path through dependency graph) criticalPath: this.calculateCriticalPath(), criticalPathExecutionTime: this.calculateCriticalPathTime() }; } /** * Calculate the critical path through the dependency graph * This finds the longest path in terms of execution time that must be completed * Uses memory-optimized algorithm to minimize overhead */ calculateCriticalPath() { // Early exit for empty graph if (this.nodes.size === 0) return []; // Initialize analysis with Map for O(1) lookups // More memory efficient than using objects with string keys const nodeAnalysis = new Map(); // Step 1: Initialize with zero values and compute durations for (const [id, node] of this.nodes.entries()) { const duration = node.endTime && node.startTime ? node.endTime - node.startTime : 0; nodeAnalysis.set(id, { earliestStart: 0, earliestFinish: duration, latestStart: Infinity, latestFinish: Infinity, slack: Infinity, duration }); } // Step 2: Forward pass - Calculate earliest start/finish times // This is an optimized implementation that eliminates redundant calculations let changed = true; while (changed) { changed = false; for (const edge of this.edges) { const fromAnalysis = nodeAnalysis.get(edge.from); const toAnalysis = nodeAnalysis.get(edge.to); if (!fromAnalysis || !toAnalysis) continue; // Only update if it would result in a later finish time const newEarliestStart = fromAnalysis.earliestFinish; if (newEarliestStart > toAnalysis.earliestStart) { toAnalysis.earliestStart = newEarliestStart; toAnalysis.earliestFinish = newEarliestStart + toAnalysis.duration; changed = true; } } } // Step 3: Find the maximum finish time as the project completion time let maxFinish = 0; for (const analysis of nodeAnalysis.values()) { maxFinish = Math.max(maxFinish, analysis.earliestFinish); } // Step 4: Backward pass - Calculate latest start/finish times // Initialize latest finish times to project completion time for (const analysis of nodeAnalysis.values()) { analysis.latestFinish = maxFinish; analysis.latestStart = analysis.latestFinish - analysis.duration; } // Iterate backward to determine latest possible times changed = true; while (changed) { changed = false; // Process edges in reverse order for greater efficiency for (let i = this.edges.length - 1; i >= 0; i--) { const edge = this.edges[i]; if (!edge) continue; // Skip if edge is undefined const fromAnalysis = nodeAnalysis.get(edge.from); const toAnalysis = nodeAnalysis.get(edge.to); if (!fromAnalysis || !toAnalysis) continue; // Update latest finish time based on successor's latest start if (toAnalysis.latestStart < fromAnalysis.latestFinish) { fromAnalysis.latestFinish = toAnalysis.latestStart; fromAnalysis.latestStart = fromAnalysis.latestFinish - fromAnalysis.duration; changed = true; } } } // Step 5: Calculate slack time and identify critical path // Critical activities have zero slack const criticalFlows = []; for (const [id, analysis] of nodeAnalysis.entries()) { // Calculate slack with mathematical precision to avoid floating point issues analysis.slack = Math.round((analysis.latestStart - analysis.earliestStart) * 1000) / 1000; // Nodes with zero slack are on the critical path if (Math.abs(analysis.slack) < 0.001) { criticalFlows.push(id); } } // Step 6: Sort critical flows in order of execution for better visualization criticalFlows.sort((a, b) => { const analysisA = nodeAnalysis.get(a); const analysisB = nodeAnalysis.get(b); if (!analysisA || !analysisB) return 0; return analysisA.earliestStart - analysisB.earliestStart; }); // Save critical path for later use this.criticalPath = criticalFlows; return criticalFlows; } /** * Calculate the execution time of the critical path * This represents the minimum possible execution time for the flow network */ calculateCriticalPathTime() { if (this.criticalPath.length === 0) { this.calculateCriticalPath(); } // Calculate total duration of critical path with memory-efficient algorithm let totalTime = 0; // Avoid creating intermediary data structures for (const flowId of this.criticalPath) { const node = this.nodes.get(flowId); if (node && node.startTime && node.endTime) { totalTime += (node.endTime - node.startTime); } } // Account for parallel execution based on dependencies // Use a more sophisticated algorithm that considers actual execution overlap // This provides a more accurate measure of the critical path execution time let actualExecutionTime = 0; let lastEndTime = 0; // Sort by start time for correct sequential analysis const sortedNodes = this.criticalPath .map(id => this.nodes.get(id)) .filter((node) => !!node && !!node.startTime && !!node.endTime) .sort((a, b) => (a.startTime || 0) - (b.startTime || 0)); for (const node of sortedNodes) { // If this node started after the previous node ended, there's a gap if (node.startTime && node.startTime > lastEndTime) { actualExecutionTime += (node.startTime - lastEndTime); } // Add this node's execution time if it extends beyond lastEndTime if (node.endTime && node.endTime > lastEndTime) { actualExecutionTime += (node.endTime - Math.max(lastEndTime, node.startTime || 0)); lastEndTime = node.endTime; } } return actualExecutionTime > 0 ? actualExecutionTime : totalTime; } /** * Get a visualization of the flow dependency graph */ getFlowGraph() { // Create graph representation const nodes = Array.from(this.nodes.entries()).map(([id, node]) => ({ id, status: node.status, executionTime: node.endTime && node.startTime ? node.endTime - node.startTime : undefined, priority: node.priority, metadata: node.metadata })); const edges = this.edges.map(edge => ({ from: edge.from, to: edge.to, hasCondition: !!edge.condition, hasDataMapping: !!edge.dataMapping })); return { nodes, edges }; } }