Files
2026-07-13 12:37:47 +08:00

1506 lines
49 KiB
TypeScript

/**
* 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<Record<string, any>>({
reducer: (left, right) => ({ ...left, ...right }),
default: () => ({ input: '', lastOutput: '' }),
}),
// Chat history for conversational workflows
chatHistory: Annotation<Array<{ role: string; content: string }>>({
reducer: (left, right) => [...left, ...right],
default: () => [],
}),
// Execution metadata
currentNodeId: Annotation<string>({
reducer: (_, right) => right,
default: () => '',
}),
// Node results for tracking
nodeResults: Annotation<Record<string, NodeExecutionResult>>({
reducer: (left, right) => ({ ...left, ...right }),
default: () => ({}),
}),
// Pending authorization state (for Arcade auth, user approval, etc.)
pendingAuth: Annotation<any>({
reducer: (_, right) => right,
default: () => null,
}),
// Loop results accumulator (array that accumulates across iterations)
loopResults: Annotation<Array<any>>({
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<string>();
private activeThreadId?: string;
private activeExecutionId?: string;
private pendingAuth: WorkflowPendingAuth | null = null;
private lastStreamState: any = null;
private edgesBySource: Map<string, WorkflowEdge[]> = 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<string>();
const whileLoopNodes = new Set<string>();
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<string, string> = {};
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<string>();
// 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<string, any> = {};
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<string, any> = {
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<any> {
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<string>();
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<string>();
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<string, string> = {};
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<any> {
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<any>, 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<string> {
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<string>(),
category: Annotation<string>(),
result: Annotation<string>(),
});
// 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;
}