from typing import List, Dict, Any, Optional from .models import WorkflowNode, WorkflowEdge, WorkflowDefinition, WorkflowStep, WorkflowTrigger, InputNodeConfig, ScheduleConfig from .tool_examples import get_tools_xml_examples import uuid from utils.logger import logger from datetime import datetime class WorkflowConverter: """Converts visual workflow flows into executable workflow definitions.""" def __init__(self): pass def convert_flow_to_workflow( self, nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]], metadata: Dict[str, Any] ) -> WorkflowDefinition: logger.info(f"Converting workflow flow with {len(nodes)} nodes and {len(edges)} edges") input_config = self._extract_input_configuration(nodes) workflow_prompt = self._generate_workflow_prompt(nodes, edges, input_config) entry_point = self._find_entry_point(nodes, edges) triggers = self._extract_triggers_from_input(input_config) logger.info(f"Looking for tool and MCP nodes in {len(nodes)} total nodes") for node in nodes: logger.info(f"Node: id={node.get('id')}, type={node.get('type')}, data={node.get('data', {})}") # Extract regular tool nodes tool_nodes = [node for node in nodes if node.get('type') == 'toolConnectionNode'] logger.info(f"Found {len(tool_nodes)} tool connection nodes") # Extract MCP nodes mcp_nodes = [node for node in nodes if node.get('type') == 'mcpNode'] logger.info(f"Found {len(mcp_nodes)} MCP nodes") # Process regular tools enabled_tools = [] for tool_node in tool_nodes: tool_data = tool_node.get('data', {}) tool_id = tool_data.get('nodeId') logger.info(f"Processing tool node: id={tool_node.get('id')}, data={tool_data}, tool_id={tool_id}") if tool_id: enabled_tools.append({ "id": tool_id, "name": tool_data.get('label', tool_id), "description": tool_data.get('description', 'No description available'), "instructions": tool_data.get('instructions', '') }) logger.info(f"Added tool {tool_id} to enabled_tools") else: logger.warning(f"Tool node {tool_node.get('id')} has no nodeId in data: {tool_data}") # Process MCP nodes and extract MCP configurations mcp_configs = self._extract_mcp_configurations(mcp_nodes) logger.info(f"Extracted {len(mcp_configs['configured_mcps'])} Smithery MCPs and {len(mcp_configs['custom_mcps'])} custom MCPs") logger.info(f"Final enabled_tools list: {enabled_tools}") agent_step = WorkflowStep( id="main_agent_step", name="Workflow Agent", description="Main agent that executes the workflow based on the visual flow", type="TOOL", config={ "tool_name": "workflow_agent", "system_prompt": workflow_prompt, "agent_id": metadata.get("agent_id"), "model": "anthropic/claude-3-5-sonnet-latest", "max_iterations": 10, "input_prompt": input_config.prompt if input_config else "", "tools": enabled_tools, "configured_mcps": mcp_configs["configured_mcps"], "custom_mcps": mcp_configs["custom_mcps"] }, next_steps=[] ) workflow = WorkflowDefinition( name=metadata.get("name", "Untitled Workflow"), description=metadata.get("description", "Generated from visual workflow"), steps=[agent_step], entry_point="main_agent_step", triggers=triggers, project_id=metadata.get("project_id", ""), agent_id=metadata.get("agent_id"), is_template=metadata.get("is_template", False), max_execution_time=metadata.get("max_execution_time", 3600), max_retries=metadata.get("max_retries", 3) ) return workflow def _extract_input_configuration(self, nodes: List[Dict[str, Any]]) -> Optional[InputNodeConfig]: """Extract input node configuration from the workflow nodes.""" for node in nodes: if node.get('type') == 'inputNode': data = node.get('data', {}) schedule_config = None if data.get('trigger_type') == 'SCHEDULE' and data.get('schedule_config'): schedule_data = data.get('schedule_config', {}) if schedule_data.get('type'): # New format: {'type': 'simple', 'simple': {...}, 'cron': {...}, 'advanced': {...}} schedule_type = schedule_data.get('type') enabled = schedule_data.get('enabled', True) if schedule_type == 'simple' and schedule_data.get('simple'): simple_config = schedule_data['simple'] schedule_config = ScheduleConfig( interval_type=simple_config.get('interval_type'), interval_value=simple_config.get('interval_value'), timezone=schedule_data.get('timezone', 'UTC'), enabled=enabled ) elif schedule_type == 'cron' and schedule_data.get('cron'): cron_config = schedule_data['cron'] schedule_config = ScheduleConfig( cron_expression=cron_config.get('cron_expression'), timezone=schedule_data.get('timezone', 'UTC'), enabled=enabled ) elif schedule_type == 'advanced' and schedule_data.get('advanced'): advanced_config = schedule_data['advanced'] schedule_config = ScheduleConfig( cron_expression=advanced_config.get('cron_expression'), timezone=advanced_config.get('timezone', 'UTC'), start_date=datetime.fromisoformat(advanced_config['start_date']) if advanced_config.get('start_date') else None, end_date=datetime.fromisoformat(advanced_config['end_date']) if advanced_config.get('end_date') else None, enabled=enabled ) else: schedule_config = ScheduleConfig( cron_expression=schedule_data.get('cron_expression'), interval_type=schedule_data.get('interval_type'), interval_value=schedule_data.get('interval_value'), timezone=schedule_data.get('timezone', 'UTC'), start_date=schedule_data.get('start_date'), end_date=schedule_data.get('end_date'), enabled=schedule_data.get('enabled', True) ) return InputNodeConfig( prompt=data.get('prompt', ''), trigger_type=data.get('trigger_type', 'MANUAL'), webhook_config=data.get('webhook_config'), schedule_config=schedule_config, variables=data.get('variables') ) return None def _extract_triggers_from_input(self, input_config: Optional[InputNodeConfig]) -> List[WorkflowTrigger]: """Extract workflow triggers from input node configuration.""" if not input_config: return [WorkflowTrigger(type="MANUAL", config={})] triggers = [] if input_config.trigger_type == 'MANUAL': triggers.append(WorkflowTrigger(type="MANUAL", config={})) elif input_config.trigger_type == 'WEBHOOK': webhook_config = input_config.webhook_config if webhook_config: if hasattr(webhook_config, 'type'): trigger_config = { "type": webhook_config.type or 'slack', "method": webhook_config.method or 'POST', "authentication": webhook_config.authentication or 'none' } if webhook_config.type == 'slack' and webhook_config.slack: slack_config = webhook_config.slack if hasattr(slack_config, 'model_dump'): trigger_config['slack'] = slack_config.model_dump() elif hasattr(slack_config, 'dict'): trigger_config['slack'] = slack_config.dict() else: trigger_config['slack'] = slack_config elif webhook_config.generic: generic_config = webhook_config.generic if hasattr(generic_config, 'model_dump'): trigger_config['generic'] = generic_config.model_dump() elif hasattr(generic_config, 'dict'): trigger_config['generic'] = generic_config.dict() else: trigger_config['generic'] = generic_config else: trigger_config = { "type": webhook_config.get('type', 'slack'), "method": webhook_config.get('method', 'POST'), "authentication": webhook_config.get('authentication', 'none') } if webhook_config.get('type') == 'slack' and webhook_config.get('slack'): trigger_config['slack'] = webhook_config['slack'] elif webhook_config.get('generic'): trigger_config['generic'] = webhook_config['generic'] triggers.append(WorkflowTrigger(type="WEBHOOK", config=trigger_config)) else: triggers.append(WorkflowTrigger(type="WEBHOOK", config={"type": "slack", "method": "POST", "authentication": "none"})) elif input_config.trigger_type == 'SCHEDULE': if input_config.schedule_config: schedule_config = { "cron_expression": input_config.schedule_config.cron_expression, "interval_type": input_config.schedule_config.interval_type, "interval_value": input_config.schedule_config.interval_value, "timezone": input_config.schedule_config.timezone, "start_date": input_config.schedule_config.start_date.isoformat() if input_config.schedule_config.start_date else None, "end_date": input_config.schedule_config.end_date.isoformat() if input_config.schedule_config.end_date else None, "enabled": input_config.schedule_config.enabled } triggers.append(WorkflowTrigger(type="SCHEDULE", config=schedule_config)) else: # Default schedule trigger triggers.append(WorkflowTrigger( type="SCHEDULE", config={ "interval_type": "hours", "interval_value": 1, "timezone": "UTC", "enabled": True } )) return triggers def _generate_workflow_prompt(self, nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]], input_config: Optional[InputNodeConfig] = None) -> str: """Generate a comprehensive system prompt that describes the workflow.""" prompt_parts = [ "You are an AI agent executing a workflow. Follow these instructions carefully:", "", ] # Add input prompt if available if input_config and input_config.prompt: prompt_parts.extend([ "## Workflow Input Prompt", input_config.prompt, "", ]) prompt_parts.extend([ "## Workflow Overview", "This workflow was created visually and consists of the following components:", "" ]) node_descriptions = [] agent_nodes = [] tool_nodes = [] mcp_nodes = [] for node in nodes: if node.get('type') == 'agentNode': agent_nodes.append(node) desc = self._describe_agent_node(node, edges) node_descriptions.append(desc) elif node.get('type') == 'toolConnectionNode': tool_nodes.append(node) desc = self._describe_tool_node(node, edges) node_descriptions.append(desc) elif node.get('type') == 'mcpNode': mcp_nodes.append(node) desc = self._describe_mcp_node(node, edges) node_descriptions.append(desc) elif node.get('type') == 'inputNode': desc = self._describe_input_node(node, edges) node_descriptions.append(desc) else: desc = self._describe_generic_node(node, edges) node_descriptions.append(desc) prompt_parts.extend(node_descriptions) prompt_parts.append("") # Add trigger information if input_config: prompt_parts.extend([ "## Trigger Configuration", f"**Trigger Type**: {input_config.trigger_type}", ]) if input_config.trigger_type == 'SCHEDULE' and input_config.schedule_config: schedule = input_config.schedule_config if schedule.cron_expression: prompt_parts.append(f"**Schedule**: {schedule.cron_expression} (cron)") elif schedule.interval_type and schedule.interval_value: prompt_parts.append(f"**Schedule**: Every {schedule.interval_value} {schedule.interval_type}") prompt_parts.append(f"**Timezone**: {schedule.timezone}") if input_config.variables: prompt_parts.extend([ "", "## Default Variables", "The following default variables are configured:" ]) for key, value in input_config.variables.items(): prompt_parts.append(f"- **{key}**: {value}") prompt_parts.append("") prompt_parts.extend([ "## Execution Instructions", "", "Execute this workflow by following these steps:", "1. Start with the input or trigger conditions", "2. Process each component in the logical order defined by the connections", "3. Use the available tools as specified in the workflow", "4. Follow the data flow between components", "5. Provide clear output at each step", "6. Handle errors gracefully and provide meaningful feedback", "", "## Available Tools", "You have access to the following tools based on the workflow configuration:" ]) # Extract tool IDs and generate tool descriptions tool_ids = [] enabled_tools = [] # Process regular tools for tool_node in tool_nodes: tool_data = tool_node.get('data', {}) tool_name = tool_data.get('nodeId', tool_data.get('label', 'Unknown Tool')) tool_desc = tool_data.get('description', 'No description available') tool_instructions = tool_data.get('instructions', '') # Build tool description with instructions if provided tool_description = f"- **{tool_name}**: {tool_desc}" if tool_instructions: tool_description += f" - Instructions: {tool_instructions}" prompt_parts.append(tool_description) # Collect tool ID for XML examples and enabled tools tool_id = tool_data.get('nodeId') if tool_id: tool_ids.append(tool_id) enabled_tools.append({ "id": tool_id, "name": tool_data.get('label', tool_name), "description": tool_desc, "instructions": tool_instructions }) # Process MCP tools mcp_tool_descriptions = [] for mcp_node in mcp_nodes: mcp_data = mcp_node.get('data', {}) mcp_type = mcp_data.get('mcpType', 'smithery') enabled_tools_list = mcp_data.get('enabledTools', []) if mcp_data.get('isConfigured', False) and enabled_tools_list: server_name = mcp_data.get('label', 'MCP Server') if mcp_type == 'smithery': qualified_name = mcp_data.get('qualifiedName', '') for tool_name in enabled_tools_list: # Use the clean tool name as the callable method name (same as MCPToolWrapper) # MCPToolWrapper creates methods using just the tool name, not the full mcp_server_tool format clean_tool_name = tool_name.replace('-', '_') tool_description = f"- **{clean_tool_name}** (MCP): From {server_name} ({qualified_name})" mcp_tool_descriptions.append(tool_description) tool_ids.append(clean_tool_name) elif mcp_type == 'custom': for tool_name in enabled_tools_list: # Use the clean tool name as the callable method name (same as MCPToolWrapper) clean_tool_name = tool_name.replace('-', '_') tool_description = f"- **{clean_tool_name}** (Custom MCP): From {server_name}" mcp_tool_descriptions.append(tool_description) tool_ids.append(clean_tool_name) # Add MCP tool descriptions to prompt if mcp_tool_descriptions: prompt_parts.extend([ "", "### MCP Server Tools", "The following tools are available from MCP servers:" ]) prompt_parts.extend(mcp_tool_descriptions) # Add XML tool examples if tools are available if tool_ids: xml_examples = get_tools_xml_examples(tool_ids) if xml_examples: prompt_parts.extend([ "", "## Tool Usage Examples", "Use the following XML format to call tools. Each tool call must be wrapped in tags:", "", xml_examples ]) prompt_parts.extend([ "", "## Workflow Execution", "When executing this workflow:", "- Follow the logical flow defined by the visual connections", "- Use tools in the order and manner specified using the XML format shown above", "- For MCP tools, use the exact tool names and formats shown in the examples", "- Provide clear, step-by-step output", "- If any step fails, explain what went wrong and suggest alternatives", "- Complete the workflow by providing the expected output", "", "Begin execution when the user provides input or triggers the workflow." ]) return "\n".join(prompt_parts) def _describe_input_node(self, node: Dict[str, Any], edges: List[Dict[str, Any]]) -> str: """Describe an input node and its configuration.""" data = node.get('data', {}) prompt = data.get('prompt', 'No prompt specified') trigger_type = data.get('trigger_type', 'MANUAL') output_connections = self._find_node_outputs(node.get('id'), edges) description = [ f"### Input Configuration", f"**Prompt**: {prompt}", f"**Trigger Type**: {trigger_type}", ] if trigger_type == 'SCHEDULE': schedule_config = data.get('schedule_config', {}) if schedule_config.get('type'): schedule_type = schedule_config.get('type') if schedule_type == 'simple' and schedule_config.get('simple'): simple_config = schedule_config['simple'] description.append(f"**Schedule**: Every {simple_config.get('interval_value')} {simple_config.get('interval_type')}") elif schedule_type == 'cron' and schedule_config.get('cron'): cron_config = schedule_config['cron'] description.append(f"**Schedule**: {cron_config.get('cron_expression')} (cron)") elif schedule_type == 'advanced' and schedule_config.get('advanced'): advanced_config = schedule_config['advanced'] description.append(f"**Schedule**: {advanced_config.get('cron_expression')} (advanced cron)") else: if schedule_config.get('cron_expression'): description.append(f"**Schedule**: {schedule_config['cron_expression']} (cron)") elif schedule_config.get('interval_type') and schedule_config.get('interval_value'): description.append(f"**Schedule**: Every {schedule_config['interval_value']} {schedule_config['interval_type']}") if output_connections: description.append(f"**Connects to**: {', '.join(output_connections)}") description.append("") return "\n".join(description) def _describe_agent_node(self, node: Dict[str, Any], edges: List[Dict[str, Any]]) -> str: """Describe an agent node and its role in the workflow.""" data = node.get('data', {}) name = data.get('label', 'AI Agent') instructions = data.get('instructions', 'No specific instructions provided') model = data.get('model', 'Default model') connected_tools = data.get('connectedTools', []) tool_list = [tool.get('name', 'Unknown') for tool in connected_tools] input_connections = self._find_node_inputs(node.get('id'), edges) output_connections = self._find_node_outputs(node.get('id'), edges) description = [ f"### {name}", f"**Role**: {instructions}", f"**Model**: {model}", ] if tool_list: description.append(f"**Available Tools**: {', '.join(tool_list)}") if input_connections: description.append(f"**Receives input from**: {', '.join(input_connections)}") if output_connections: description.append(f"**Sends output to**: {', '.join(output_connections)}") description.append("") return "\n".join(description) def _describe_tool_node(self, node: Dict[str, Any], edges: List[Dict[str, Any]]) -> str: """Describe a tool node and its configuration.""" data = node.get('data', {}) name = data.get('label', 'Tool') tool_id = data.get('nodeId', 'unknown_tool') instructions = data.get('instructions', '') input_connections = self._find_node_inputs(node.get('id'), edges) output_connections = self._find_node_outputs(node.get('id'), edges) description = [ f"### {name} Tool", f"**Tool ID**: {tool_id}", f"**Purpose**: Provides {name.lower()} functionality to the workflow", ] # Add instructions if provided if instructions: description.append(f"**Instructions**: {instructions}") if input_connections: description.append(f"**Connected to agents**: {', '.join(input_connections)}") description.append("") return "\n".join(description) def _describe_generic_node(self, node: Dict[str, Any], edges: List[Dict[str, Any]]) -> str: """Describe a generic node.""" data = node.get('data', {}) name = data.get('label', 'Component') node_type = node.get('type') description = [ f"### {name}", f"**Type**: {node_type}", f"**Purpose**: {data.get('description', 'Workflow component')}", "" ] return "\n".join(description) def _describe_mcp_node(self, node: Dict[str, Any], edges: List[Dict[str, Any]]) -> str: """Describe an MCP node and its configuration.""" data = node.get('data', {}) name = data.get('label', 'MCP Server') mcp_type = data.get('mcpType', 'smithery') enabled_tools = data.get('enabledTools', []) is_configured = data.get('isConfigured', False) input_connections = self._find_node_inputs(node.get('id'), edges) output_connections = self._find_node_outputs(node.get('id'), edges) description = [ f"### {name}", ] if mcp_type == 'smithery': qualified_name = data.get('qualifiedName', '') description.extend([ f"**Type**: Smithery MCP Server", f"**Qualified Name**: {qualified_name}", ]) elif mcp_type == 'custom': custom_config = data.get('customConfig', {}) custom_type = custom_config.get('type', 'sse') description.extend([ f"**Type**: Custom MCP Server ({custom_type.upper()})", ]) description.append(f"**Status**: {'Configured' if is_configured else 'Not Configured'}") if enabled_tools: description.append(f"**Enabled Tools**: {', '.join(enabled_tools)}") description.append(f"**Purpose**: Provides {len(enabled_tools)} MCP tool{'s' if len(enabled_tools) != 1 else ''} to the workflow") else: description.append("**Purpose**: MCP server (no tools enabled)") if input_connections: description.append(f"**Connected from**: {', '.join(input_connections)}") if output_connections: description.append(f"**Connected to**: {', '.join(output_connections)}") description.append("") return "\n".join(description) def _find_node_inputs(self, node_id: str, edges: List[Dict[str, Any]]) -> List[str]: """Find nodes that connect to this node as inputs.""" inputs = [] for edge in edges: if edge.get('target') == node_id: inputs.append(edge.get('source')) return inputs def _find_node_outputs(self, node_id: str, edges: List[Dict[str, Any]]) -> List[str]: """Find nodes that this node connects to as outputs.""" outputs = [] for edge in edges: if edge.get('source') == node_id: outputs.append(edge.get('target')) return outputs def _find_entry_point(self, nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]]) -> str: """Find the entry point of the workflow.""" for node in nodes: if node.get('type') == 'triggerNode': return node.get('id') for node in nodes: if node.get('type') == 'inputNode': return node.get('id') node_ids = {node.get('id') for node in nodes} nodes_with_inputs = {edge.get('target') for edge in edges} root_nodes = node_ids - nodes_with_inputs if root_nodes: return list(root_nodes)[0] return nodes[0].get('id') if nodes else "main_agent_step" def _extract_mcp_configurations(self, mcp_nodes: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]: """Extract MCP configurations from MCP nodes.""" configured_mcps = [] custom_mcps = [] for mcp_node in mcp_nodes: mcp_data = mcp_node.get('data', {}) mcp_type = mcp_data.get('mcpType', 'smithery') is_configured = mcp_data.get('isConfigured', False) enabled_tools = mcp_data.get('enabledTools', []) selected_profile_id = mcp_data.get('selectedProfileId') # Get the selected profile ID logger.info(f"Processing MCP node: id={mcp_node.get('id')}, type={mcp_type}, data={mcp_data}") logger.info(f"MCP node configured: {is_configured}, enabled tools: {enabled_tools}, selected profile: {selected_profile_id}") # Process configured nodes if is_configured: if mcp_type == 'smithery': # Smithery MCP server qualified_name = mcp_data.get('qualifiedName', '') if qualified_name: mcp_config = { 'name': mcp_data.get('label', qualified_name), 'qualifiedName': qualified_name, 'config': mcp_data.get('config', {}), 'enabledTools': enabled_tools, # Can be empty, will be populated from credential manager 'selectedProfileId': selected_profile_id # Include the selected profile ID } configured_mcps.append(mcp_config) logger.info(f"Added Smithery MCP: {qualified_name} with {len(enabled_tools)} enabled tools and profile {selected_profile_id}") else: logger.warning(f"Smithery MCP node {mcp_data.get('label', 'Unknown')} missing qualifiedName") elif mcp_type == 'custom' and enabled_tools: custom_config = mcp_data.get('customConfig', {}) custom_mcp = { 'name': mcp_data.get('label', 'Custom MCP'), 'isCustom': True, 'customType': custom_config.get('type', 'sse'), 'config': custom_config.get('config', {}), 'enabledTools': enabled_tools, 'selectedProfileId': selected_profile_id } custom_mcps.append(custom_mcp) logger.info(f"Added custom MCP: {custom_mcp['name']} with profile {selected_profile_id}") elif mcp_type == 'custom': logger.warning(f"Custom MCP node {mcp_data.get('label', 'Unknown')} is configured but has no enabled tools") else: logger.warning(f"MCP node {mcp_data.get('label', 'Unknown')} is not configured") return { "configured_mcps": configured_mcps, "custom_mcps": custom_mcps } def validate_workflow_flow(nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]]) -> tuple[bool, List[str]]: """Validate a workflow flow for common issues.""" errors = [] if not nodes: errors.append("Workflow must have at least one node") return False, errors # Check for required input node has_input = any(node.get('type') == 'inputNode' for node in nodes) if not has_input: errors.append("Every workflow must have an input node") # Validate input node configuration for node in nodes: if node.get('type') == 'inputNode': data = node.get('data', {}) if not data.get('prompt'): errors.append("Input node must have a prompt configured") trigger_type = data.get('trigger_type', 'MANUAL') if trigger_type == 'SCHEDULE': schedule_config = data.get('schedule_config', {}) if schedule_config.get('type'): schedule_type = schedule_config.get('type') if schedule_type == 'simple': simple_config = schedule_config.get('simple', {}) if not (simple_config.get('interval_type') and simple_config.get('interval_value')): errors.append("Simple schedule must have interval type and value configured") elif schedule_type == 'cron': cron_config = schedule_config.get('cron', {}) if not cron_config.get('cron_expression'): errors.append("Cron schedule must have cron expression configured") elif schedule_type == 'advanced': advanced_config = schedule_config.get('advanced', {}) if not advanced_config.get('cron_expression'): errors.append("Advanced schedule must have cron expression configured") else: errors.append("Schedule must have a valid type (simple, cron, or advanced)") else: if not schedule_config.get('cron_expression') and not (schedule_config.get('interval_type') and schedule_config.get('interval_value')): errors.append("Schedule trigger must have either cron expression or interval configuration") elif trigger_type == 'WEBHOOK': webhook_config = data.get('webhook_config', {}) if not webhook_config: errors.append("Webhook trigger must have webhook configuration") connected_nodes = set() for edge in edges: connected_nodes.add(edge.get('source')) connected_nodes.add(edge.get('target')) node_ids = {node.get('id') for node in nodes} disconnected = node_ids - connected_nodes if len(disconnected) > 1: errors.append(f"Found disconnected nodes: {', '.join(disconnected)}") for edge in edges: if edge.get('source') == edge.get('target'): errors.append(f"Self-referencing edge found on node {edge.get('source')}") has_agent = any(node.get('type') == 'agentNode' for node in nodes) if not has_agent: errors.append("Workflow should have at least one agent node") return len(errors) == 0, errors