suna/backend/workflows/executor.py

559 lines
26 KiB
Python

import asyncio
import uuid
import json
from datetime import datetime, timezone
from typing import Dict, Any, Optional, AsyncGenerator
from .models import WorkflowDefinition, WorkflowExecution
from services.supabase import DBConnection
from utils.logger import logger
from typing import List
class WorkflowExecutor:
"""Executes workflows using the AgentPress agent system."""
def __init__(self, db: DBConnection):
self.db = db
async def execute_workflow(
self,
workflow: WorkflowDefinition,
variables: Optional[Dict[str, Any]] = None,
thread_id: Optional[str] = None,
project_id: Optional[str] = None
) -> AsyncGenerator[Dict[str, Any], None]:
if not thread_id:
thread_id = str(uuid.uuid4())
if not project_id:
project_id = workflow.project_id
logger.info(f"Executing workflow {workflow.name} (ID: {workflow.id}) in thread {thread_id}")
execution = WorkflowExecution(
id=str(uuid.uuid4()),
workflow_id=workflow.id or str(uuid.uuid4()),
status="running",
started_at=datetime.now(timezone.utc),
trigger_type="MANUAL",
trigger_data={},
variables=variables or {}
)
try:
await self._store_execution(execution)
await self._ensure_project_has_sandbox(project_id)
await self._ensure_workflow_thread_exists(thread_id, project_id, workflow, variables)
if not workflow.steps:
raise ValueError("Workflow has no steps defined")
main_step = workflow.steps[0]
system_prompt = main_step.config.get("system_prompt", "")
selected_model = main_step.config.get("model", "anthropic/claude-sonnet-4-20250514")
if variables:
variables_text = "\n\n## Workflow Variables\n"
variables_text += "The following variables are available for this workflow execution:\n"
for key, value in variables.items():
variables_text += f"- **{key}**: {value}\n"
variables_text += "\nUse these variables as needed during workflow execution.\n"
system_prompt += variables_text
enabled_tools = self._extract_enabled_tools_from_workflow(workflow)
mcp_configs = await self._extract_mcp_configurations_from_workflow_and_agent(workflow)
agent_config = {
"name": f"Workflow Agent: {workflow.name}",
"description": workflow.description or "Generated workflow agent",
"system_prompt": system_prompt,
"agentpress_tools": enabled_tools,
"configured_mcps": mcp_configs["configured_mcps"],
"custom_mcps": mcp_configs["custom_mcps"]
}
logger.info(f"Agent config for workflow - configured_mcps: {len(agent_config['configured_mcps'])} servers")
logger.info(f"Agent config for workflow - custom_mcps: {len(agent_config['custom_mcps'])} servers")
from agent.run import run_agent
try:
client = await self.db.client
debug_messages = await client.table('messages').select('*').eq('thread_id', thread_id).execute()
logger.info(f"[Workflow Debug] Found {len(debug_messages.data) if debug_messages.data else 0} messages in thread {thread_id}")
if debug_messages.data:
for msg in debug_messages.data:
logger.info(f"[Workflow Debug] Message: type={msg.get('type', 'unknown')}, created_at={msg.get('created_at', 'no timestamp')}, is_llm_message={msg.get('is_llm_message', False)}")
else:
logger.error(f"[Workflow Debug] No messages found in thread {thread_id} - this will cause 'Received Messages=[]' error")
except Exception as e:
logger.error(f"[Workflow Debug] Error checking messages: {e}")
async for response in run_agent(
thread_id=thread_id,
project_id=project_id,
stream=True,
model_name=selected_model,
enable_thinking=False,
reasoning_effort="low",
enable_context_manager=True,
agent_config=agent_config,
max_iterations=5
):
yield self._transform_agent_response_to_workflow_update(response, execution.id)
if response.get('type') == 'status':
status = response.get('status')
if status in ['completed', 'failed', 'stopped']:
execution.status = status.lower()
execution.completed_at = datetime.now(timezone.utc)
if status == 'failed':
execution.error = response.get('message', 'Workflow execution failed')
await self._update_execution(execution)
break
if execution.status == "running":
execution.status = "completed"
execution.completed_at = datetime.now(timezone.utc)
await self._update_execution(execution)
yield {
"type": "workflow_status",
"execution_id": execution.id,
"status": "completed",
"message": "Workflow completed successfully"
}
except Exception as e:
logger.error(f"Error executing workflow {workflow.id}: {e}")
execution.status = "failed"
execution.completed_at = datetime.now(timezone.utc)
execution.error = str(e)
await self._update_execution(execution)
yield {
"type": "workflow_status",
"execution_id": execution.id,
"status": "failed",
"error": str(e)
}
def _transform_agent_response_to_workflow_update(
self,
agent_response: Dict[str, Any],
execution_id: str
) -> Dict[str, Any]:
"""Transform agent response into workflow execution update."""
workflow_response = {
**agent_response,
"execution_id": execution_id,
"source": "workflow_executor"
}
if isinstance(workflow_response.get('metadata'), str):
try:
metadata = json.loads(workflow_response['metadata'])
except:
metadata = {}
else:
metadata = workflow_response.get('metadata', {})
metadata.update({
"is_workflow_response": True,
"workflow_execution_id": execution_id
})
workflow_response['metadata'] = json.dumps(metadata) if isinstance(workflow_response.get('metadata'), str) else metadata
return workflow_response
async def _store_execution(self, execution: WorkflowExecution):
"""Store workflow execution in database."""
try:
client = await self.db.client
logger.info(f"Execution {execution.id} handled by API endpoint")
except Exception as e:
logger.error(f"Failed to store workflow execution: {e}")
async def _update_execution(self, execution: WorkflowExecution):
"""Update workflow execution in database."""
try:
client = await self.db.client
update_data = {
"status": execution.status,
"completed_at": execution.completed_at.isoformat() if execution.completed_at else None,
"error": execution.error
}
await client.table('workflow_executions').update(update_data).eq('id', execution.id).execute()
logger.info(f"Updated workflow execution {execution.id} status to {execution.status}")
except Exception as e:
logger.error(f"Failed to update workflow execution: {e}")
async def get_execution_status(self, execution_id: str) -> Optional[WorkflowExecution]:
"""Get the status of a workflow execution."""
try:
client = await self.db.client
result = await client.table('workflow_executions').select('*').eq('id', execution_id).execute()
if result.data:
data = result.data[0]
return WorkflowExecution(
id=data['id'],
workflow_id=data['workflow_id'],
status=data['status'],
started_at=datetime.fromisoformat(data['started_at']) if data['started_at'] else None,
completed_at=datetime.fromisoformat(data['completed_at']) if data['completed_at'] else None,
trigger_type=data.get('triggered_by', 'MANUAL'),
trigger_data={},
variables=data.get('execution_context', {}),
error=data.get('error')
)
return None
except Exception as e:
logger.error(f"Failed to get execution status: {e}")
return None
async def _ensure_workflow_thread_exists(
self,
thread_id: str,
project_id: str,
workflow: WorkflowDefinition,
variables: Optional[Dict[str, Any]] = None
):
"""Ensure a thread exists for workflow execution (create only if missing)."""
try:
client = await self.db.client
existing_thread = await client.table('threads').select('thread_id').eq('thread_id', thread_id).execute()
if existing_thread.data:
logger.info(f"Thread {thread_id} already exists, skipping creation")
return
await self._create_workflow_thread(thread_id, project_id, workflow, variables)
except Exception as e:
logger.error(f"Failed to ensure workflow thread exists: {e}")
raise
def _extract_enabled_tools_from_workflow(self, workflow: WorkflowDefinition) -> Dict[str, Dict[str, Any]]:
"""Extract tools that should be enabled based on workflow configuration."""
enabled_tools = {
"message_tool": {"enabled": True, "description": "Send messages"},
"expand_msg_tool": {"enabled": True, "description": "Expand messages"}
}
logger.info(f"Processing workflow with {len(workflow.steps)} steps")
logger.info(f"Workflow name: {workflow.name}")
logger.info(f"Workflow ID: {workflow.id}")
for step in workflow.steps:
step_config = step.config or {}
tools_section = step_config.get("tools", [])
logger.info(f"Step {step.id} - config keys: {list(step_config.keys())}")
logger.info(f"Step {step.id} - tools section: {tools_section}")
for tool in tools_section:
if isinstance(tool, dict):
tool_id = tool.get("id") or tool.get("tool_id") or tool.get("nodeId")
tool_name = tool.get("name", tool_id)
tool_desc = tool.get("description", f"Tool: {tool_name}")
logger.info(f"Processing tool dict: {tool}")
logger.info(f"Extracted tool_id: {tool_id}")
if tool_id:
enabled_tools[tool_id] = {
"enabled": True,
"description": tool_desc
}
logger.info(f"Added tool {tool_id} to enabled_tools")
elif isinstance(tool, str):
enabled_tools[tool] = {
"enabled": True,
"description": f"Tool: {tool}"
}
logger.info(f"Added string tool {tool} to enabled_tools")
if hasattr(workflow, 'metadata') and workflow.metadata:
logger.info(f"Workflow metadata: {workflow.metadata}")
workflow_tools = workflow.metadata.get("tools", [])
logger.info(f"Workflow metadata tools: {workflow_tools}")
for tool in workflow_tools:
if isinstance(tool, dict):
tool_id = tool.get("id") or tool.get("nodeId")
if tool_id:
enabled_tools[tool_id] = {
"enabled": True,
"description": tool.get("description", f"Tool: {tool_id}")
}
logger.info(f"Added metadata tool {tool_id} to enabled_tools")
else:
logger.info("No workflow metadata found")
logger.info(f"Final enabled tools for workflow: {list(enabled_tools.keys())}")
return enabled_tools
async def _extract_mcp_configurations_from_workflow_and_agent(self, workflow: WorkflowDefinition) -> Dict[str, List[Dict[str, Any]]]:
"""Extract MCP configurations from workflow steps and agent using credential manager."""
configured_mcps = []
custom_mcps = []
logger.info(f"Processing workflow with {len(workflow.steps)} steps for MCP extraction")
logger.info(f"Workflow name: {workflow.name}")
logger.info(f"Workflow ID: {workflow.id}")
logger.info(f"Workflow agent_id: {workflow.agent_id}")
for step in workflow.steps:
step_config = step.config or {}
step_configured_mcps = step_config.get("configured_mcps", [])
logger.info(f"Step {step.id} - configured_mcps: {step_configured_mcps}")
for mcp in step_configured_mcps:
if isinstance(mcp, dict):
qualified_name = mcp.get("qualifiedName")
if qualified_name:
configured_mcps.append({
"name": mcp.get("name", qualified_name),
"qualifiedName": qualified_name,
"config": mcp.get("config", {}),
"enabledTools": mcp.get("enabledTools", []),
"selectedProfileId": mcp.get("selectedProfileId"),
"instructions": mcp.get("instructions", "")
})
logger.info(f"Added configured MCP from workflow step: {qualified_name} with profile {mcp.get('selectedProfileId')}")
step_custom_mcps = step_config.get("custom_mcps", [])
logger.info(f"Step {step.id} - custom_mcps: {step_custom_mcps}")
for mcp in step_custom_mcps:
if isinstance(mcp, dict):
mcp_name = mcp.get("name", "Custom MCP")
custom_mcps.append({
"name": mcp_name,
"isCustom": True,
"customType": mcp.get("customType", mcp.get("type", "sse")),
"config": mcp.get("config", {}),
"enabledTools": mcp.get("enabledTools", []),
"selectedProfileId": mcp.get("selectedProfileId"),
"instructions": mcp.get("instructions", "")
})
logger.info(f"Added custom MCP from workflow step: {mcp_name} with profile {mcp.get('selectedProfileId')}")
from mcp_local.credential_manager import credential_manager
try:
client = await self.db.client
project_result = await client.table('projects').select('account_id').eq('project_id', workflow.project_id).execute()
if not project_result.data:
raise ValueError(f"Project {workflow.project_id} not found")
account_id = project_result.data[0]['account_id']
logger.info(f"Getting MCP credentials for workflow account_id: {account_id}")
except Exception as e:
logger.error(f"Error getting account_id from project: {e}")
account_id = None
if account_id:
for i, mcp in enumerate(configured_mcps):
qualified_name = mcp.get("qualifiedName")
selected_profile_id = mcp.get("selectedProfileId")
if qualified_name and not mcp.get("config"):
try:
if selected_profile_id:
logger.info(f"Using selected profile {selected_profile_id} for MCP {qualified_name}")
credential = await credential_manager.get_credential_by_profile(account_id, selected_profile_id)
else:
logger.info(f"No profile selected, using default profile for MCP {qualified_name}")
credential = await credential_manager.get_default_credential_profile(account_id, qualified_name)
if credential:
configured_mcps[i]["config"] = credential.config
logger.info(f"Added credentials for MCP {qualified_name} using profile: {getattr(credential, 'profile_name', 'legacy')}")
else:
logger.warning(f"No credential profile found for MCP {qualified_name}")
except Exception as e:
logger.error(f"Error getting credential for MCP {qualified_name}: {e}")
for i, mcp in enumerate(custom_mcps):
mcp_name = mcp.get("name", "Custom MCP")
mcp_type = mcp.get("customType", "sse")
selected_profile_id = mcp.get("selectedProfileId")
if not mcp.get("config"):
try:
if selected_profile_id:
logger.info(f"Using selected profile {selected_profile_id} for custom MCP {mcp_name}")
credential = await credential_manager.get_credential_by_profile(account_id, selected_profile_id)
else:
custom_qualified_name = f"custom_{mcp_type}_{mcp_name.replace(' ', '_').lower()}"
logger.info(f"No profile selected, using default profile for custom MCP {mcp_name}")
credential = await credential_manager.get_default_credential_profile(account_id, custom_qualified_name)
if credential:
custom_mcps[i]["config"] = credential.config
logger.info(f"Added credentials for custom MCP {mcp_name} using profile: {getattr(credential, 'profile_name', 'legacy')}")
else:
logger.warning(f"No credential profile found for custom MCP {mcp_name}")
except Exception as e:
logger.error(f"Error getting credential for custom MCP {mcp_name}: {e}")
else:
logger.warning("No account_id found, skipping MCP credential lookup")
logger.info(f"Final configured MCPs for workflow: {len(configured_mcps)} servers")
logger.info(f"Final custom MCPs for workflow: {len(custom_mcps)} servers")
for mcp in configured_mcps:
config_keys = list(mcp.get('config', {}).keys()) if mcp.get('config') else []
logger.info(f"Configured MCP: {mcp.get('qualifiedName')} with tools: {mcp.get('enabledTools', [])} and config keys: {config_keys}")
for mcp in custom_mcps:
config_keys = list(mcp.get('config', {}).keys()) if mcp.get('config') else []
logger.info(f"Custom MCP: {mcp.get('name')} with tools: {mcp.get('enabledTools', [])} and config keys: {config_keys}")
return {
"configured_mcps": configured_mcps,
"custom_mcps": custom_mcps
}
async def _create_workflow_thread(
self,
thread_id: str,
project_id: str,
workflow: WorkflowDefinition,
variables: Optional[Dict[str, Any]] = None
):
"""Create a thread in the database for workflow execution."""
try:
client = await self.db.client
project_result = await client.table('projects').select('account_id').eq('project_id', project_id).execute()
if not project_result.data:
raise ValueError(f"Project {project_id} not found")
account_id = project_result.data[0]['account_id']
thread_data = {
"thread_id": thread_id,
"project_id": project_id,
"account_id": account_id,
"metadata": {
"workflow_id": workflow.id,
"workflow_name": workflow.name,
"is_workflow_execution": True,
"workflow_run_name": f"Workflow Run: {workflow.name}"
},
"created_at": datetime.now(timezone.utc).isoformat()
}
await client.table('threads').insert(thread_data).execute()
input_prompt = ""
if workflow.steps:
main_step = workflow.steps[0]
input_prompt = main_step.config.get("input_prompt", "")
if input_prompt:
initial_message = input_prompt
else:
initial_message = f"Execute the workflow: {workflow.name}"
if workflow.description:
initial_message += f"\n\nDescription: {workflow.description}"
if variables:
initial_message += f"\n\nVariables: {json.dumps(variables, indent=2)}"
message_data = {
"message_id": str(uuid.uuid4()),
"thread_id": thread_id,
"type": "user",
"is_llm_message": True,
"content": json.dumps({"role": "user", "content": initial_message}),
"created_at": datetime.now(timezone.utc).isoformat()
}
await client.table('messages').insert(message_data).execute()
logger.info(f"Created workflow thread {thread_id} for workflow {workflow.id}")
except Exception as e:
logger.error(f"Failed to create workflow thread: {e}")
raise
async def _ensure_project_has_sandbox(self, project_id: str):
"""Ensure that a project has a sandbox, creating one if it doesn't exist."""
try:
client = await self.db.client
project_result = await client.table('projects').select('*').eq('project_id', project_id).execute()
if not project_result.data:
raise ValueError(f"Project {project_id} not found")
project_data = project_result.data[0]
sandbox_info = project_data.get('sandbox', {})
sandbox_id = sandbox_info.get('id') if sandbox_info else None
if not sandbox_id:
logger.info(f"No sandbox found for workflow project {project_id}, creating new sandbox")
await self._create_new_sandbox_for_project(client, project_id)
else:
logger.info(f"Sandbox {sandbox_id} already exists for workflow project {project_id}, ensuring it's active")
try:
from sandbox.sandbox import get_or_start_sandbox
await get_or_start_sandbox(sandbox_id)
logger.info(f"Sandbox {sandbox_id} is now active for workflow project {project_id}")
except Exception as sandbox_error:
if "not found" in str(sandbox_error).lower():
logger.warning(f"Sandbox {sandbox_id} not found in Daytona system, creating new sandbox for project {project_id}")
await self._create_new_sandbox_for_project(client, project_id)
else:
raise sandbox_error
except Exception as e:
logger.error(f"Failed to ensure sandbox for workflow project {project_id}: {e}")
raise
async def _create_new_sandbox_for_project(self, client, project_id: str):
"""Create a new sandbox and update the project record."""
from sandbox.sandbox import create_sandbox
import uuid
sandbox_pass = str(uuid.uuid4())
sandbox = create_sandbox(sandbox_pass, project_id)
sandbox_id = sandbox.id
logger.info(f"Created new sandbox {sandbox_id} for workflow project {project_id}")
vnc_link = sandbox.get_preview_link(6080)
website_link = sandbox.get_preview_link(8080)
vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link).split("url='")[1].split("'")[0]
website_url = website_link.url if hasattr(website_link, 'url') else str(website_link).split("url='")[1].split("'")[0]
token = None
if hasattr(vnc_link, 'token'):
token = vnc_link.token
elif "token='" in str(vnc_link):
token = str(vnc_link).split("token='")[1].split("'")[0]
update_result = await client.table('projects').update({
'sandbox': {
'id': sandbox_id,
'pass': sandbox_pass,
'vnc_preview': vnc_url,
'sandbox_url': website_url,
'token': token
}
}).eq('project_id', project_id).execute()
if not update_result.data:
logger.error(f"Failed to update project {project_id} with new sandbox {sandbox_id}")
try:
from sandbox.sandbox import delete_sandbox
await delete_sandbox(sandbox_id)
except Exception as cleanup_e:
logger.error(f"Error cleaning up sandbox {sandbox_id}: {str(cleanup_e)}")
raise Exception("Failed to update project with sandbox information")
logger.info(f"Successfully created and configured sandbox {sandbox_id} for workflow project {project_id}")