/** * LangGraph Integration for Open Agent Builder * * This module provides LangGraph-powered workflow execution with: * - StateGraph-based orchestration * - Conditional routing * - State persistence * - Streaming support * - Export/Import capabilities * Server-side only - should not be imported in client code */ import 'server-only'; import { StateGraph, Annotation, START, END, MemorySaver, Command, Send, interrupt, isInterrupted } from "@langchain/langgraph"; import { Workflow, WorkflowState, NodeExecutionResult, WorkflowNode, WorkflowEdge, WorkflowPendingAuth } from './types'; import { executeAgentNode } from './executors/agent'; import { executeMCPNode } from './executors/mcp'; import { executeLogicNode } from './executors/logic'; import { executeDataNode } from './executors/data'; import { executeToolsNode } from './executors/tools'; import { executeHTTPNode } from './executors/http'; import { executeExtractNode } from './executors/extract'; import { executeArcadeNode } from './executors/arcade'; import { createOrUpdateArcadeAuthRecord } from '../arcade/auth-store'; interface ArcadePendingResponse { __arcadePendingAuth: true; authUrl?: string | null; authId: string; toolName: string; userId?: string; message?: string; pendingInput?: any; } interface ApprovalPendingResponse { __pendingApproval: true; approvalId: string; message: string; status: string; createdAt: string; } /** * LangGraph State Annotation * Maps our WorkflowState to LangGraph's state management */ export const WorkflowStateAnnotation = Annotation.Root({ // Core workflow variables variables: Annotation>({ reducer: (left, right) => ({ ...left, ...right }), default: () => ({ input: '', lastOutput: '' }), }), // Chat history for conversational workflows chatHistory: Annotation>({ reducer: (left, right) => [...left, ...right], default: () => [], }), // Execution metadata currentNodeId: Annotation({ reducer: (_, right) => right, default: () => '', }), // Node results for tracking nodeResults: Annotation>({ reducer: (left, right) => ({ ...left, ...right }), default: () => ({}), }), // Pending authorization state (for Arcade auth, user approval, etc.) pendingAuth: Annotation({ reducer: (_, right) => right, default: () => null, }), // Loop results accumulator (array that accumulates across iterations) loopResults: Annotation>({ reducer: (left, right) => [...left, ...right], default: () => [], }), }); /** * LangGraph Workflow Executor * Converts Open Agent Builder workflows to LangGraph StateGraph */ export class LangGraphExecutor { private workflow: Workflow; private graph: any; // Compiled StateGraph private apiKeys?: { anthropic?: string; groq?: string; openai?: string; firecrawl?: string; arcade?: string }; private onNodeUpdate?: (nodeId: string, result: NodeExecutionResult) => void; private checkpointer: MemorySaver; private parallelNodeIds = new Set(); private activeThreadId?: string; private activeExecutionId?: string; private pendingAuth: WorkflowPendingAuth | null = null; private lastStreamState: any = null; private edgesBySource: Map = new Map(); constructor( workflow: Workflow, onNodeUpdate?: (nodeId: string, result: NodeExecutionResult) => void, apiKeys?: { anthropic?: string; groq?: string; openai?: string; firecrawl?: string; arcade?: string } ) { this.workflow = workflow; this.onNodeUpdate = onNodeUpdate; this.apiKeys = apiKeys; // Checkpointing ENABLED for interrupt support // Required for: // - Human-in-the-loop approvals (interrupt/resume) // - Arcade auth pauses // - Resuming workflows after server restarts // - Time-travel debugging // Note: MemorySaver is lightweight and doesn't impact performance this.checkpointer = new MemorySaver(); // Build the LangGraph StateGraph this.graph = this.buildGraph(); } /** * Build LangGraph StateGraph from workflow definition */ private buildGraph() { console.log('Building LangGraph from workflow:', { workflowId: this.workflow.id, workflowName: this.workflow.name, nodes: this.workflow.nodes.length, edges: this.workflow.edges.length, nodeIds: this.workflow.nodes.map(n => n.id), nodeTypes: this.workflow.nodes.map(n => (n.data as any)?.nodeType || n.type), edgeSources: this.workflow.edges.map(e => e.source), edgeTargets: this.workflow.edges.map(e => e.target), fullNodes: this.workflow.nodes, fullEdges: this.workflow.edges, }); const builder = new StateGraph(WorkflowStateAnnotation); this.parallelNodeIds.clear(); this.edgesBySource = new Map(); // Create a set of valid node IDs for edge validation const validNodeIds = new Set(this.workflow.nodes.map(n => n.id)); // Build edge map, skipping edges with invalid source/target for (const edge of this.workflow.edges) { // Validate that both source and target nodes exist if (!validNodeIds.has(edge.source)) { console.warn(`⚠️ Skipping edge ${edge.id}: source node '${edge.source}' does not exist`); continue; } if (!validNodeIds.has(edge.target)) { console.warn(`⚠️ Skipping edge ${edge.id}: target node '${edge.target}' does not exist`); continue; } if (!this.edgesBySource.has(edge.source)) { this.edgesBySource.set(edge.source, []); } this.edgesBySource.get(edge.source)!.push(edge); } console.log('Edges by source:', Object.fromEntries(this.edgesBySource)); // Add nodes to the graph for (const node of this.workflow.nodes) { const nodeType = (node.data as any)?.nodeType || node.type; // Skip note nodes entirely - they are visual-only sticky notes if (nodeType === 'note') { console.log(`Skipping note node ${node.id} (visual only)`); continue; } // Skip end nodes - LangGraph has built-in END // But we still want to execute them for UI feedback if (nodeType === 'end') { // Create a special end node executor that notifies UI before transitioning to END const endNodeExecutor = this.createNodeExecutor(node); builder.addNode(node.id, endNodeExecutor); continue; } // Handle start nodes - use regular executor but still connect to LangGraph's START if (nodeType === 'start') { const startNodeExecutor = this.createNodeExecutor(node); builder.addNode(node.id, startNodeExecutor); continue; } // Create node executor function const nodeExecutor = this.createNodeExecutor(node); const outgoingEdges = this.edgesBySource.get(node.id) || []; const shouldParallelize = this.shouldUseParallelRouting(nodeType, outgoingEdges); if (shouldParallelize) { this.parallelNodeIds.add(node.id); } builder.addNode( node.id, nodeExecutor, shouldParallelize ? { ends: this.getParallelEnds(outgoingEdges) } : undefined ); } // Add edges (skip edges connected to note nodes) const conditionalNodes = new Set(); const whileLoopNodes = new Set(); for (const [sourceId, sourceEdges] of Array.from(this.edgesBySource.entries())) { const sourceNode = this.workflow.nodes.find(n => n.id === sourceId); const sourceType = (sourceNode?.data as any)?.nodeType || sourceNode?.type; // Skip edges from note nodes (notes are visual only) if (sourceType === 'note') { console.log(`Skipping edges from note node ${sourceId}`); continue; } if (sourceType === 'while') { if (!whileLoopNodes.has(sourceId)) { const routingFunction = this.createWhileLoopRouter(sourceId); const pathMap = this.buildWhilePathMap(sourceId, sourceEdges); builder.addConditionalEdges(sourceId as any, routingFunction, pathMap as any); whileLoopNodes.add(sourceId); } continue; } if (sourceType === 'if-else' || sourceType === 'if / else') { if (!conditionalNodes.has(sourceId)) { const routingFunction = this.createConditionalRouter(sourceId); const pathMap: Record = {}; for (const edge of sourceEdges) { const handle = edge.sourceHandle || 'default'; pathMap[handle] = edge.target; } builder.addConditionalEdges(sourceId as any, routingFunction, pathMap as any); conditionalNodes.add(sourceId); } continue; } // For regular nodes, add their outgoing edges for (const edge of sourceEdges) { // Verify target node exists const targetNode = this.workflow.nodes.find(n => n.id === edge.target); if (!targetNode) { console.warn(`⚠️ Skipping edge ${edge.id}: target node '${edge.target}' not found`); continue; } // Skip edges that connect to note nodes (notes are visual only) const targetType = (targetNode.data as any)?.nodeType || targetNode.type; if (targetType === 'note') { console.log(`Skipping edge to note node ${edge.target}`); continue; } // Use the actual target node ID for all edges, including end nodes // Since we're adding end nodes as actual nodes, we don't need to redirect to END builder.addEdge(sourceId as any, edge.target as any); } } // Connect LangGraph's START to our start node console.log('Connecting LangGraph START to workflow start node...'); const startNode = this.workflow.nodes.find(n => { const nodeType = (n.data as any)?.nodeType || n.type; return nodeType === 'start'; }); if (startNode) { console.log(`Connecting LangGraph START to start node: ${startNode.id}`); builder.addEdge(START, startNode.id as any); } // Connect all end nodes to LangGraph's END const endNodes = this.workflow.nodes.filter(n => { const nodeType = (n.data as any)?.nodeType || n.type; return nodeType === 'end'; }); for (const endNode of endNodes) { console.log(`Connecting end node ${endNode.id} to LangGraph END`); builder.addEdge(endNode.id as any, END); } // Compile the graph WITH checkpointing (required for interrupts/approvals) try { return builder.compile({ checkpointer: this.checkpointer }); } catch (error) { // Provide descriptive error messages for common issues if (error instanceof Error && error.message.includes('is not reachable')) { // Extract the unreachable node ID from the error const match = error.message.match(/Node `([^`]+)` is not reachable/); const unreachableNodeId = match ? match[1] : 'unknown'; const unreachableNode = this.workflow.nodes.find(n => n.id === unreachableNodeId); const nodeName = (unreachableNode?.data as any)?.nodeName || unreachableNode?.type || unreachableNodeId; // Find what nodes ARE reachable to help debug const allNodeIds = new Set(this.workflow.nodes.map(n => n.id)); const connectedFromStart = new Set(); // BFS from start node to find all reachable nodes const startNode = this.workflow.nodes.find(n => { const nodeType = (n.data as any)?.nodeType || n.type; return nodeType === 'start'; }); if (startNode) { const queue = [startNode.id]; connectedFromStart.add(startNode.id); while (queue.length > 0) { const currentId = queue.shift()!; const outgoingEdges = this.edgesBySource.get(currentId) || []; for (const edge of outgoingEdges) { if (!connectedFromStart.has(edge.target)) { connectedFromStart.add(edge.target); queue.push(edge.target); } } } } const unreachableNodes = Array.from(allNodeIds).filter(id => !connectedFromStart.has(id)); throw new Error( `Workflow configuration error: Node "${nodeName}" (${unreachableNodeId}) is not connected to the workflow.\n\n` + `Unreachable nodes: ${unreachableNodes.map(id => { const node = this.workflow.nodes.find(n => n.id === id); const name = (node?.data as any)?.nodeName || node?.type || id; return `"${name}" (${id})`; }).join(', ')}\n\n` + `To fix this:\n` + `1. Connect the Start node to your first agent/action node\n` + `2. Ensure all nodes have incoming connections (except Start)\n` + `3. Check that all edges point to valid nodes\n\n` + `Tip: Use the canvas to drag connections between node handles` ); } // Re-throw other errors throw error; } } /** * Create node executor function for LangGraph (CLEAN VERSION) */ private createNodeExecutor(node: WorkflowNode) { return async (state: typeof WorkflowStateAnnotation.State) => { console.log(`Executing node: ${node.id}`); // Notify UI const result: NodeExecutionResult = { nodeId: node.id, status: 'running', startedAt: new Date().toISOString(), }; this.onNodeUpdate?.(node.id, result); try { // Execute the node (pure, server-side) const output = await this.executeNodePure(node, state); // Log full output shape for debugging console.log(`Node ${node.id} output shape:`, { outputType: typeof output, outputKeys: output && typeof output === 'object' && output !== null ? Object.keys(output) : [], hasAgentValue: output && typeof output === 'object' && output !== null && '__agentValue' in output, hasAgentToolCalls: output && typeof output === 'object' && output !== null && '__agentToolCalls' in output, }); // Check if output is a pending approval (user-approval node) if (output && typeof output === 'object' && output !== null && '__pendingApproval' in output) { console.log(`Detected pending approval from node ${node.id}`); return await this.handlePendingApproval(node, result, output as ApprovalPendingResponse); } // Extract tool calls and chat history if this is an agent node let actualOutput = output; let toolCalls: any = undefined; let chatHistoryUpdates: any[] = []; let variableUpdates: Record = {}; if (output && typeof output === 'object' && output !== null && '__agentValue' in output) { actualOutput = output.__agentValue; toolCalls = (output as any).__agentToolCalls; chatHistoryUpdates = (output as any).__chatHistoryUpdates || []; variableUpdates = (output as any).__variableUpdates || {}; console.log(`Extracted from agent output:`, { actualOutput: typeof actualOutput === 'string' ? actualOutput.substring(0, 100) : actualOutput, toolCallsCount: Array.isArray(toolCalls) ? toolCalls.length : 0, toolCalls: toolCalls, chatHistoryUpdates: chatHistoryUpdates.length, variableUpdates: Object.keys(variableUpdates), }); } // Update result result.output = actualOutput; result.toolCalls = toolCalls; result.status = 'completed'; result.completedAt = new Date().toISOString(); this.onNodeUpdate?.(node.id, result); // For while loops, extract the iteration counter from output // Merge with any agent-provided variable updates // Use node name for variable storage, fallback to node ID const nodeKey = (node.data as any)?.nodeName || (node.data as any)?.name || node.id; const mergedVariableUpdates: Record = { lastOutput: actualOutput, [nodeKey]: actualOutput, [node.id]: actualOutput, // Also store with ID for backward compatibility ...variableUpdates, // Include agent's variable updates }; // Check if output wants to append to loop results let loopResultsUpdate: any[] | undefined; if (actualOutput && typeof actualOutput === 'object' && '__appendToLoopResults' in actualOutput) { const newResult = actualOutput.__appendToLoopResults; loopResultsUpdate = [newResult]; // Will be appended by reducer console.log(`Appending result to loopResults array`); // Remove the signal from lastOutput const { __appendToLoopResults, ...cleanOutput } = actualOutput; mergedVariableUpdates.lastOutput = cleanOutput; mergedVariableUpdates[node.id] = cleanOutput; } // If this is a while loop, save the iteration counter to variables const nodeType = (node.data as any)?.nodeType || node.type; if (nodeType === 'while' && actualOutput && typeof actualOutput === 'object') { const iterationKey = `${node.id}__iterationCount`; if (iterationKey in actualOutput) { mergedVariableUpdates[iterationKey] = actualOutput[iterationKey]; console.log(`Saving iteration counter: ${iterationKey} = ${actualOutput[iterationKey]}`); } } // Return LangGraph state update (immutable, use reducer-friendly format) return { variables: mergedVariableUpdates, // Reducer merges this chatHistory: chatHistoryUpdates.length > 0 ? chatHistoryUpdates : [], currentNodeId: node.id, nodeResults: { [node.id]: result }, // Reducer merges this pendingAuth: null, ...(loopResultsUpdate ? { loopResults: loopResultsUpdate } : {}), }; } catch (error) { result.status = 'failed'; result.error = error instanceof Error ? error.message : 'Unknown error'; result.completedAt = new Date().toISOString(); this.onNodeUpdate?.(node.id, result); throw error; } }; } /** * Pure node execution (no state mutation) */ private async executeNodePure( node: WorkflowNode, state: typeof WorkflowStateAnnotation.State ): Promise { const nodeType = (node.data as any)?.nodeType || node.type; const data = node.data as any; switch (nodeType) { case 'start': // Return the input object properly without spreading strings const input = state.variables.input; // If input is a JSON string, parse it let parsedInput = input; if (typeof input === 'string') { try { parsedInput = JSON.parse(input); } catch { // Not JSON, keep as string parsedInput = input; } } return { message: 'Workflow started', ...(typeof parsedInput === 'object' && parsedInput !== null ? parsedInput : { input: parsedInput }) }; case 'agent': { // Use the proper executeAgentNode which handles MCP tools console.log(`Agent node ${node.id} configuration:`, { hasMcpTools: !!data.mcpTools, mcpToolsCount: data.mcpTools?.length || 0, mcpTools: data.mcpTools, }); const result = await executeAgentNode(node, state as WorkflowState, this.apiKeys); return result; } case 'mcp': { const mcpServers = data.mcpServers || []; if (mcpServers.length === 0) return { error: 'No MCP servers configured' }; const server = mcpServers[0]; const action = data.mcpAction || 'scrape'; if (server.name.toLowerCase().includes('firecrawl')) { // Firecrawl has been removed from the project return { error: 'Firecrawl has been removed from this project. Please use alternative web scraping solutions or remove this MCP server from your workflow.', action, server: server.name }; } return { error: 'MCP server not implemented' }; } case 'transform': case 'data-transform': { // Support both transformScript (UI) and transformation (templates) const script = data.transformScript || data.transformation || 'return input;'; const input = state.variables.lastOutput; const transformFn = new Function('input', 'lastOutput', 'state', script); return transformFn(input, input, state); } case 'http': { const url = data.httpUrl || ''; const method = data.httpMethod || 'GET'; const response = await fetch(url, { method }); return await response.json(); } case 'if-else': { const condition = data.condition || 'true'; const evalFn = new Function('input', 'state', 'lastOutput', `return ${condition}`); const result = evalFn(state.variables.input, state, state.variables.lastOutput); return { condition: Boolean(result), branch: result ? 'if' : 'else' }; } case 'while': { // Execute while loop check using the proper method const tempState: WorkflowState = { variables: state.variables, chatHistory: state.chatHistory, }; return await this.executeWhileNode(node, tempState, state); } case 'end': return { message: 'Workflow completed', finalOutput: state.variables.lastOutput }; default: // For node types not in executeNodePure, fall back to the full executeNode implementation console.log(`WARNING: Node type '${nodeType}' not in executeNodePure, using executeNode fallback`); const tempState = { variables: state.variables, chatHistory: state.chatHistory, }; return await this.executeNode(node, tempState); } } /** * Create conditional router for if-else nodes */ private createConditionalRouter(nodeId: string) { return async (state: typeof WorkflowStateAnnotation.State) => { const node = this.workflow.nodes.find(n => n.id === nodeId); if (!node) return 'default'; // Execute the if-else condition const tempState: WorkflowState = { variables: state.variables, chatHistory: state.chatHistory, }; const result = await executeLogicNode(node, tempState); // Return the branch handle ('if' or 'else') return result.branch || 'else'; }; } private shouldUseParallelRouting(nodeType: string | undefined, edges: WorkflowEdge[]): boolean { if (!edges || edges.length <= 1) { return false; } if (!nodeType) { return false; } const normalized = nodeType.toLowerCase(); if (['if-else', 'if / else', 'while', 'user-approval', 'user approval'].includes(normalized)) { return false; } if (edges.some(edge => edge.sourceHandle)) { return false; } const uniqueTargets = new Set(); let hasNonTerminalTarget = false; for (const edge of edges) { if (!edge.target) continue; uniqueTargets.add(edge.target); const targetNode = this.workflow.nodes.find(n => n.id === edge.target); const targetType = (targetNode?.data as any)?.nodeType || targetNode?.type; if (targetType !== 'end') { hasNonTerminalTarget = true; } } return uniqueTargets.size > 1 && hasNonTerminalTarget; } private getParallelEnds(edges: WorkflowEdge[]): string[] { const ends = new Set(); for (const edge of edges) { if (!edge.target) continue; const targetNode = this.workflow.nodes.find(n => n.id === edge.target); const targetType = (targetNode?.data as any)?.nodeType || targetNode?.type; if (targetType === 'end') { continue; } ends.add(edge.target); } return Array.from(ends); } private buildWhilePathMap(nodeId: string, edges: WorkflowEdge[]) { const pathMap: Record = {}; for (const edge of edges) { const handle = (edge.sourceHandle || edge.label || '').toLowerCase(); if (['continue', 'true', 'yes', 'loop', 'next'].includes(handle)) { pathMap['continue'] = edge.target; } else if (['break', 'false', 'no', 'exit', 'stop', 'end', 'complete'].includes(handle)) { pathMap['break'] = edge.target; } else if (!pathMap['continue']) { pathMap['continue'] = edge.target; } else if (!pathMap['break']) { pathMap['break'] = edge.target; } } if (!pathMap['continue'] && edges[0]) { pathMap['continue'] = edges[0].target; } if (!pathMap['break']) { console.warn(`While loop ${nodeId}: no explicit break edge found, defaulting to END`); pathMap['break'] = END as unknown as string; } return pathMap; } /** * Create while loop router for iteration control */ private createWhileLoopRouter(nodeId: string) { return async (state: typeof WorkflowStateAnnotation.State) => { const node = this.workflow.nodes.find(n => n.id === nodeId); if (!node) { console.warn(`While loop router: node ${nodeId} not found, breaking`); return 'break'; } const iterationKey = `${nodeId}__iterationCount`; const result = state.nodeResults?.[nodeId]; const loopOutput = (result?.output || state.variables?.[node.id]) as any; const maxIterations = this.parseMaxIterations((node.data as any)?.maxIterations); const currentIteration = Number(state.variables?.[iterationKey] || 0); console.log(`While loop router for ${nodeId}:`, { currentIteration, maxIterations, loopOutputCondition: loopOutput?.condition, stoppedReason: loopOutput?.stoppedReason, }); // Check if loop already decided to break if (loopOutput?.condition === false || loopOutput?.stoppedReason === 'condition_false') { console.log(`While loop ${nodeId}: loop output says break (condition=false)`); return 'break'; } // Check for max_iterations reason if (loopOutput?.stoppedReason === 'max_iterations') { console.log(`While loop ${nodeId}: loop output says break (max_iterations reached)`); return 'break'; } if (currentIteration >= maxIterations) { console.log(`While loop ${nodeId}: iteration count (${currentIteration}) >= max (${maxIterations}), breaking`); return 'break'; } const shouldContinue = this.getWhileCondition(node, state, currentIteration, loopOutput); if (shouldContinue) { console.log(`While loop ${nodeId}: condition true, continuing`); return 'continue'; } console.log(`While loop ${nodeId}: condition false, breaking`); return 'break'; }; } private getWhileCondition( node: WorkflowNode, state: typeof WorkflowStateAnnotation.State, currentIteration: number, loopOutput?: any, ) { if (loopOutput && typeof loopOutput === 'object' && 'condition' in loopOutput) { if (loopOutput.condition === true && loopOutput.stoppedReason === 'max_iterations') { return false; } return Boolean(loopOutput.condition); } const conditionExpr = (node.data as any)?.whileCondition || (node.data as any)?.condition || 'false'; try { const evalFunction = new Function( 'input', 'state', 'lastOutput', 'iteration', `return ${conditionExpr}` ); return Boolean( evalFunction( state.variables.input, state, state.variables.lastOutput, currentIteration ) ); } catch (error) { console.error(`ERROR: While loop ${node.id}: condition evaluation error`, error); return false; } } private parseMaxIterations(rawValue: any) { const ABSOLUTE_MAX = 100; // Hard limit to prevent infinite loops const DEFAULT = 10; const parsed = Number.parseInt(rawValue ?? DEFAULT.toString(), 10); if (!Number.isFinite(parsed) || Number.isNaN(parsed) || parsed <= 0) { return DEFAULT; } // Enforce absolute maximum if (parsed > ABSOLUTE_MAX) { console.warn(`While loop max iterations ${parsed} exceeds limit, capping at ${ABSOLUTE_MAX}`); return ABSOLUTE_MAX; } return parsed; } private async executeWhileNode( node: WorkflowNode, state: WorkflowState, langGraphState: typeof WorkflowStateAnnotation.State ) { const data: any = node.data || {}; const ABSOLUTE_MAX = 100; // Hard safety limit const conditionExpr = data.whileCondition || data.condition || 'false'; const maxIterations = Math.min(this.parseMaxIterations(data.maxIterations), ABSOLUTE_MAX); const iterationKey = `${node.id}__iterationCount`; const previousIteration = Number(langGraphState.variables?.[iterationKey] || 0); // Add absolute safety check if (previousIteration > ABSOLUTE_MAX) { console.error(`While loop ${node.id} exceeded absolute limit of ${ABSOLUTE_MAX}`); return { condition: false, iteration: previousIteration, nextIteration: previousIteration, maxIterations: ABSOLUTE_MAX, stoppedReason: 'absolute_limit_exceeded', error: `Loop terminated: exceeded absolute limit of ${ABSOLUTE_MAX} iterations`, timestamp: new Date().toISOString(), }; } if (previousIteration > maxIterations) { return { condition: false, iteration: previousIteration, nextIteration: previousIteration, maxIterations, stoppedReason: 'max_iterations', timestamp: new Date().toISOString(), }; } try { const evaluationIteration = previousIteration + 1; const evalFunction = new Function( 'input', 'state', 'lastOutput', 'iteration', `return ${conditionExpr}` ); const shouldContinue = Boolean( evalFunction( state.variables.input, langGraphState, state.variables.lastOutput, evaluationIteration ) ); const nextIteration = shouldContinue ? evaluationIteration : previousIteration; // Get accumulated loop results // NOTE: We return immutable updates - the reducer will merge [iterationKey] into state.variables const loopResultsKey = `${node.id}__loopResults`; const accumulatedResults = langGraphState.variables[loopResultsKey] || []; if (!shouldContinue) { console.log(`Loop breaking, passing ${accumulatedResults.length} results to next node`); } return { condition: shouldContinue, iteration: shouldContinue ? evaluationIteration : previousIteration, previousIteration, nextIteration, maxIterations, stoppedReason: shouldContinue ? 'condition_true' : 'condition_false', timestamp: new Date().toISOString(), conditionExpression: conditionExpr, evaluationIteration, [iterationKey]: nextIteration, loopResults: accumulatedResults, // Include accumulated results in output }; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error(`ERROR: While loop ${node.id}: condition evaluation failed`, error); throw new Error(`While loop ${node.id} condition evaluation failed: ${message}`); } } private async handleArcadePendingAuth( node: WorkflowNode, result: NodeExecutionResult, pending: ArcadePendingResponse, state: WorkflowState, ) { const message = pending.message ?? `Authorization required for ${pending.toolName}`; const pendingAuth: WorkflowPendingAuth = { authId: pending.authId, nodeId: node.id, toolName: pending.toolName, authUrl: pending.authUrl, status: 'pending', userId: pending.userId, message, threadId: this.activeThreadId, executionId: this.activeExecutionId, }; result.status = 'pending-authorization'; result.output = message; result.pendingAuth = pendingAuth; result.completedAt = new Date().toISOString(); this.pendingAuth = pendingAuth; this.onNodeUpdate?.(node.id, result); const executionRef = this.activeExecutionId ?? this.activeThreadId ?? `exec_${Date.now()}`; await createOrUpdateArcadeAuthRecord({ authId: pending.authId, executionId: executionRef, workflowId: this.workflow.id, nodeId: node.id, toolName: pending.toolName, authUrl: pending.authUrl, status: 'pending', userId: pending.userId, pendingInput: pending.pendingInput, }); // TODO: Save arcade auth state to database when running on server // This should be handled by the API route that calls this executor const resumeValue = interrupt({ type: 'arcade-auth', workflowId: this.workflow.id, nodeId: node.id, pendingAuth, }) as any; if (resumeValue && typeof resumeValue === 'object' && 'status' in resumeValue) { const normalized = String(resumeValue.status).toLowerCase(); if (normalized !== 'completed' && normalized !== 'authorized' && normalized !== 'approved') { throw new Error('Arcade authorization not completed yet. Please finish authorization before resuming.'); } } this.pendingAuth = null; result.status = 'running'; result.pendingAuth = undefined; result.output = undefined; delete result.completedAt; this.onNodeUpdate?.(node.id, result); return await executeArcadeNode(node, state, this.apiKeys?.arcade); } private async handlePendingApproval( node: WorkflowNode, result: NodeExecutionResult, pending: ApprovalPendingResponse, ) { const message = pending.message || 'Approval required'; const pendingAuth: WorkflowPendingAuth = { authId: pending.approvalId, nodeId: node.id, toolName: 'user-approval', status: 'pending', message, threadId: this.activeThreadId, executionId: this.activeExecutionId, }; result.status = 'pending-approval'; result.output = message; result.pendingAuth = pendingAuth; result.completedAt = new Date().toISOString(); this.pendingAuth = pendingAuth; this.onNodeUpdate?.(node.id, result); const executionRef = this.activeExecutionId ?? this.activeThreadId ?? `exec_${Date.now()}`; // TODO: Save approval state to database // This should be handled by the API route that calls this executor // The API route should save to Convex/Redis as needed const resumeValue = interrupt({ type: 'user-approval', workflowId: this.workflow.id, nodeId: node.id, pendingAuth, }); this.pendingAuth = null; result.status = 'running'; result.pendingAuth = undefined; result.output = undefined; delete result.completedAt; this.onNodeUpdate?.(node.id, result); return resumeValue ?? { approved: true }; } /** * Execute individual node using existing executors */ private async executeNode(node: WorkflowNode, state: WorkflowState): Promise { const nodeType = (node.data as any).nodeType || node.type; switch (nodeType) { case 'start': await new Promise(resolve => setTimeout(resolve, 500)); // Pass through the initial input variables so they're available to next nodes return { message: 'Workflow started', ...(state.variables.input || {}) }; case 'agent': return await executeAgentNode(node, state, this.apiKeys); case 'extract': return await executeExtractNode(node, state, this.apiKeys); case 'arcade': return await executeArcadeNode(node, state, this.apiKeys?.arcade); case 'mcp': return await executeMCPNode(node, state, this.apiKeys?.firecrawl); case 'if-else': case 'if / else': case 'while': case 'user-approval': case 'user approval': case 'approval': return await executeLogicNode(node, state); case 'transform': case 'data-transform': case 'set-state': case 'set state': return await executeDataNode(node, state); case 'file-search': case 'file search': case 'guardrails': case 'guardrail': return await executeToolsNode(node, state); case 'http': case 'http-request': return await executeHTTPNode(node, state); case 'note': return { message: 'Note node (visual only)' }; case 'end': return { message: 'Workflow completed' }; default: return await executeAgentNode(node, state); } } /** * Execute workflow with streaming support */ async executeStream(input: any, config?: { threadId?: string; executionId?: string }) { const threadId = config?.threadId || `thread_${Date.now()}`; this.activeThreadId = threadId; if (config?.executionId) { this.activeExecutionId = config.executionId; } this.pendingAuth = null; const initialState = { variables: { input: typeof input === 'string' ? input : input, lastOutput: typeof input === 'string' ? input : '', }, chatHistory: [], currentNodeId: '', nodeResults: {}, pendingAuth: null, }; this.lastStreamState = initialState; const rawStream = await this.graph.stream(initialState, { configurable: { thread_id: threadId }, streamMode: "values" as const, recursionLimit: 100, // Support up to 100 graph steps (default: 25) }); return this.wrapStreamWithInterruptHandling(rawStream, initialState); } async resumeFromAuth(threadId: string, resumeValue?: any, options?: { executionId?: string }) { this.activeThreadId = threadId; if (options?.executionId) { this.activeExecutionId = options.executionId; } const command = new Command({ resume: resumeValue }); const rawStream = await this.graph.stream(command, { configurable: { thread_id: threadId }, streamMode: "values" as const, recursionLimit: 100, // Support up to 100 graph steps (default: 25) }); const fallback = this.lastStreamState ?? { variables: {}, nodeResults: {}, pendingAuth: null, currentNodeId: '', }; return this.wrapStreamWithInterruptHandling(rawStream, fallback); } /** * Execute workflow (non-streaming) */ async execute(input: any, config?: { threadId?: string }) { const threadId = config?.threadId || `thread_${Date.now()}`; const initialState = { variables: { input: typeof input === 'string' ? input : input, lastOutput: typeof input === 'string' ? input : '', }, chatHistory: [], currentNodeId: '', nodeResults: {}, }; const result = await this.graph.invoke(initialState, { configurable: { thread_id: threadId }, recursionLimit: 100, // Support up to 100 graph steps (default: 25) }); return { id: `exec_${Date.now()}`, workflowId: this.workflow.id, status: 'completed' as const, nodeResults: result.nodeResults || {}, startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), }; } private wrapStreamWithInterruptHandling(rawStream: AsyncIterable, fallbackState: any) { const self = this; return (async function* () { let latestState = fallbackState; try { for await (const chunk of rawStream) { if (isInterrupted(chunk)) { const interruptRecord = (chunk as any).__interrupt__?.[0] ?? null; const pendingAuth = self.pendingAuth ?? interruptRecord?.value?.pendingAuth ?? null; const pauseState = { ...(latestState ?? {}), pendingAuth, currentNodeId: interruptRecord?.value?.nodeId ?? latestState?.currentNodeId ?? '', nodeResults: latestState?.nodeResults ?? {}, }; self.lastStreamState = pauseState; yield pauseState; return; } const enrichedChunk = { ...chunk, pendingAuth: self.pendingAuth, }; latestState = enrichedChunk; self.lastStreamState = enrichedChunk; yield enrichedChunk; } } catch (streamError) { console.error('ERROR: Stream iteration error in wrapStreamWithInterruptHandling:', streamError); // Yield error state instead of throwing const errorState = { ...(latestState ?? {}), error: streamError instanceof Error ? streamError.message : 'Stream error', status: 'failed' }; self.lastStreamState = errorState; yield errorState; return; } self.pendingAuth = null; self.lastStreamState = latestState; })(); } /** * Export workflow as LangGraph JSON */ exportToLangGraph(): any { return { nodes: this.workflow.nodes.map(node => ({ id: node.id, type: (node.data as any)?.nodeType || node.type, data: node.data, })), edges: this.workflow.edges.map(edge => ({ source: edge.source, target: edge.target, sourceHandle: edge.sourceHandle, })), state: { schema: { variables: 'object', chatHistory: 'array', currentNodeId: 'string', nodeResults: 'object', }, }, }; } /** * Get LangGraph mermaid diagram */ async getMermaidDiagram(): Promise { try { const graphDrawable = this.graph.getGraph(); return graphDrawable.drawMermaid(); } catch (error) { console.error('Failed to generate Mermaid diagram:', error); return ''; } } /** * Get state checkpoints for a thread */ async getCheckpoints(threadId: string) { const config = { configurable: { thread_id: threadId } }; const checkpoints: any[] = []; for await (const checkpoint of this.checkpointer.list(config)) { checkpoints.push(checkpoint); } return checkpoints; } /** * Resume from a specific checkpoint */ async resumeFromCheckpoint(threadId: string, checkpointId: string, input: any) { const config = { configurable: { thread_id: threadId, checkpoint_id: checkpointId, }, recursionLimit: 100, // Support up to 100 graph steps (default: 25) }; return await this.graph.invoke(input, config); } } /** * Convert workflow to LangGraph format for export */ export function workflowToLangGraphJSON(workflow: Workflow) { return { name: workflow.name, description: workflow.description, nodes: workflow.nodes.map(node => ({ id: node.id, type: (node.data as any)?.nodeType || node.type, position: node.position, data: { ...node.data, // Clean up any UI-specific data nodeType: (node.data as any)?.nodeType || node.type, }, })), edges: workflow.edges.map(edge => ({ id: edge.id, source: edge.source, target: edge.target, sourceHandle: edge.sourceHandle, label: edge.label, })), metadata: { createdAt: workflow.createdAt, updatedAt: workflow.updatedAt, version: '1.0.0', framework: 'langgraph', }, }; } /** * Generate executable TypeScript code from workflow */ export function workflowToLangGraphCode(workflow: Workflow): string { const nodes = workflow.nodes; const edges = workflow.edges; // Generate node executors const nodeExecutors = nodes .filter(n => { const nodeType = (n.data as any)?.nodeType || n.type; return nodeType !== 'start' && nodeType !== 'end'; }) .map(node => { const nodeType = (node.data as any)?.nodeType || node.type; const data = node.data as any; if (nodeType === 'agent') { return `// ${data.nodeName || node.id} - AI Agent const ${node.id} = async (state: typeof StateAnnotation.State) => { const response = await anthropic.messages.create({ model: "${data.model || 'claude-3-5-sonnet-20241022'}", max_tokens: 4096, messages: [{ role: 'user', content: \`${data.instructions || 'Process the input'}\${state.variables.lastOutput ? '\\n\\nContext: ' + JSON.stringify(state.variables.lastOutput) : ''}\` }] }); return { ...state.variables, lastOutput: response.content[0].type === 'text' ? response.content[0].text : '' }; };`; } if (nodeType === 'transform') { return `// ${data.nodeName || node.id} - Transform Data const ${node.id} = async (state: typeof StateAnnotation.State) => { // Transform logic here return { ...state.variables, lastOutput: state.variables.lastOutput // Add your transformation }; };`; } return `// ${node.id} - ${nodeType} const ${node.id} = async (state: typeof StateAnnotation.State) => state.variables;`; }) .join('\n\n'); // Generate graph building code const startNode = nodes.find(n => ((n.data as any)?.nodeType || n.type) === 'start'); const inputVars = startNode ? ((startNode.data as any)?.inputVariables || []) : []; const graphBuilder = `const graph = new StateGraph(StateAnnotation) ${nodes.filter(n => { const t = (n.data as any)?.nodeType || n.type; return t !== 'start' && t !== 'end'; }).map(n => ` .addNode("${n.id}", ${n.id})`).join('\n')} .addNode("end", async (state) => state.variables) ${edges.map(e => ` .addEdge("${e.source}", "${e.target}")`).join('\n')} .addEdge(START, "${startNode?.id || 'start'}") .addEdge("end", END) .compile();`; return `import { Anthropic } from "@anthropic-ai/sdk"; import { StateGraph, START, END, Annotation } from "@langchain/langgraph"; import { MemorySaver } from "@langchain/langgraph"; // Initialize Anthropic client const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); // Define state structure const StateAnnotation = Annotation.Root({ variables: Annotation<{ ${inputVars.map((v: any) => `${v.name}: string;`).join('\n ')} lastOutput?: any; }>(), }); ${nodeExecutors} // Build the graph ${graphBuilder} // Execute the workflow async function main() { const result = await graph.invoke({ variables: { ${inputVars.map((v: any) => `${v.name}: "${v.defaultValue || ''}",`).join('\n ')} } }); console.log("Workflow Result:", result); } main();`; } /** * Import LangGraph JSON back to workflow format */ export function langGraphJSONToWorkflow(json: any): Workflow { return { id: json.metadata?.workflowId || `workflow_${Date.now()}`, name: json.name, description: json.description, nodes: json.nodes.map((node: any) => ({ id: node.id, type: node.type, position: node.position || { x: 0, y: 0 }, data: node.data, })), edges: json.edges.map((edge: any) => ({ id: edge.id, source: edge.source, target: edge.target, sourceHandle: edge.sourceHandle, label: edge.label, })), createdAt: json.metadata?.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), }; } /** * Create a simple example demonstrating LangGraph integration */ export async function createExampleLangGraphWorkflow() { // Example: Query classifier with conditional routing const ExampleStateAnnotation = Annotation.Root({ query: Annotation(), category: Annotation(), result: Annotation(), }); // Router node - classifies the query const classifierNode = async (state: typeof ExampleStateAnnotation.State) => { const query = state.query.toLowerCase(); let category = 'general'; if (query.includes('stock') || query.includes('price') || /[A-Z]{2,5}/.test(state.query)) { category = 'finance'; } else if (query.includes('product') || query.includes('buy') || query.includes('price comparison')) { category = 'shopping'; } else if (query.includes('news') || query.includes('article')) { category = 'news'; } return { category }; }; // Finance node const financeNode = async (state: typeof ExampleStateAnnotation.State) => { return { result: `Finance data for: ${state.query}` }; }; // Shopping node const shoppingNode = async (state: typeof ExampleStateAnnotation.State) => { return { result: `Shopping results for: ${state.query}` }; }; // News node const newsNode = async (state: typeof ExampleStateAnnotation.State) => { return { result: `News articles for: ${state.query}` }; }; // General node const generalNode = async (state: typeof ExampleStateAnnotation.State) => { return { result: `General info for: ${state.query}` }; }; // Conditional router const routeByCategory = (state: typeof ExampleStateAnnotation.State) => { switch (state.category) { case 'finance': return 'finance'; case 'shopping': return 'shopping'; case 'news': return 'news'; default: return 'general'; } }; // Build the graph const graph = new StateGraph(ExampleStateAnnotation) .addNode('classifier', classifierNode) .addNode('finance', financeNode) .addNode('shopping', shoppingNode) .addNode('news', newsNode) .addNode('general', generalNode) .addEdge(START, 'classifier') .addConditionalEdges('classifier', routeByCategory, { finance: 'finance', shopping: 'shopping', news: 'news', general: 'general', }) .addEdge('finance', END) .addEdge('shopping', END) .addEdge('news', END) .addEdge('general', END) .compile(); return graph; }