diff --git a/backend/README.md b/backend/README.md index ad42b76e..389cf192 100644 --- a/backend/README.md +++ b/backend/README.md @@ -83,6 +83,104 @@ RABBITMQ_PORT=5672 --- +## Feature Flags + +The backend includes a Redis-backed feature flag system that allows you to control feature availability without code deployments. + +### Setup + +The feature flag system uses the existing Redis service and is automatically available when Redis is running. + +### CLI Management + +Use the CLI tool to manage feature flags: + +```bash +cd backend/flags +python setup.py [arguments] +``` + +#### Available Commands + +**Enable a feature flag:** +```bash +python setup.py enable test_flag "Test decsription" +``` + +**Disable a feature flag:** +```bash +python setup.py disable test_flag +``` + +**List all feature flags:** +```bash +python setup.py list +``` + +### API Endpoints + +Feature flags are accessible via REST API: + +**Get all feature flags:** +```bash +GET /feature-flags +``` + +**Get specific feature flag:** +```bash +GET /feature-flags/{flag_name} +``` + +Example response: +```json +{ + "test_flag": { + "enabled": true, + "description": "Test flag", + "updated_at": "2024-01-15T10:30:00Z" + } +} +``` + +### Backend Integration + +Use feature flags in your Python code: + +```python +from flags.flags import is_enabled + +# Check if a feature is enabled +if await is_enabled('test_flag'): + # Feature-specific logic + pass + +# With fallback value +enabled = await is_enabled('new_feature', default=False) +``` + +### Current Feature Flags + +The system currently supports these feature flags: + +- **`custom_agents`**: Controls custom agent creation and management +- **`agent_marketplace`**: Controls agent marketplace functionality + +### Error Handling + +The feature flag system includes robust error handling: + +- If Redis is unavailable, flags default to `False` +- API endpoints return empty objects on Redis errors +- CLI operations show clear error messages + +### Caching + +- Backend operations are direct Redis calls (no caching) +- Frontend includes 5-minute caching for performance +- Use `clearCache()` in frontend to force refresh + +--- + ## Production Setup For production deployments, use the following command to set resource limits diff --git a/backend/agent/api.py b/backend/agent/api.py index 2b3b8d82..4f051a14 100644 --- a/backend/agent/api.py +++ b/backend/agent/api.py @@ -22,6 +22,7 @@ from sandbox.sandbox import create_sandbox, delete_sandbox, get_or_start_sandbox from services.llm import make_llm_api_call from run_agent_background import run_agent_background, _cleanup_redis_response_list, update_agent_run_status from utils.constants import MODEL_NAME_ALIASES +from flags.flags import is_enabled # Initialize shared resources router = APIRouter() @@ -1059,6 +1060,12 @@ async def initiate_agent_with_files( # TODO: Clean up created project/thread if initiation fails mid-way raise HTTPException(status_code=500, detail=f"Failed to initiate agent session: {str(e)}") + + +# Custom agents + + + @router.get("/agents", response_model=AgentsResponse) async def get_agents( user_id: str = Depends(get_current_user_id_from_jwt), @@ -1073,6 +1080,11 @@ async def get_agents( tools: Optional[str] = Query(None, description="Comma-separated list of tools to filter by") ): """Get agents for the current user with pagination, search, sort, and filter support.""" + if not await is_enabled("custom_agents"): + raise HTTPException( + status_code=403, + detail="Custom agents currently disabled. This feature is not available at the moment." + ) logger.info(f"Fetching agents for user: {user_id} with page={page}, limit={limit}, search='{search}', sort_by={sort_by}, sort_order={sort_order}") client = await db.client @@ -1234,6 +1246,12 @@ async def get_agents( @router.get("/agents/{agent_id}", response_model=AgentResponse) async def get_agent(agent_id: str, user_id: str = Depends(get_current_user_id_from_jwt)): """Get a specific agent by ID. Only the owner can access non-public agents.""" + if not await is_enabled("custom_agents"): + raise HTTPException( + status_code=403, + detail="Custom agents currently disabled. This feature is not available at the moment." + ) + logger.info(f"Fetching agent {agent_id} for user: {user_id}") client = await db.client @@ -1283,6 +1301,11 @@ async def create_agent( ): """Create a new agent.""" logger.info(f"Creating new agent for user: {user_id}") + if not await is_enabled("custom_agents"): + raise HTTPException( + status_code=403, + detail="Custom agents currently disabled. This feature is not available at the moment." + ) client = await db.client try: @@ -1350,6 +1373,11 @@ async def update_agent( user_id: str = Depends(get_current_user_id_from_jwt) ): """Update an existing agent.""" + if not await is_enabled("custom_agents"): + raise HTTPException( + status_code=403, + detail="Custom agent currently disabled. This feature is not available at the moment." + ) logger.info(f"Updating agent {agent_id} for user: {user_id}") client = await db.client @@ -1441,6 +1469,11 @@ async def update_agent( @router.delete("/agents/{agent_id}") async def delete_agent(agent_id: str, user_id: str = Depends(get_current_user_id_from_jwt)): """Delete an agent.""" + if not await is_enabled("custom_agents"): + raise HTTPException( + status_code=403, + detail="Custom agent currently disabled. This feature is not available at the moment." + ) logger.info(f"Deleting agent: {agent_id}") client = await db.client @@ -1503,6 +1536,12 @@ async def get_marketplace_agents( creator: Optional[str] = Query(None, description="Filter by creator name") ): """Get public agents from the marketplace with pagination, search, sort, and filter support.""" + if not await is_enabled("agent_marketplace"): + raise HTTPException( + status_code=403, + detail="Custom agent currently disabled. This feature is not available at the moment." + ) + logger.info(f"Fetching marketplace agents with page={page}, limit={limit}, search='{search}', tags='{tags}', sort_by={sort_by}") client = await db.client @@ -1569,6 +1608,12 @@ async def publish_agent_to_marketplace( user_id: str = Depends(get_current_user_id_from_jwt) ): """Publish an agent to the marketplace.""" + if not await is_enabled("agent_marketplace"): + raise HTTPException( + status_code=403, + detail="Custom agent currently disabled. This feature is not available at the moment." + ) + logger.info(f"Publishing agent {agent_id} to marketplace") client = await db.client @@ -1608,6 +1653,12 @@ async def unpublish_agent_from_marketplace( user_id: str = Depends(get_current_user_id_from_jwt) ): """Unpublish an agent from the marketplace.""" + if not await is_enabled("agent_marketplace"): + raise HTTPException( + status_code=403, + detail="Custom agent currently disabled. This feature is not available at the moment." + ) + logger.info(f"Unpublishing agent {agent_id} from marketplace") client = await db.client @@ -1642,6 +1693,12 @@ async def add_agent_to_library( user_id: str = Depends(get_current_user_id_from_jwt) ): """Add an agent from the marketplace to user's library.""" + if not await is_enabled("agent_marketplace"): + raise HTTPException( + status_code=403, + detail="Custom agent currently disabled. This feature is not available at the moment." + ) + logger.info(f"Adding marketplace agent {agent_id} to user {user_id} library") client = await db.client @@ -1673,6 +1730,12 @@ async def add_agent_to_library( @router.get("/user/agent-library") async def get_user_agent_library(user_id: str = Depends(get_current_user_id_from_jwt)): """Get user's agent library (agents added from marketplace).""" + if not await is_enabled("agent_marketplace"): + raise HTTPException( + status_code=403, + detail="Custom agent currently disabled. This feature is not available at the moment." + ) + logger.info(f"Fetching agent library for user {user_id}") client = await db.client @@ -1706,6 +1769,12 @@ async def get_agent_builder_chat_history( user_id: str = Depends(get_current_user_id_from_jwt) ): """Get chat history for agent builder sessions for a specific agent.""" + if not await is_enabled("custom_agents"): + raise HTTPException( + status_code=403, + detail="Custom agents currently disabled. This feature is not available at the moment." + ) + logger.info(f"Fetching agent builder chat history for agent: {agent_id}") client = await db.client diff --git a/backend/agent/gemini_prompt.py b/backend/agent/gemini_prompt.py index b1bbfb25..69b239cf 100644 --- a/backend/agent/gemini_prompt.py +++ b/backend/agent/gemini_prompt.py @@ -543,13 +543,16 @@ For casual conversation and social interactions: ## 7.3 ATTACHMENT PROTOCOL - **CRITICAL: ALL VISUALIZATIONS MUST BE ATTACHED:** * When using the 'ask' tool , ALWAYS attach ALL visualizations, markdown files, charts, graphs, reports, and any viewable content created + * **MANDATORY RULE: If you have created ANY files during this conversation, you MUST include them as attachments when using the ask tool** * This includes but is not limited to: HTML files, PDF documents, markdown files, images, data visualizations, presentations, reports, dashboards, and UI mockups + * **NEVER use the ask tool without attachments if you have created files** - this is a critical error * NEVER mention a visualization or viewable content without attaching it * If you've created multiple visualizations, attach ALL of them * Always make visualizations available to the user BEFORE marking tasks as complete * For web applications or interactive content, always attach the main HTML file * When creating data analysis results, charts must be attached, not just described * Remember: If the user should SEE it, you must ATTACH it with the 'ask' tool + * **EXAMPLE: If you create files like main.py, README.md, config.json, notes.txt, you MUST use: ** * Verify that ALL visual outputs have been attached before proceeding - **Attachment Checklist:** @@ -562,7 +565,7 @@ For casual conversation and social interactions: * Analysis results with visual components * UI designs and mockups * Any file intended for user viewing or interaction - + * **ANY FILES CREATED DURING THE CONVERSATION - ALWAYS ATTACH THEM** # 8. COMPLETION PROTOCOLS diff --git a/backend/agent/run.py b/backend/agent/run.py index e514d22e..e97ef729 100644 --- a/backend/agent/run.py +++ b/backend/agent/run.py @@ -290,7 +290,9 @@ async def run_agent( latest_user_message = await client.table('messages').select('*').eq('thread_id', thread_id).eq('type', 'user').order('created_at', desc=True).limit(1).execute() if latest_user_message.data and len(latest_user_message.data) > 0: - data = json.loads(latest_user_message.data[0]['content']) + data = latest_user_message.data[0]['content'] + if isinstance(data, str): + data = json.loads(data) trace.update(input=data['content']) while continue_execution and iteration_count < max_iterations: @@ -327,14 +329,16 @@ async def run_agent( latest_browser_state_msg = await client.table('messages').select('*').eq('thread_id', thread_id).eq('type', 'browser_state').order('created_at', desc=True).limit(1).execute() if latest_browser_state_msg.data and len(latest_browser_state_msg.data) > 0: try: - browser_content = json.loads(latest_browser_state_msg.data[0]["content"]) + browser_content = latest_browser_state_msg.data[0]["content"] + if isinstance(browser_content, str): + browser_content = json.loads(browser_content) screenshot_base64 = browser_content.get("screenshot_base64") - screenshot_url = browser_content.get("screenshot_url") + screenshot_url = browser_content.get("image_url") # Create a copy of the browser state without screenshot data browser_state_text = browser_content.copy() browser_state_text.pop('screenshot_base64', None) - browser_state_text.pop('screenshot_url', None) + browser_state_text.pop('image_url', None) if browser_state_text: temp_message_content_list.append({ @@ -348,6 +352,7 @@ async def run_agent( "type": "image_url", "image_url": { "url": screenshot_url, + "format": "image/jpeg" } }) elif screenshot_base64: @@ -369,7 +374,7 @@ async def run_agent( latest_image_context_msg = await client.table('messages').select('*').eq('thread_id', thread_id).eq('type', 'image_context').order('created_at', desc=True).limit(1).execute() if latest_image_context_msg.data and len(latest_image_context_msg.data) > 0: try: - image_context_content = json.loads(latest_image_context_msg.data[0]["content"]) + image_context_content = latest_image_context_msg.data[0]["content"] if isinstance(latest_image_context_msg.data[0]["content"], dict) else json.loads(latest_image_context_msg.data[0]["content"]) base64_image = image_context_content.get("base64") mime_type = image_context_content.get("mime_type") file_path = image_context_content.get("file_path", "unknown file") diff --git a/backend/agent/tools/message_tool.py b/backend/agent/tools/message_tool.py index 6b07e437..eef3ef59 100644 --- a/backend/agent/tools/message_tool.py +++ b/backend/agent/tools/message_tool.py @@ -1,5 +1,6 @@ from typing import List, Optional, Union from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema +from utils.logger import logger class MessageTool(Tool): """Tool for user communication and interaction. @@ -68,11 +69,11 @@ This information will help me make sure the cake meets your expectations for the Returns: ToolResult indicating the question was successfully sent """ - try: + try: # Convert single attachment to list for consistent handling if attachments and isinstance(attachments, str): attachments = [attachments] - + return self.success_response({"status": "Awaiting user response..."}) except Exception as e: return self.fail_response(f"Error asking user: {str(e)}") diff --git a/backend/agent/tools/sb_deploy_tool.py b/backend/agent/tools/sb_deploy_tool.py index bbd0d3e2..5c52f394 100644 --- a/backend/agent/tools/sb_deploy_tool.py +++ b/backend/agent/tools/sb_deploy_tool.py @@ -107,7 +107,8 @@ class SandboxDeployTool(SandboxToolsBase): npx wrangler pages deploy {full_path} --project-name {project_name}))''' # Execute the command directly using the sandbox's process.exec method - response = self.sandbox.process.exec(deploy_cmd, timeout=300) + response = self.sandbox.process.exec(f"/bin/sh -c \"{deploy_cmd}\"", + timeout=300) print(f"Deployment command output: {response.result}") diff --git a/backend/agentpress/response_processor.py b/backend/agentpress/response_processor.py index 8b7af0d7..3076f7a6 100644 --- a/backend/agentpress/response_processor.py +++ b/backend/agentpress/response_processor.py @@ -6,7 +6,6 @@ This module handles the processing of LLM responses, including: - XML and native tool call detection and parsing - Tool execution orchestration - Message formatting and persistence -- Cost calculation and tracking """ import json @@ -20,13 +19,13 @@ from utils.logger import logger from agentpress.tool import ToolResult from agentpress.tool_registry import ToolRegistry from agentpress.xml_tool_parser import XMLToolParser -from litellm import completion_cost from langfuse.client import StatefulTraceClient from services.langfuse import langfuse from agentpress.utils.json_helpers import ( ensure_dict, ensure_list, safe_json_parse, to_json_string, format_for_yield ) +from litellm import token_counter # Type alias for XML result adding strategy XmlAddingStrategy = Literal["user_message", "assistant_message", "inline_edit"] @@ -146,6 +145,21 @@ class ResponseProcessor: tool_result_message_objects = {} # tool_index -> full saved message object has_printed_thinking_prefix = False # Flag for printing thinking prefix only once agent_should_terminate = False # Flag to track if a terminating tool has been executed + complete_native_tool_calls = [] # Initialize early for use in assistant_response_end + + # Collect metadata for reconstructing LiteLLM response object + streaming_metadata = { + "model": llm_model, + "created": None, + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0 + }, + "response_ms": None, + "first_chunk_time": None, + "last_chunk_time": None + } logger.info(f"Streaming Config: XML={config.xml_tool_calling}, Native={config.native_tool_calling}, " f"Execute on stream={config.execute_on_stream}, Strategy={config.tool_execution_strategy}") @@ -172,6 +186,26 @@ class ResponseProcessor: __sequence = 0 async for chunk in llm_response: + # Extract streaming metadata from chunks + current_time = datetime.now(timezone.utc).timestamp() + if streaming_metadata["first_chunk_time"] is None: + streaming_metadata["first_chunk_time"] = current_time + streaming_metadata["last_chunk_time"] = current_time + + # Extract metadata from chunk attributes + if hasattr(chunk, 'created') and chunk.created: + streaming_metadata["created"] = chunk.created + if hasattr(chunk, 'model') and chunk.model: + streaming_metadata["model"] = chunk.model + if hasattr(chunk, 'usage') and chunk.usage: + # Update usage information if available (including zero values) + if hasattr(chunk.usage, 'prompt_tokens') and chunk.usage.prompt_tokens is not None: + streaming_metadata["usage"]["prompt_tokens"] = chunk.usage.prompt_tokens + if hasattr(chunk.usage, 'completion_tokens') and chunk.usage.completion_tokens is not None: + streaming_metadata["usage"]["completion_tokens"] = chunk.usage.completion_tokens + if hasattr(chunk.usage, 'total_tokens') and chunk.usage.total_tokens is not None: + streaming_metadata["usage"]["total_tokens"] = chunk.usage.total_tokens + if hasattr(chunk, 'choices') and chunk.choices and hasattr(chunk.choices[0], 'finish_reason') and chunk.choices[0].finish_reason: finish_reason = chunk.choices[0].finish_reason logger.debug(f"Detected finish_reason: {finish_reason}") @@ -317,6 +351,38 @@ class ResponseProcessor: # print() # Add a final newline after the streaming loop finishes # --- After Streaming Loop --- + + if ( + streaming_metadata["usage"]["total_tokens"] == 0 + ): + logger.info("🔥 No usage data from provider, counting with litellm.token_counter") + + try: + # prompt side + prompt_tokens = token_counter( + model=llm_model, + messages=prompt_messages # chat or plain; token_counter handles both + ) + + # completion side + completion_tokens = token_counter( + model=llm_model, + text=accumulated_content or "" # empty string safe + ) + + streaming_metadata["usage"]["prompt_tokens"] = prompt_tokens + streaming_metadata["usage"]["completion_tokens"] = completion_tokens + streaming_metadata["usage"]["total_tokens"] = prompt_tokens + completion_tokens + + logger.info( + f"🔥 Estimated tokens – prompt: {prompt_tokens}, " + f"completion: {completion_tokens}, total: {prompt_tokens + completion_tokens}" + ) + self.trace.event(name="usage_calculated_with_litellm_token_counter", level="DEFAULT", status_message=(f"Usage calculated with litellm.token_counter")) + except Exception as e: + logger.warning(f"Failed to calculate usage: {str(e)}") + self.trace.event(name="failed_to_calculate_usage", level="WARNING", status_message=(f"Failed to calculate usage: {str(e)}")) + # Wait for pending tool executions from streaming phase tool_results_buffer = [] # Stores (tool_call, result, tool_index, context) @@ -409,7 +475,7 @@ class ResponseProcessor: accumulated_content = accumulated_content[:last_chunk_end_pos] # ... (Extract complete_native_tool_calls logic) ... - complete_native_tool_calls = [] + # Update complete_native_tool_calls from buffer (initialized earlier) if config.native_tool_calling: for idx, tc_buf in tool_calls_buffer.items(): if tc_buf['id'] and tc_buf['function']['name'] and tc_buf['function']['arguments']: @@ -575,34 +641,6 @@ class ResponseProcessor: self.trace.event(name="failed_to_save_tool_result_for_index", level="ERROR", status_message=(f"Failed to save tool result for index {tool_idx}, not yielding result message.")) # Optionally yield error status for saving failure? - # --- Calculate and Store Cost --- - if last_assistant_message_object: # Only calculate if assistant message was saved - try: - # Use accumulated_content for streaming cost calculation - final_cost = completion_cost( - model=llm_model, - messages=prompt_messages, # Use the prompt messages provided - completion=accumulated_content - ) - if final_cost is not None and final_cost > 0: - logger.info(f"Calculated final cost for stream: {final_cost}") - await self.add_message( - thread_id=thread_id, - type="cost", - content={"cost": final_cost}, - is_llm_message=False, # Cost is metadata - metadata={"thread_run_id": thread_run_id} # Keep track of the run - ) - logger.info(f"Cost message saved for stream: {final_cost}") - self.trace.update(metadata={"cost": final_cost}) - else: - logger.info("Stream cost calculation resulted in zero or None, not storing cost message.") - self.trace.update(metadata={"cost": 0}) - except Exception as e: - logger.error(f"Error calculating final cost for stream: {str(e)}") - self.trace.event(name="error_calculating_final_cost_for_stream", level="ERROR", status_message=(f"Error calculating final cost for stream: {str(e)}")) - - # --- Final Finish Status --- if finish_reason and finish_reason != "xml_tool_limit_reached": finish_content = {"status_type": "finish", "finish_reason": finish_reason} @@ -628,9 +666,107 @@ class ResponseProcessor: ) if finish_msg_obj: yield format_for_yield(finish_msg_obj) + # Save assistant_response_end BEFORE terminating + if last_assistant_message_object: + try: + # Calculate response time if we have timing data + if streaming_metadata["first_chunk_time"] and streaming_metadata["last_chunk_time"]: + streaming_metadata["response_ms"] = (streaming_metadata["last_chunk_time"] - streaming_metadata["first_chunk_time"]) * 1000 + + # Create a LiteLLM-like response object for streaming (before termination) + # Check if we have any actual usage data + has_usage_data = ( + streaming_metadata["usage"]["prompt_tokens"] > 0 or + streaming_metadata["usage"]["completion_tokens"] > 0 or + streaming_metadata["usage"]["total_tokens"] > 0 + ) + + assistant_end_content = { + "choices": [ + { + "finish_reason": finish_reason or "stop", + "index": 0, + "message": { + "role": "assistant", + "content": accumulated_content, + "tool_calls": complete_native_tool_calls or None + } + } + ], + "created": streaming_metadata.get("created"), + "model": streaming_metadata.get("model", llm_model), + "usage": streaming_metadata["usage"], # Always include usage like LiteLLM does + "streaming": True, # Add flag to indicate this was reconstructed from streaming + } + + # Only include response_ms if we have timing data + if streaming_metadata.get("response_ms"): + assistant_end_content["response_ms"] = streaming_metadata["response_ms"] + + await self.add_message( + thread_id=thread_id, + type="assistant_response_end", + content=assistant_end_content, + is_llm_message=False, + metadata={"thread_run_id": thread_run_id} + ) + logger.info("Assistant response end saved for stream (before termination)") + except Exception as e: + logger.error(f"Error saving assistant response end for stream (before termination): {str(e)}") + self.trace.event(name="error_saving_assistant_response_end_for_stream_before_termination", level="ERROR", status_message=(f"Error saving assistant response end for stream (before termination): {str(e)}")) + # Skip all remaining processing and go to finally block return + # --- Save and Yield assistant_response_end --- + if last_assistant_message_object: # Only save if assistant message was saved + try: + # Calculate response time if we have timing data + if streaming_metadata["first_chunk_time"] and streaming_metadata["last_chunk_time"]: + streaming_metadata["response_ms"] = (streaming_metadata["last_chunk_time"] - streaming_metadata["first_chunk_time"]) * 1000 + + # Create a LiteLLM-like response object for streaming + # Check if we have any actual usage data + has_usage_data = ( + streaming_metadata["usage"]["prompt_tokens"] > 0 or + streaming_metadata["usage"]["completion_tokens"] > 0 or + streaming_metadata["usage"]["total_tokens"] > 0 + ) + + assistant_end_content = { + "choices": [ + { + "finish_reason": finish_reason or "stop", + "index": 0, + "message": { + "role": "assistant", + "content": accumulated_content, + "tool_calls": complete_native_tool_calls or None + } + } + ], + "created": streaming_metadata.get("created"), + "model": streaming_metadata.get("model", llm_model), + "usage": streaming_metadata["usage"], # Always include usage like LiteLLM does + "streaming": True, # Add flag to indicate this was reconstructed from streaming + } + + # Only include response_ms if we have timing data + if streaming_metadata.get("response_ms"): + assistant_end_content["response_ms"] = streaming_metadata["response_ms"] + + await self.add_message( + thread_id=thread_id, + type="assistant_response_end", + content=assistant_end_content, + is_llm_message=False, + metadata={"thread_run_id": thread_run_id} + ) + logger.info("Assistant response end saved for stream") + except Exception as e: + logger.error(f"Error saving assistant response end for stream: {str(e)}") + self.trace.event(name="error_saving_assistant_response_end_for_stream", level="ERROR", status_message=(f"Error saving assistant response end for stream: {str(e)}")) + except Exception as e: logger.error(f"Error processing stream: {str(e)}", exc_info=True) self.trace.event(name="error_processing_stream", level="ERROR", status_message=(f"Error processing stream: {str(e)}")) @@ -759,43 +895,7 @@ class ResponseProcessor: ) if err_msg_obj: yield format_for_yield(err_msg_obj) - # --- Calculate and Store Cost --- - if assistant_message_object: # Only calculate if assistant message was saved - try: - # Use the full llm_response object for potentially more accurate cost calculation - final_cost = None - if hasattr(llm_response, '_hidden_params') and 'response_cost' in llm_response._hidden_params and llm_response._hidden_params['response_cost'] is not None and llm_response._hidden_params['response_cost'] != 0.0: - final_cost = llm_response._hidden_params['response_cost'] - logger.info(f"Using response_cost from _hidden_params: {final_cost}") - - if final_cost is None: # Fall back to calculating cost if direct cost not available or zero - logger.info("Calculating cost using completion_cost function.") - # Note: litellm might need 'messages' kwarg depending on model/provider - final_cost = completion_cost( - completion_response=llm_response, - model=llm_model, # Explicitly pass the model name - # messages=prompt_messages # Pass prompt messages if needed by litellm for this model - ) - - if final_cost is not None and final_cost > 0: - logger.info(f"Calculated final cost for non-stream: {final_cost}") - await self.add_message( - thread_id=thread_id, - type="cost", - content={"cost": final_cost}, - is_llm_message=False, # Cost is metadata - metadata={"thread_run_id": thread_run_id} # Keep track of the run - ) - logger.info(f"Cost message saved for non-stream: {final_cost}") - self.trace.update(metadata={"cost": final_cost}) - else: - logger.info("Non-stream cost calculation resulted in zero or None, not storing cost message.") - self.trace.update(metadata={"cost": 0}) - - except Exception as e: - logger.error(f"Error calculating final cost for non-stream: {str(e)}") - self.trace.event(name="error_calculating_final_cost_for_non_stream", level="ERROR", status_message=(f"Error calculating final cost for non-stream: {str(e)}")) - # --- Execute Tools and Yield Results --- + # --- Execute Tools and Yield Results --- tool_calls_to_execute = [item['tool_call'] for item in all_tool_data] if config.execute_tools and tool_calls_to_execute: logger.info(f"Executing {len(tool_calls_to_execute)} tools with strategy: {config.tool_execution_strategy}") @@ -850,6 +950,22 @@ class ResponseProcessor: ) if finish_msg_obj: yield format_for_yield(finish_msg_obj) + # --- Save and Yield assistant_response_end --- + if assistant_message_object: # Only save if assistant message was saved + try: + # Save the full LiteLLM response object directly in content + await self.add_message( + thread_id=thread_id, + type="assistant_response_end", + content=llm_response, + is_llm_message=False, + metadata={"thread_run_id": thread_run_id} + ) + logger.info("Assistant response end saved for non-stream") + except Exception as e: + logger.error(f"Error saving assistant response end for non-stream: {str(e)}") + self.trace.event(name="error_saving_assistant_response_end_for_non_stream", level="ERROR", status_message=(f"Error saving assistant response end for non-stream: {str(e)}")) + except Exception as e: logger.error(f"Error processing non-streaming response: {str(e)}", exc_info=True) self.trace.event(name="error_processing_non_streaming_response", level="ERROR", status_message=(f"Error processing non-streaming response: {str(e)}")) @@ -1162,7 +1278,7 @@ class ResponseProcessor: "arguments": params # The extracted parameters } - logger.debug(f"Parsed old format tool call: {tool_call}") + # logger.debug(f"Parsed old format tool call: {tool_call["function_name"]}") return tool_call, parsing_details # Return both dicts except Exception as e: @@ -1619,7 +1735,7 @@ class ResponseProcessor: # return summary summary_output = result.output if hasattr(result, 'output') else str(result) - success_status = structured_result["tool_execution"]["result"]["success"] + success_status = structured_result_v1["tool_execution"]["result"]["success"] # Create a more comprehensive summary for the LLM if xml_tag_name: @@ -1636,7 +1752,7 @@ class ResponseProcessor: return summary else: - return json.dumps(structured_result_v1) + return structured_result_v1 def _format_xml_tool_result(self, tool_call: Dict[str, Any], result: ToolResult) -> str: """Format a tool result wrapped in a tag. @@ -1744,4 +1860,4 @@ class ResponseProcessor: saved_message_obj = await self.add_message( thread_id=thread_id, type="status", content=content, is_llm_message=False, metadata=metadata ) - return saved_message_obj \ No newline at end of file + return saved_message_obj diff --git a/backend/agentpress/thread_manager.py b/backend/agentpress/thread_manager.py index 5ab6766f..1f75c4aa 100644 --- a/backend/agentpress/thread_manager.py +++ b/backend/agentpress/thread_manager.py @@ -61,6 +61,20 @@ class ThreadManager: ) self.context_manager = ContextManager() + def _is_tool_result_message(self, msg: Dict[str, Any]) -> bool: + if not ("content" in msg and msg['content']): + return False + content = msg['content'] + if isinstance(content, str) and "ToolResult" in content: return True + if isinstance(content, dict) and "tool_execution" in content: return True + if isinstance(content, str): + try: + parsed_content = json.loads(content) + if isinstance(parsed_content, dict) and "tool_execution" in parsed_content: return True + except (json.JSONDecodeError, TypeError): + pass + return False + def add_tool(self, tool_class: Type[Tool], function_names: Optional[List[str]] = None, **kwargs): """Add a tool to the ThreadManager.""" self.tool_registry.register_tool(tool_class, function_names, **kwargs) @@ -336,7 +350,7 @@ Here are the XML tools available with examples: if uncompressed_total_token_count > (llm_max_tokens or (100 * 1000)): _i = 0 # Count the number of ToolResult messages for msg in reversed(prepared_messages): # Start from the end and work backwards - if "content" in msg and msg['content'] and "ToolResult" in msg['content']: # Only compress ToolResult messages + if self._is_tool_result_message(msg): # Only compress ToolResult messages _i += 1 # Count the number of ToolResult messages msg_token_count = token_counter(messages=[msg]) # Count the number of tokens in the message if msg_token_count > 5000: # If the message is too long @@ -412,6 +426,7 @@ Here are the XML tools available with examples: logger.error(f"Error in run_thread: {str(e)}", exc_info=True) # Return the error as a dict to be handled by the caller return { + "type": "status", "status": "error", "message": str(e) } diff --git a/backend/api.py b/backend/api.py index 5617530c..65fcf5c7 100644 --- a/backend/api.py +++ b/backend/api.py @@ -19,9 +19,12 @@ from pydantic import BaseModel from agent import api as agent_api from sandbox import api as sandbox_api from services import billing as billing_api +from flags import api as feature_flags_api from services import transcription as transcription_api from services.mcp_custom import discover_custom_tools import sys +from services import email_api + load_dotenv() @@ -130,12 +133,16 @@ app.include_router(sandbox_api.router, prefix="/api") app.include_router(billing_api.router, prefix="/api") +app.include_router(feature_flags_api.router, prefix="/api") + from mcp_local import api as mcp_api app.include_router(mcp_api.router, prefix="/api") app.include_router(transcription_api.router, prefix="/api") +app.include_router(email_api.router, prefix="/api") + @app.get("/api/health") async def health_check(): """Health check endpoint to verify API is working.""" diff --git a/backend/docker-compose.prod.yml b/backend/docker-compose.prod.yml index 7c3d63a7..add788ee 100644 --- a/backend/docker-compose.prod.yml +++ b/backend/docker-compose.prod.yml @@ -10,7 +10,7 @@ services: memory: 32G worker: - command: python -m dramatiq --processes 20 --threads 16 run_agent_background + command: python -m dramatiq --processes 10 --threads 32 run_agent_background deploy: resources: limits: diff --git a/backend/flags/__init__.py b/backend/flags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/flags/api.py b/backend/flags/api.py new file mode 100644 index 00000000..448139dd --- /dev/null +++ b/backend/flags/api.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter +from utils.logger import logger +from .flags import list_flags, is_enabled, get_flag_details + +router = APIRouter() + + +@router.get("/feature-flags") +async def get_feature_flags(): + try: + flags = await list_flags() + return {"flags": flags} + except Exception as e: + logger.error(f"Error fetching feature flags: {str(e)}") + return {"flags": {}} + +@router.get("/feature-flags/{flag_name}") +async def get_feature_flag(flag_name: str): + try: + enabled = await is_enabled(flag_name) + details = await get_flag_details(flag_name) + return { + "flag_name": flag_name, + "enabled": enabled, + "details": details + } + except Exception as e: + logger.error(f"Error fetching feature flag {flag_name}: {str(e)}") + return { + "flag_name": flag_name, + "enabled": False, + "details": None + } \ No newline at end of file diff --git a/backend/flags/flags.py b/backend/flags/flags.py new file mode 100644 index 00000000..7277aaf1 --- /dev/null +++ b/backend/flags/flags.py @@ -0,0 +1,151 @@ +import json +import logging +import os +from datetime import datetime +from typing import Dict, List, Optional +from services import redis + +logger = logging.getLogger(__name__) + + +class FeatureFlagManager: + def __init__(self): + """Initialize with existing Redis service""" + self.flag_prefix = "feature_flag:" + self.flag_list_key = "feature_flags:list" + + async def set_flag(self, key: str, enabled: bool, description: str = "") -> bool: + """Set a feature flag to enabled or disabled""" + try: + flag_key = f"{self.flag_prefix}{key}" + flag_data = { + 'enabled': str(enabled).lower(), + 'description': description, + 'updated_at': datetime.utcnow().isoformat() + } + + # Use the existing Redis service + redis_client = await redis.get_client() + await redis_client.hset(flag_key, mapping=flag_data) + await redis_client.sadd(self.flag_list_key, key) + + logger.info(f"Set feature flag {key} to {enabled}") + return True + except Exception as e: + logger.error(f"Failed to set feature flag {key}: {e}") + return False + + async def is_enabled(self, key: str) -> bool: + """Check if a feature flag is enabled""" + try: + flag_key = f"{self.flag_prefix}{key}" + redis_client = await redis.get_client() + enabled = await redis_client.hget(flag_key, 'enabled') + return enabled == 'true' if enabled else False + except Exception as e: + logger.error(f"Failed to check feature flag {key}: {e}") + # Return False by default if Redis is unavailable + return False + + async def get_flag(self, key: str) -> Optional[Dict[str, str]]: + """Get feature flag details""" + try: + flag_key = f"{self.flag_prefix}{key}" + redis_client = await redis.get_client() + flag_data = await redis_client.hgetall(flag_key) + return flag_data if flag_data else None + except Exception as e: + logger.error(f"Failed to get feature flag {key}: {e}") + return None + + async def delete_flag(self, key: str) -> bool: + """Delete a feature flag""" + try: + flag_key = f"{self.flag_prefix}{key}" + redis_client = await redis.get_client() + deleted = await redis_client.delete(flag_key) + if deleted: + await redis_client.srem(self.flag_list_key, key) + logger.info(f"Deleted feature flag: {key}") + return True + return False + except Exception as e: + logger.error(f"Failed to delete feature flag {key}: {e}") + return False + + async def list_flags(self) -> Dict[str, bool]: + """List all feature flags with their status""" + try: + redis_client = await redis.get_client() + flag_keys = await redis_client.smembers(self.flag_list_key) + flags = {} + + for key in flag_keys: + flags[key] = await self.is_enabled(key) + + return flags + except Exception as e: + logger.error(f"Failed to list feature flags: {e}") + return {} + + async def get_all_flags_details(self) -> Dict[str, Dict[str, str]]: + """Get all feature flags with detailed information""" + try: + redis_client = await redis.get_client() + flag_keys = await redis_client.smembers(self.flag_list_key) + flags = {} + + for key in flag_keys: + flag_data = await self.get_flag(key) + if flag_data: + flags[key] = flag_data + + return flags + except Exception as e: + logger.error(f"Failed to get all flags details: {e}") + return {} + + +_flag_manager: Optional[FeatureFlagManager] = None + + +def get_flag_manager() -> FeatureFlagManager: + """Get the global feature flag manager instance""" + global _flag_manager + if _flag_manager is None: + _flag_manager = FeatureFlagManager() + return _flag_manager + + +# Async convenience functions +async def set_flag(key: str, enabled: bool, description: str = "") -> bool: + return await get_flag_manager().set_flag(key, enabled, description) + + +async def is_enabled(key: str) -> bool: + return await get_flag_manager().is_enabled(key) + + +async def enable_flag(key: str, description: str = "") -> bool: + return await set_flag(key, True, description) + + +async def disable_flag(key: str, description: str = "") -> bool: + return await set_flag(key, False, description) + + +async def delete_flag(key: str) -> bool: + return await get_flag_manager().delete_flag(key) + + +async def list_flags() -> Dict[str, bool]: + return await get_flag_manager().list_flags() + + +async def get_flag_details(key: str) -> Optional[Dict[str, str]]: + return await get_flag_manager().get_flag(key) + + +async def get_all_flags() -> Dict[str, Dict[str, str]]: + """Get all feature flags with detailed information""" + return await get_flag_manager().get_all_flags_details() diff --git a/backend/flags/setup.py b/backend/flags/setup.py new file mode 100644 index 00000000..57ea2dd2 --- /dev/null +++ b/backend/flags/setup.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +import sys +import argparse +import asyncio +from flags import enable_flag, disable_flag, is_enabled, list_flags, delete_flag, get_flag_details + +async def enable_command(flag_name: str, description: str = ""): + """Enable a feature flag""" + if await enable_flag(flag_name, description): + print(f"✓ Enabled flag: {flag_name}") + if description: + print(f" Description: {description}") + else: + print(f"✗ Failed to enable flag: {flag_name}") + + +async def disable_command(flag_name: str, description: str = ""): + """Disable a feature flag""" + if await disable_flag(flag_name, description): + print(f"✓ Disabled flag: {flag_name}") + if description: + print(f" Description: {description}") + else: + print(f"✗ Failed to disable flag: {flag_name}") + + +async def list_command(): + """List all feature flags""" + flags = await list_flags() + + if not flags: + print("No feature flags found.") + return + + print("Feature Flags:") + print("-" * 50) + + for flag_name, enabled in flags.items(): + details = await get_flag_details(flag_name) + description = details.get('description', 'No description') if details else 'No description' + updated_at = details.get('updated_at', 'Unknown') if details else 'Unknown' + + status_icon = "✓" if enabled else "✗" + status_text = "ENABLED" if enabled else "DISABLED" + + print(f"{status_icon} {flag_name}: {status_text}") + print(f" Description: {description}") + print(f" Updated: {updated_at}") + print() + + +async def status_command(flag_name: str): + """Show status of a specific feature flag""" + details = await get_flag_details(flag_name) + + if not details: + print(f"✗ Flag '{flag_name}' not found.") + return + + enabled = await is_enabled(flag_name) + status_icon = "✓" if enabled else "✗" + status_text = "ENABLED" if enabled else "DISABLED" + + print(f"Flag: {flag_name}") + print(f"Status: {status_icon} {status_text}") + print(f"Description: {details.get('description', 'No description')}") + print(f"Updated: {details.get('updated_at', 'Unknown')}") + + +async def delete_command(flag_name: str): + """Delete a feature flag""" + if not await get_flag_details(flag_name): + print(f"✗ Flag '{flag_name}' not found.") + return + + confirm = input(f"Are you sure you want to delete flag '{flag_name}'? (y/N): ") + if confirm.lower() in ['y', 'yes']: + if await delete_flag(flag_name): + print(f"✓ Deleted flag: {flag_name}") + else: + print(f"✗ Failed to delete flag: {flag_name}") + else: + print("Cancelled.") + + +async def toggle_command(flag_name: str, description: str = ""): + """Toggle a feature flag""" + current_status = await is_enabled(flag_name) + + if current_status: + await disable_command(flag_name, description) + else: + await enable_command(flag_name, description) + + +async def main(): + parser = argparse.ArgumentParser( + description="Feature Flag Management Tool", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python setup.py enable new_ui "Enable new user interface" + python setup.py disable beta_features "Disable beta features" + python setup.py list + python setup.py status new_ui + python setup.py toggle maintenance_mode "Toggle maintenance mode" + python setup.py delete old_feature + """ + ) + + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # Enable command + enable_parser = subparsers.add_parser('enable', help='Enable a feature flag') + enable_parser.add_argument('flag_name', help='Name of the feature flag') + enable_parser.add_argument('description', nargs='?', default='', help='Optional description') + + # Disable command + disable_parser = subparsers.add_parser('disable', help='Disable a feature flag') + disable_parser.add_argument('flag_name', help='Name of the feature flag') + disable_parser.add_argument('description', nargs='?', default='', help='Optional description') + + # List command + subparsers.add_parser('list', help='List all feature flags') + + # Status command + status_parser = subparsers.add_parser('status', help='Show status of a feature flag') + status_parser.add_argument('flag_name', help='Name of the feature flag') + + # Delete command + delete_parser = subparsers.add_parser('delete', help='Delete a feature flag') + delete_parser.add_argument('flag_name', help='Name of the feature flag') + + # Toggle command + toggle_parser = subparsers.add_parser('toggle', help='Toggle a feature flag') + toggle_parser.add_argument('flag_name', help='Name of the feature flag') + toggle_parser.add_argument('description', nargs='?', default='', help='Optional description') + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + try: + if args.command == 'enable': + await enable_command(args.flag_name, args.description) + elif args.command == 'disable': + await disable_command(args.flag_name, args.description) + elif args.command == 'list': + await list_command() + elif args.command == 'status': + await status_command(args.flag_name) + elif args.command == 'delete': + await delete_command(args.flag_name) + elif args.command == 'toggle': + await toggle_command(args.flag_name, args.description) + except KeyboardInterrupt: + print("\nOperation cancelled.") + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/backend/mcp_local/api.py b/backend/mcp_local/api.py index 60312ffa..b6deaaab 100644 --- a/backend/mcp_local/api.py +++ b/backend/mcp_local/api.py @@ -12,14 +12,6 @@ The flow: 2. Configure MCP servers with credentials and save to agent's configured_mcps 3. When agent runs, it connects to MCP servers using: https://server.smithery.ai/{qualifiedName}/mcp?config={base64_encoded_config}&api_key={smithery_api_key} - -Example MCP configuration stored in agent's configured_mcps: -{ - "name": "Exa Search", - "qualifiedName": "exa", - "config": {"exaApiKey": "user's-exa-api-key"}, - "enabledTools": ["search", "find_similar"] -} """ from fastapi import APIRouter, HTTPException, Depends, Query diff --git a/backend/poetry.lock b/backend/poetry.lock index dbc6585c..c459e04d 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -543,6 +543,27 @@ files = [ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, ] +[[package]] +name = "dnspython" +version = "2.7.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, + {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, +] + +[package.extras] +dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] +dnssec = ["cryptography (>=43)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] +doq = ["aioquic (>=1.0.0)"] +idna = ["idna (>=3.7)"] +trio = ["trio (>=0.23)"] +wmi = ["wmi (>=1.5.1)"] + [[package]] name = "dramatiq" version = "1.17.1" @@ -605,6 +626,22 @@ attrs = ">=21.3.0" e2b = ">=1.3.1,<2.0.0" httpx = ">=0.20.0,<1.0.0" +[[package]] +name = "email-validator" +version = "2.2.0" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"}, + {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + [[package]] name = "entrypoints" version = "0.4" @@ -1297,6 +1334,21 @@ tokenizers = "*" extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0,<0.9.0)"] proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "backoff", "boto3 (==1.34.34)", "cryptography (>=43.0.1,<44.0.0)", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-proxy-extras (==0.1.7)", "mcp (==1.5.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0)", "websockets (>=13.1.0,<14.0.0)"] +[[package]] +name = "mailtrap" +version = "2.1.0" +description = "Official mailtrap.io API client" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "mailtrap-2.1.0-py3-none-any.whl", hash = "sha256:6cef8dc02734e3e3a16161e38d184ea6971e925673c731c8ac968b88556f069e"}, + {file = "mailtrap-2.1.0.tar.gz", hash = "sha256:22fccf3cd912a7e47d4a1bb86865cf0f0587d59dc73bc78d9e77d596767f5b85"}, +] + +[package.dependencies] +requests = ">=2.26.0" + [[package]] name = "markupsafe" version = "3.0.2" @@ -3719,4 +3771,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "ed0ccb92ccc81ecff536968c883a2ea96d2ee8a6c06a18d0023b0cd4185f8a28" +content-hash = "70ef4a9be6ddd82debb9e9e377d9f47f6f6917b43b75a688e404adeef9e61018" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a62ed1ef..5afdabde 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -56,6 +56,10 @@ langfuse = "^2.60.5" Pillow = "^10.0.0" mcp = "^1.0.0" sentry-sdk = {extras = ["fastapi"], version = "^2.29.1"} +httpx = "^0.28.0" +aiohttp = "^3.9.0" +email-validator = "^2.0.0" +mailtrap = "^2.0.1" [tool.poetry.scripts] agentpress = "agentpress.cli:main" diff --git a/backend/requirements.txt b/backend/requirements.txt index ac805f9b..cc58b88b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -38,4 +38,6 @@ Pillow>=10.0.0 sentry-sdk[fastapi]>=2.29.1 mcp>=1.0.0 mcp_use>=1.0.0 -aiohttp>=3.9.0 \ No newline at end of file +aiohttp>=3.9.0 +email-validator>=2.0.0 +mailtrap>=2.0.1 diff --git a/backend/run_agent_background.py b/backend/run_agent_background.py index 275928a1..7269450b 100644 --- a/backend/run_agent_background.py +++ b/backend/run_agent_background.py @@ -132,6 +132,8 @@ async def run_agent_background( final_status = "running" error_message = None + pending_redis_operations = [] + async for response in agent_gen: if stop_signal_received: logger.info(f"Agent run {agent_run_id} stopped by signal.") @@ -141,8 +143,8 @@ async def run_agent_background( # Store response in Redis list and publish notification response_json = json.dumps(response) - asyncio.create_task(redis.rpush(response_list_key, response_json)) - asyncio.create_task(redis.publish(response_channel, "new")) + pending_redis_operations.append(asyncio.create_task(redis.rpush(response_list_key, response_json))) + pending_redis_operations.append(asyncio.create_task(redis.publish(response_channel, "new"))) total_responses += 1 # Check for agent-signaled completion or error @@ -239,6 +241,12 @@ async def run_agent_background( # Remove the instance-specific active run key await _cleanup_redis_instance_key(agent_run_id) + # Wait for all pending redis operations to complete, with timeout + try: + await asyncio.wait_for(asyncio.gather(*pending_redis_operations), timeout=30.0) + except asyncio.TimeoutError: + logger.warning(f"Timeout waiting for pending Redis operations for {agent_run_id}") + logger.info(f"Agent run background task fully completed for: {agent_run_id} (Instance: {instance_id}) with final status: {final_status}") async def _cleanup_redis_instance_key(agent_run_id: str): diff --git a/backend/sandbox/README.md b/backend/sandbox/README.md index 0be85940..565a770f 100644 --- a/backend/sandbox/README.md +++ b/backend/sandbox/README.md @@ -20,7 +20,7 @@ You can modify the sandbox environment for development or to add new capabilitie ``` cd backend/sandbox/docker docker compose build - docker push kortix/suna:0.1.2 + docker push kortix/suna:0.1.3 ``` 3. Test your changes locally using docker-compose diff --git a/backend/sandbox/docker/Dockerfile b/backend/sandbox/docker/Dockerfile index 5608e335..d2f12ff1 100644 --- a/backend/sandbox/docker/Dockerfile +++ b/backend/sandbox/docker/Dockerfile @@ -96,11 +96,6 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Copy server script -COPY . /app -COPY server.py /app/server.py -COPY browser_api.py /app/browser_api.py - # Install Playwright and browsers with system dependencies ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright # Install Playwright package first @@ -111,6 +106,11 @@ RUN playwright install chromium # Verify installation RUN python -c "from playwright.sync_api import sync_playwright; print('Playwright installation verified')" +# Copy server script +COPY . /app +COPY server.py /app/server.py +COPY browser_api.py /app/browser_api.py + # Set environment variables ENV PYTHONUNBUFFERED=1 ENV CHROME_PATH=/ms-playwright/chromium-*/chrome-linux/chrome diff --git a/backend/sandbox/docker/browser_api.py b/backend/sandbox/docker/browser_api.py index 7fb5baf5..7456b848 100644 --- a/backend/sandbox/docker/browser_api.py +++ b/backend/sandbox/docker/browser_api.py @@ -1,5 +1,5 @@ from fastapi import FastAPI, APIRouter, HTTPException, Body -from playwright.async_api import async_playwright, Browser, Page +from playwright.async_api import async_playwright, Browser, BrowserContext, Page from pydantic import BaseModel from typing import Optional, List, Dict, Any import asyncio @@ -282,6 +282,7 @@ class BrowserAutomation: def __init__(self): self.router = APIRouter() self.browser: Browser = None + self.browser_context: BrowserContext = None self.pages: List[Page] = [] self.current_page_index: int = 0 self.logger = logging.getLogger("browser_automation") @@ -341,6 +342,7 @@ class BrowserAutomation: try: self.browser = await playwright.chromium.launch(**launch_options) + self.browser_context = await self.browser.new_context(viewport={'width': 1024, 'height': 768}) print("Browser launched successfully") except Exception as browser_error: print(f"Failed to launch browser: {browser_error}") @@ -348,6 +350,7 @@ class BrowserAutomation: print("Retrying with minimal options...") launch_options = {"timeout": 90000} self.browser = await playwright.chromium.launch(**launch_options) + self.browser_context = await self.browser.new_context(viewport={'width': 1024, 'height': 768}) print("Browser launched with minimal options") try: @@ -356,13 +359,20 @@ class BrowserAutomation: self.current_page_index = 0 except Exception as page_error: print(f"Error finding existing page, creating new one. ( {page_error})") - page = await self.browser.new_page(viewport={'width': 1024, 'height': 768}) + page = await self.browser_context.new_page() print("New page created successfully") self.pages.append(page) self.current_page_index = 0 # Navigate directly to google.com instead of about:blank await page.goto("https://www.google.com", wait_until="domcontentloaded", timeout=30000) print("Navigated to google.com") + + try: + self.browser_context.on("page", self.handle_page_created) + except Exception as e: + print(f"Error setting up page event handler: {e}") + traceback.print_exc() + print("Browser initialization completed successfully") except Exception as e: @@ -372,8 +382,17 @@ class BrowserAutomation: async def shutdown(self): """Clean up browser instance on shutdown""" + if self.browser_context: + await self.browser_context.close() if self.browser: await self.browser.close() + + async def handle_page_created(self, page: Page): + """Handle new page creation""" + await asyncio.sleep(0.5) + self.pages.append(page) + self.current_page_index = len(self.pages) - 1 + print(f"Page created: {page.url}; current page index: {self.current_page_index}") async def get_current_page(self) -> Page: """Get the current active page""" @@ -958,6 +977,7 @@ class BrowserAutomation: # Give time for any navigation or DOM updates to occur await page.wait_for_load_state("networkidle", timeout=5000) + await asyncio.sleep(1) # Get updated state after action dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"click_coordinates({action.x}, {action.y})") @@ -977,6 +997,7 @@ class BrowserAutomation: # Try to get state even after error try: + await asyncio.sleep(1) dom_state, screenshot, elements, metadata = await self.get_updated_browser_state("click_coordinates_error_recovery") return self.build_action_result( False, @@ -1076,7 +1097,7 @@ class BrowserAutomation: await page.wait_for_load_state("networkidle", timeout=5000) except Exception as wait_error: print(f"Timeout or error waiting for network idle after click: {wait_error}") - await asyncio.sleep(1) # Fallback wait + await asyncio.sleep(1) # Get updated state after action dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"click_element({action.index})") @@ -1161,6 +1182,7 @@ class BrowserAutomation: # Fallback to xpath await page.fill(f"//{element.tag_name}[{action.index}]", action.text) + await asyncio.sleep(1) # Get updated state after action dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"input_text({action.index}, '{action.text}')") @@ -1192,6 +1214,7 @@ class BrowserAutomation: page = await self.get_current_page() await page.keyboard.press(action.keys) + await asyncio.sleep(1) # Get updated state after action dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"send_keys({action.keys})") @@ -1267,7 +1290,7 @@ class BrowserAutomation: try: print(f"Attempting to open new tab with URL: {action.url}") # Create new page in same browser instance - new_page = await self.browser.new_page() + new_page = await self.browser_context.new_page() print(f"New page created successfully") # Navigate to the URL diff --git a/backend/sandbox/docker/docker-compose.yml b/backend/sandbox/docker/docker-compose.yml index 08f0969d..7b6646ab 100644 --- a/backend/sandbox/docker/docker-compose.yml +++ b/backend/sandbox/docker/docker-compose.yml @@ -6,12 +6,12 @@ services: dockerfile: ${DOCKERFILE:-Dockerfile} args: TARGETPLATFORM: ${TARGETPLATFORM:-linux/amd64} - image: kortix/suna:0.1.2.8 + image: kortix/suna:0.1.3 ports: - "6080:6080" # noVNC web interface - "5901:5901" # VNC port - "9222:9222" # Chrome remote debugging port - - "8000:8000" # API server port + - "8003:8003" # API server port - "8080:8080" # HTTP server port environment: - ANONYMIZED_TELEMETRY=${ANONYMIZED_TELEMETRY:-false} diff --git a/backend/services/email.py b/backend/services/email.py new file mode 100644 index 00000000..9e9230df --- /dev/null +++ b/backend/services/email.py @@ -0,0 +1,188 @@ +import os +import logging +from typing import Optional +import mailtrap as mt +from utils.config import config + +logger = logging.getLogger(__name__) + +class EmailService: + def __init__(self): + self.api_token = os.getenv('MAILTRAP_API_TOKEN') + self.sender_email = os.getenv('MAILTRAP_SENDER_EMAIL', 'dom@kortix.ai') + self.sender_name = os.getenv('MAILTRAP_SENDER_NAME', 'Suna Team') + + if not self.api_token: + logger.warning("MAILTRAP_API_TOKEN not found in environment variables") + self.client = None + else: + self.client = mt.MailtrapClient(token=self.api_token) + + def send_welcome_email(self, user_email: str, user_name: Optional[str] = None) -> bool: + if not self.client: + logger.error("Cannot send email: MAILTRAP_API_TOKEN not configured") + return False + + if not user_name: + user_name = user_email.split('@')[0].title() + + subject = "🎉 Welcome to Suna — Let's Get Started " + html_content = self._get_welcome_email_template(user_name) + text_content = self._get_welcome_email_text(user_name) + + return self._send_email( + to_email=user_email, + to_name=user_name, + subject=subject, + html_content=html_content, + text_content=text_content + ) + + def _send_email( + self, + to_email: str, + to_name: str, + subject: str, + html_content: str, + text_content: str + ) -> bool: + try: + mail = mt.Mail( + sender=mt.Address(email=self.sender_email, name=self.sender_name), + to=[mt.Address(email=to_email, name=to_name)], + subject=subject, + text=text_content, + html=html_content, + category="welcome" + ) + + response = self.client.send(mail) + + logger.info(f"Welcome email sent to {to_email}. Response: {response}") + return True + + except Exception as e: + logger.error(f"Error sending email to {to_email}: {str(e)}") + return False + + def _get_welcome_email_template(self, user_name: str) -> str: + return f""" + + + + + Welcome to Kortix Suna + + + +
+
+ +
+

Welcome to Kortix Suna!

+ +

Hi {user_name},

+ +

Welcome to Kortix Suna — we're excited to have you on board!

+ +

To get started, we'd like to get to know you better: fill out this short form!

+ +

To celebrate your arrival, here's a 15% discount to try out the best version of Suna (1 month):

+ +

🎁 Use code WELCOME15 at checkout.

+ +

Let us know if you need help getting started or have questions — we're always here, and join our Discord community.

+ +

Thanks again, and welcome to the Suna community 🌞

+ +

— The Suna Team

+ + Go to the platform +
+ +""" + + def _get_welcome_email_text(self, user_name: str) -> str: + return f"""Hi {user_name}, + +Welcome to Suna — we're excited to have you on board! + +To get started, we'd like to get to know you better: fill out this short form! +https://docs.google.com/forms/d/e/1FAIpQLSef1EHuqmIh_iQz-kwhjnzSC3Ml-V_5wIySDpMoMU9W_j24JQ/viewform + +To celebrate your arrival, here's a 15% discount to try out the best version of Suna (1 month): +🎁 Use code WELCOME15 at checkout. + +Let us know if you need help getting started or have questions — we're always here, and join our Discord community: https://discord.com/invite/FjD644cfcs + +Thanks again, and welcome to the Suna community 🌞 + +— The Suna Team + +Go to the platform: https://www.suna.so/ + +--- +© 2024 Suna. All rights reserved. +You received this email because you signed up for a Suna account.""" + +email_service = EmailService() diff --git a/backend/services/email_api.py b/backend/services/email_api.py new file mode 100644 index 00000000..9834c7ba --- /dev/null +++ b/backend/services/email_api.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel, EmailStr +from typing import Optional +import asyncio +from services.email import email_service +from utils.logger import logger + +router = APIRouter() + +class SendWelcomeEmailRequest(BaseModel): + email: EmailStr + name: Optional[str] = None + +class EmailResponse(BaseModel): + success: bool + message: str + +@router.post("/send-welcome-email", response_model=EmailResponse) +async def send_welcome_email(request: SendWelcomeEmailRequest): + try: + logger.info(f"Sending welcome email to {request.email}") + success = email_service.send_welcome_email( + user_email=request.email, + user_name=request.name + ) + + if success: + return EmailResponse( + success=True, + message="Welcome email sent successfully" + ) + else: + return EmailResponse( + success=False, + message="Failed to send welcome email" + ) + + except Exception as e: + logger.error(f"Error sending welcome email to {request.email}: {str(e)}") + raise HTTPException( + status_code=500, + detail="Internal server error while sending email" + ) + +@router.post("/send-welcome-email-background", response_model=EmailResponse) +async def send_welcome_email_background(request: SendWelcomeEmailRequest): + try: + logger.info(f"Queuing welcome email for {request.email}") + + def send_email(): + return email_service.send_welcome_email( + user_email=request.email, + user_name=request.name + ) + + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(send_email) + + return EmailResponse( + success=True, + message="Welcome email queued for sending" + ) + + except Exception as e: + logger.error(f"Error queuing welcome email for {request.email}: {str(e)}") + raise HTTPException( + status_code=500, + detail="Internal server error while queuing email" + ) diff --git a/backend/utils/config.py b/backend/utils/config.py index 119a852f..f22e1898 100644 --- a/backend/utils/config.py +++ b/backend/utils/config.py @@ -159,7 +159,7 @@ class Configuration: STRIPE_PRODUCT_ID_STAGING: str = 'prod_SCgIj3G7yPOAWY' # Sandbox configuration - SANDBOX_IMAGE_NAME = "kortix/suna:0.1.2.8" + SANDBOX_IMAGE_NAME = "kortix/suna:0.1.3" SANDBOX_ENTRYPOINT = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf" # LangFuse configuration diff --git a/docs/SELF-HOSTING.md b/docs/SELF-HOSTING.md index 32f09f52..00942744 100644 --- a/docs/SELF-HOSTING.md +++ b/docs/SELF-HOSTING.md @@ -115,7 +115,7 @@ As part of the setup, you'll need to: 1. Create a Daytona account 2. Generate an API key 3. Create a Docker image: - - Image name: `kortix/suna:0.1.2.8` + - Image name: `kortix/suna:0.1.3` - Entrypoint: `/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf` ## Manual Configuration diff --git a/frontend/.env.example b/frontend/.env.example index 6500b295..ea1747a6 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -4,4 +4,4 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY="" NEXT_PUBLIC_BACKEND_URL="" NEXT_PUBLIC_URL="" NEXT_PUBLIC_GOOGLE_CLIENT_ID="" -OPENAI_API_KEY="" \ No newline at end of file +OPENAI_API_KEY="" diff --git a/frontend/src/app/(dashboard)/agents/layout.tsx b/frontend/src/app/(dashboard)/agents/layout.tsx index b13139a9..d0771499 100644 --- a/frontend/src/app/(dashboard)/agents/layout.tsx +++ b/frontend/src/app/(dashboard)/agents/layout.tsx @@ -1,4 +1,7 @@ +import { agentPlaygroundFlagFrontend } from '@/flags'; +import { isFlagEnabled } from '@/lib/feature-flags'; import { Metadata } from 'next'; +import { redirect } from 'next/navigation'; export const metadata: Metadata = { title: 'Agent Conversation | Kortix Suna', @@ -10,10 +13,14 @@ export const metadata: Metadata = { }, }; -export default function AgentsLayout({ +export default async function AgentsLayout({ children, }: { children: React.ReactNode; }) { + const agentPlaygroundEnabled = await isFlagEnabled('custom_agents'); + if (!agentPlaygroundEnabled) { + redirect('/dashboard'); + } return <>{children}; } diff --git a/frontend/src/app/(dashboard)/agents/new/[agentId]/layout.tsx b/frontend/src/app/(dashboard)/agents/new/[agentId]/layout.tsx index 5c2902d5..72782f20 100644 --- a/frontend/src/app/(dashboard)/agents/new/[agentId]/layout.tsx +++ b/frontend/src/app/(dashboard)/agents/new/[agentId]/layout.tsx @@ -1,4 +1,6 @@ import { Metadata } from 'next'; +import { redirect } from 'next/navigation'; +import { isFlagEnabled } from '@/lib/feature-flags'; export const metadata: Metadata = { title: 'Create Agent | Kortix Suna', @@ -10,10 +12,14 @@ export const metadata: Metadata = { }, }; -export default function NewAgentLayout({ +export default async function NewAgentLayout({ children, }: { children: React.ReactNode; }) { + const agentPlaygroundEnabled = await isFlagEnabled('custom_agents'); + if (!agentPlaygroundEnabled) { + redirect('/dashboard'); + } return <>{children}; } diff --git a/frontend/src/app/(dashboard)/agents/page.tsx b/frontend/src/app/(dashboard)/agents/page.tsx index db49b818..7d96c6d4 100644 --- a/frontend/src/app/(dashboard)/agents/page.tsx +++ b/frontend/src/app/(dashboard)/agents/page.tsx @@ -16,6 +16,7 @@ import { Pagination } from './_components/pagination'; import { useRouter } from 'next/navigation'; import { DEFAULT_AGENTPRESS_TOOLS } from './_data/tools'; import { AgentsParams } from '@/hooks/react-query/agents/utils'; +import { useFeatureFlags } from '@/lib/feature-flags'; type ViewMode = 'grid' | 'list'; type SortOption = 'name' | 'created_at' | 'updated_at' | 'tools_count'; @@ -34,6 +35,7 @@ export default function AgentsPage() { const [editingAgentId, setEditingAgentId] = useState(null); const [viewMode, setViewMode] = useState('grid'); + // Server-side parameters const [page, setPage] = useState(1); const [searchQuery, setSearchQuery] = useState(''); diff --git a/frontend/src/app/(dashboard)/dashboard/_components/dashboard-content.tsx b/frontend/src/app/(dashboard)/dashboard/_components/dashboard-content.tsx new file mode 100644 index 00000000..6021f0a2 --- /dev/null +++ b/frontend/src/app/(dashboard)/dashboard/_components/dashboard-content.tsx @@ -0,0 +1,238 @@ +'use client'; + +import React, { useState, Suspense, useEffect, useRef } from 'react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Menu } from 'lucide-react'; +import { + ChatInput, + ChatInputHandles, +} from '@/components/thread/chat-input/chat-input'; +import { + BillingError, +} from '@/lib/api'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { useSidebar } from '@/components/ui/sidebar'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useBillingError } from '@/hooks/useBillingError'; +import { BillingErrorAlert } from '@/components/billing/usage-limit-alert'; +import { useAccounts } from '@/hooks/use-accounts'; +import { config } from '@/lib/config'; +import { useInitiateAgentWithInvalidation } from '@/hooks/react-query/dashboard/use-initiate-agent'; +import { ModalProviders } from '@/providers/modal-providers'; +import { AgentSelector } from '@/components/dashboard/agent-selector'; +import { cn } from '@/lib/utils'; +import { useModal } from '@/hooks/use-modal-store'; +import { Examples } from './suggestions/examples'; +import { useThreadQuery } from '@/hooks/react-query/threads/use-threads'; + +const PENDING_PROMPT_KEY = 'pendingAgentPrompt'; + +export function DashboardContent() { + const [inputValue, setInputValue] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [autoSubmit, setAutoSubmit] = useState(false); + const [selectedAgentId, setSelectedAgentId] = useState(); + const [initiatedThreadId, setInitiatedThreadId] = useState(null); + const { billingError, handleBillingError, clearBillingError } = + useBillingError(); + const router = useRouter(); + const searchParams = useSearchParams(); + const isMobile = useIsMobile(); + const { setOpenMobile } = useSidebar(); + const { data: accounts } = useAccounts(); + const personalAccount = accounts?.find((account) => account.personal_account); + const chatInputRef = useRef(null); + const initiateAgentMutation = useInitiateAgentWithInvalidation(); + const { onOpen } = useModal(); + + const threadQuery = useThreadQuery(initiatedThreadId || ''); + + useEffect(() => { + const agentIdFromUrl = searchParams.get('agent_id'); + if (agentIdFromUrl && agentIdFromUrl !== selectedAgentId) { + setSelectedAgentId(agentIdFromUrl); + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete('agent_id'); + router.replace(newUrl.pathname + newUrl.search, { scroll: false }); + } + }, [searchParams, selectedAgentId, router]); + + useEffect(() => { + if (threadQuery.data && initiatedThreadId) { + const thread = threadQuery.data; + console.log('Thread data received:', thread); + if (thread.project_id) { + router.push(`/projects/${thread.project_id}/thread/${initiatedThreadId}`); + } else { + router.push(`/agents/${initiatedThreadId}`); + } + setInitiatedThreadId(null); + } + }, [threadQuery.data, initiatedThreadId, router]); + + const secondaryGradient = + 'bg-gradient-to-r from-blue-500 to-blue-500 bg-clip-text text-transparent'; + + const handleSubmit = async ( + message: string, + options?: { + model_name?: string; + enable_thinking?: boolean; + reasoning_effort?: string; + stream?: boolean; + enable_context_manager?: boolean; + }, + ) => { + if ( + (!message.trim() && !chatInputRef.current?.getPendingFiles().length) || + isSubmitting + ) + return; + + setIsSubmitting(true); + + try { + const files = chatInputRef.current?.getPendingFiles() || []; + localStorage.removeItem(PENDING_PROMPT_KEY); + + const formData = new FormData(); + formData.append('prompt', message); + + // Add selected agent if one is chosen + if (selectedAgentId) { + formData.append('agent_id', selectedAgentId); + } + + files.forEach((file, index) => { + formData.append('files', file, file.name); + }); + + if (options?.model_name) formData.append('model_name', options.model_name); + formData.append('enable_thinking', String(options?.enable_thinking ?? false)); + formData.append('reasoning_effort', options?.reasoning_effort ?? 'low'); + formData.append('stream', String(options?.stream ?? true)); + formData.append('enable_context_manager', String(options?.enable_context_manager ?? false)); + + console.log('FormData content:', Array.from(formData.entries())); + + const result = await initiateAgentMutation.mutateAsync(formData); + console.log('Agent initiated:', result); + + if (result.thread_id) { + setInitiatedThreadId(result.thread_id); + } else { + throw new Error('Agent initiation did not return a thread_id.'); + } + chatInputRef.current?.clearPendingFiles(); + } catch (error: any) { + console.error('Error during submission process:', error); + if (error instanceof BillingError) { + console.log('Handling BillingError:', error.detail); + onOpen("paymentRequiredDialog"); + } + } finally { + setIsSubmitting(false); + } + }; + + useEffect(() => { + const timer = setTimeout(() => { + const pendingPrompt = localStorage.getItem(PENDING_PROMPT_KEY); + + if (pendingPrompt) { + setInputValue(pendingPrompt); + setAutoSubmit(true); + } + }, 200); + + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (autoSubmit && inputValue && !isSubmitting) { + const timer = setTimeout(() => { + handleSubmit(inputValue); + setAutoSubmit(false); + }, 500); + + return () => clearTimeout(timer); + } + }, [autoSubmit, inputValue, isSubmitting]); + + return ( + <> + +
+ {isMobile && ( +
+ + + + + Open menu + +
+ )} + +
+
+
+

+ Hey, I am +

+ +
+

+ What would you like to do today? +

+
+ +
+ +
+ + +
+ + +
+ + ); +} diff --git a/frontend/src/app/(dashboard)/dashboard/page.tsx b/frontend/src/app/(dashboard)/dashboard/page.tsx index fff1574d..ef80c942 100644 --- a/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -1,226 +1,10 @@ -'use client'; +import { cn } from "@/lib/utils"; +import { DashboardContent } from "./_components/dashboard-content"; +import { Suspense } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { isFlagEnabled } from "@/lib/feature-flags"; -import React, { useState, Suspense, useEffect, useRef } from 'react'; -import { Skeleton } from '@/components/ui/skeleton'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { Menu } from 'lucide-react'; -import { - ChatInput, - ChatInputHandles, -} from '@/components/thread/chat-input/chat-input'; -import { - BillingError, -} from '@/lib/api'; -import { useIsMobile } from '@/hooks/use-mobile'; -import { useSidebar } from '@/components/ui/sidebar'; -import { Button } from '@/components/ui/button'; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { useBillingError } from '@/hooks/useBillingError'; -import { BillingErrorAlert } from '@/components/billing/usage-limit-alert'; -import { useAccounts } from '@/hooks/use-accounts'; -import { config } from '@/lib/config'; -import { useInitiateAgentWithInvalidation } from '@/hooks/react-query/dashboard/use-initiate-agent'; -import { ModalProviders } from '@/providers/modal-providers'; -import { AgentSelector } from '@/components/dashboard/agent-selector'; -import { cn } from '@/lib/utils'; -import { useModal } from '@/hooks/use-modal-store'; -import { Examples } from './_components/suggestions/examples'; - -const PENDING_PROMPT_KEY = 'pendingAgentPrompt'; - -function DashboardContent() { - const [inputValue, setInputValue] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); - const [autoSubmit, setAutoSubmit] = useState(false); - const [selectedAgentId, setSelectedAgentId] = useState(); - const { billingError, handleBillingError, clearBillingError } = - useBillingError(); - const router = useRouter(); - const searchParams = useSearchParams(); - const isMobile = useIsMobile(); - const { setOpenMobile } = useSidebar(); - const { data: accounts } = useAccounts(); - const personalAccount = accounts?.find((account) => account.personal_account); - const chatInputRef = useRef(null); - const initiateAgentMutation = useInitiateAgentWithInvalidation(); - const { onOpen } = useModal(); - - useEffect(() => { - const agentIdFromUrl = searchParams.get('agent_id'); - if (agentIdFromUrl && agentIdFromUrl !== selectedAgentId) { - setSelectedAgentId(agentIdFromUrl); - const newUrl = new URL(window.location.href); - newUrl.searchParams.delete('agent_id'); - router.replace(newUrl.pathname + newUrl.search, { scroll: false }); - } - }, [searchParams, selectedAgentId, router]); - - const secondaryGradient = - 'bg-gradient-to-r from-blue-500 to-blue-500 bg-clip-text text-transparent'; - - const handleSubmit = async ( - message: string, - options?: { - model_name?: string; - enable_thinking?: boolean; - reasoning_effort?: string; - stream?: boolean; - enable_context_manager?: boolean; - }, - ) => { - if ( - (!message.trim() && !chatInputRef.current?.getPendingFiles().length) || - isSubmitting - ) - return; - - setIsSubmitting(true); - - try { - const files = chatInputRef.current?.getPendingFiles() || []; - localStorage.removeItem(PENDING_PROMPT_KEY); - - const formData = new FormData(); - formData.append('prompt', message); - - // Add selected agent if one is chosen - if (selectedAgentId) { - formData.append('agent_id', selectedAgentId); - } - - files.forEach((file, index) => { - formData.append('files', file, file.name); - }); - - if (options?.model_name) formData.append('model_name', options.model_name); - formData.append('enable_thinking', String(options?.enable_thinking ?? false)); - formData.append('reasoning_effort', options?.reasoning_effort ?? 'low'); - formData.append('stream', String(options?.stream ?? true)); - formData.append('enable_context_manager', String(options?.enable_context_manager ?? false)); - - console.log('FormData content:', Array.from(formData.entries())); - - const result = await initiateAgentMutation.mutateAsync(formData); - console.log('Agent initiated:', result); - - if (result.thread_id) { - router.push(`/agents/${result.thread_id}`); - } else { - throw new Error('Agent initiation did not return a thread_id.'); - } - chatInputRef.current?.clearPendingFiles(); - } catch (error: any) { - console.error('Error during submission process:', error); - if (error instanceof BillingError) { - console.log('Handling BillingError:', error.detail); - onOpen("paymentRequiredDialog"); - } - } finally { - setIsSubmitting(false); - } - }; - - useEffect(() => { - const timer = setTimeout(() => { - const pendingPrompt = localStorage.getItem(PENDING_PROMPT_KEY); - - if (pendingPrompt) { - setInputValue(pendingPrompt); - setAutoSubmit(true); - } - }, 200); - - return () => clearTimeout(timer); - }, []); - - useEffect(() => { - if (autoSubmit && inputValue && !isSubmitting) { - const timer = setTimeout(() => { - handleSubmit(inputValue); - setAutoSubmit(false); - }, 500); - - return () => clearTimeout(timer); - } - }, [autoSubmit, inputValue, isSubmitting]); - - return ( - <> - -
- {isMobile && ( -
- - - - - Open menu - -
- )} - -
-
-
-

- Hey, I am -

- -
-

- What would you like to do today? -

-
- -
- -
- - -
- - -
- - ); -} - -export default function DashboardPage() { +export default async function DashboardPage() { return ( {children}; } diff --git a/frontend/src/app/auth/actions.ts b/frontend/src/app/auth/actions.ts index 0d092fad..f9ed1074 100644 --- a/frontend/src/app/auth/actions.ts +++ b/frontend/src/app/auth/actions.ts @@ -3,6 +3,30 @@ import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; +async function sendWelcomeEmail(email: string, name?: string) { + try { + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL; + const response = await fetch(`${backendUrl}/send-welcome-email-background`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email, + name, + }), + }); + + if (response.ok) { + console.log(`Welcome email queued for ${email}`); + } else { + console.error(`Failed to queue welcome email for ${email}`); + } + } catch (error) { + console.error('Error sending welcome email:', error); + } +} + export async function signIn(prevState: any, formData: FormData) { const email = formData.get('email') as string; const password = formData.get('password') as string; @@ -64,12 +88,17 @@ export async function signUp(prevState: any, formData: FormData) { return { message: error.message || 'Could not create account' }; } - // Try to sign in immediately - const { error: signInError } = await supabase.auth.signInWithPassword({ + const userName = email.split('@')[0].replace(/[._-]/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + + const { error: signInError, data: signInData } = await supabase.auth.signInWithPassword({ email, password, }); + if (signInData) { + sendWelcomeEmail(email, userName); + } + if (signInError) { return { message: diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c2cf2118..a5e0a079 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -637,4 +637,20 @@ animation: var(--animate-shimmer); width: 100%; transform: translateX(-100%); +} + +@theme inline { + --animate-shiny-text: shiny-text 5s infinite; + + @keyframes shiny-text { + 0%, + 90%, + 100% { + background-position: calc(-100% - var(--shiny-width)) 0; + } + 30%, + 60% { + background-position: calc(100% + var(--shiny-width)) 0; + } + } } \ No newline at end of file diff --git a/frontend/src/components/dashboard/agent-selector.tsx b/frontend/src/components/dashboard/agent-selector.tsx index 6bf58337..1c2cd869 100644 --- a/frontend/src/components/dashboard/agent-selector.tsx +++ b/frontend/src/components/dashboard/agent-selector.tsx @@ -15,6 +15,7 @@ import { useAgents } from '@/hooks/react-query/agents/use-agents'; import { useRouter } from 'next/navigation'; import { cn } from '@/lib/utils'; import { CreateAgentDialog } from '@/app/(dashboard)/agents/_components/create-agent-dialog'; +import { useFeatureFlags } from '@/lib/feature-flags'; interface AgentSelectorProps { onAgentSelect?: (agentId: string | undefined) => void; @@ -27,13 +28,17 @@ export function AgentSelector({ onAgentSelect, selectedAgentId, className, - variant = 'default' + variant = 'default', }: AgentSelectorProps) { const { data: agentsResponse, isLoading, refetch: loadAgents } = useAgents({ limit: 100, sort_by: 'name', sort_order: 'asc' }); + + + const { flags, loading: flagsLoading } = useFeatureFlags(['custom_agents']); + const customAgentsEnabled = flags.custom_agents; const router = useRouter(); const [isOpen, setIsOpen] = useState(false); @@ -69,6 +74,18 @@ export function AgentSelector({ setIsOpen(false); }; + if (!customAgentsEnabled) { + if (variant === 'heading') { + return ( +
+ + Suna + +
+ ); + } + } + if (isLoading) { if (variant === 'heading') { return ( diff --git a/frontend/src/components/sidebar/search-search.tsx b/frontend/src/components/sidebar/search-search.tsx index b797a713..45700b91 100644 --- a/frontend/src/components/sidebar/search-search.tsx +++ b/frontend/src/components/sidebar/search-search.tsx @@ -82,7 +82,7 @@ export function SidebarSearch() { threadId: thread.thread_id, projectId: projectId, projectName: project.name || 'Unnamed Project', - url: `/agents/${thread.thread_id}`, + url: `/projects/${projectId}/thread/${thread.thread_id}`, updatedAt: thread.updated_at || project.updated_at || new Date().toISOString(), }); diff --git a/frontend/src/components/sidebar/sidebar-left.tsx b/frontend/src/components/sidebar/sidebar-left.tsx index d3286878..a984d20f 100644 --- a/frontend/src/components/sidebar/sidebar-left.tsx +++ b/frontend/src/components/sidebar/sidebar-left.tsx @@ -31,6 +31,7 @@ import { useIsMobile } from '@/hooks/use-mobile'; import { Badge } from '../ui/badge'; import { cn } from '@/lib/utils'; import { usePathname } from 'next/navigation'; +import { useFeatureFlags } from '@/lib/feature-flags'; export function SidebarLeft({ ...props @@ -48,6 +49,9 @@ export function SidebarLeft({ }); const pathname = usePathname(); + const { flags, loading: flagsLoading } = useFeatureFlags(['custom_agents', 'agent_marketplace']); + const customAgentsEnabled = flags.custom_agents; + const marketplaceEnabled = flags.agent_marketplace; // Fetch user data useEffect(() => { @@ -134,35 +138,40 @@ export function SidebarLeft({ - - - - - - Agent Playground - - New - - - - - - - - - - Marketplace - - New - - - - - + {!flagsLoading && (customAgentsEnabled || marketplaceEnabled) && ( + + {customAgentsEnabled && ( + + + + + Agent Playground + + New + + + + + )} + {marketplaceEnabled && ( + + + + + Marketplace + + New + + + + + )} + + )} {state !== 'collapsed' && ( diff --git a/frontend/src/components/thread/chat-input/chat-input.tsx b/frontend/src/components/thread/chat-input/chat-input.tsx index afe4456b..dbe4ad41 100644 --- a/frontend/src/components/thread/chat-input/chat-input.tsx +++ b/frontend/src/components/thread/chat-input/chat-input.tsx @@ -250,17 +250,7 @@ export const ChatInput = forwardRef( }} >
- - {onAgentSelect && ( -
- -
- )} + ( maxHeight="216px" showPreviews={true} /> - = ({ groupedMessages.push(currentGroup); } - // Handle streaming content + // Handle streaming content - only add to existing group or create new one if needed if (streamingTextContent) { const lastGroup = groupedMessages.at(-1); if (!lastGroup || lastGroup.type === 'user') { @@ -443,7 +443,6 @@ export const ThreadContent: React.FC = ({ key: `assistant-group-${assistantGroupCounter}-streaming` }); } else if (lastGroup.type === 'assistant_group') { - // Add to existing assistant group lastGroup.messages.push({ content: streamingTextContent, type: 'assistant', @@ -513,7 +512,7 @@ export const ThreadContent: React.FC = ({ return (
- {/* Logo positioned above the message content */} + {/* Logo positioned above the message content - ONLY ONCE PER GROUP */}
{agentAvatar} @@ -521,8 +520,8 @@ export const ThreadContent: React.FC = ({

{agentName ? agentName : 'Suna'}

- {/* Message content */} -
+ {/* Message content - ALL messages in the group */} +
{(() => { // In debug mode, just show raw messages content @@ -566,13 +565,13 @@ export const ThreadContent: React.FC = ({ const renderedToolResultIds = new Set(); const elements: React.ReactNode[] = []; - let assistantMessageCount = 0; // Track assistant messages for spacing group.messages.forEach((message, msgIndex) => { if (message.type === 'assistant') { const parsedContent = safeJsonParse(message.content, {}); const msgKey = message.message_id || `submsg-assistant-${msgIndex}`; - + let assistantMessageCount = 0; + if (!parsedContent.content) return; const renderedContent = renderMarkdownContent( @@ -586,13 +585,12 @@ export const ThreadContent: React.FC = ({ ); elements.push( -
0 ? "mt-2" : ""}> +
0 ? "mt-4" : ""}>
{renderedContent}
); - assistantMessageCount++; } }); diff --git a/frontend/src/components/thread/content/loader.tsx b/frontend/src/components/thread/content/loader.tsx index bc70eb73..87aa217a 100644 --- a/frontend/src/components/thread/content/loader.tsx +++ b/frontend/src/components/thread/content/loader.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; +import { AnimatedShinyText } from '@/components/ui/animated-shiny-text'; const items = [ { id: 1, content: "Initializing neural pathways..." }, @@ -33,10 +34,8 @@ export const AgentLoader = () => { }, []); return ( -
-
-
-
+
+
{ exit={{ y: -20, opacity: 0, filter: "blur(8px)" }} transition={{ ease: "easeInOut" }} style={{ position: "absolute" }} + className='ml-7' > - {items[index].content} + {items[index].content}
-
-
-
); }; diff --git a/frontend/src/components/thread/tool-call-side-panel.tsx b/frontend/src/components/thread/tool-call-side-panel.tsx index 1f268cd9..e932ae6b 100644 --- a/frontend/src/components/thread/tool-call-side-panel.tsx +++ b/frontend/src/components/thread/tool-call-side-panel.tsx @@ -299,6 +299,52 @@ export function ToolCallSidePanel({ internalNavigate(totalCalls - 1, 'user_explicit'); }, [totalCalls, internalNavigate]); + const renderStatusButton = React.useCallback(() => { + const baseClasses = "flex items-center justify-center gap-1.5 px-2 py-0.5 rounded-full w-[116px]"; + const dotClasses = "w-1.5 h-1.5 rounded-full"; + const textClasses = "text-xs font-medium"; + + if (isLiveMode) { + if (agentStatus === 'running') { + return ( +
+
+ Live Updates +
+ ); + } else { + return ( +
+
+ Latest Tool +
+ ); + } + } else { + if (agentStatus === 'running') { + return ( +
+
+ Jump to Live +
+ ); + } else { + return ( +
+
+ Jump to Latest +
+ ); + } + } + }, [isLiveMode, agentStatus, jumpToLive, jumpToLatest]); + const handleSliderChange = React.useCallback(([newValue]: [number]) => { const targetSnapshot = completedToolCalls[newValue]; if (targetSnapshot) { @@ -637,43 +683,9 @@ export function ToolCallSidePanel({
- {!isMobile && ( -
-
-
- -
- - {getUserFriendlyToolName(currentToolName)} {isStreaming && `(Running${dots})`} - -
- -
- {isLiveMode && agentStatus === 'running' && ( -
-
- Live -
- )} - {!isLiveMode && agentStatus !== 'running' && ( -
-
- Live -
- )} - - Step {displayIndex + 1} of {displayTotalCalls} - -
-
- )} - {isMobile ? (
-
- {isLiveMode && agentStatus === 'running' ? ( -
-
- Live -
- ) : ( -
-
- Live -
- )} - - - {displayIndex + 1} / {displayTotalCalls} - {isCurrentToolStreaming && totalCompletedCalls > 0 && ( - • Running - )} +
+ + {displayIndex + 1}/{displayTotalCalls} + {renderStatusButton()}
) : ( -
+
+ + {displayIndex + 1}/{displayTotalCalls} +
-
- {(showJumpToLive || showJumpToLatest) && ( -
- {showJumpToLive && ( - - )} - {showJumpToLatest && ( - - )} -
- )} - +
+ +
+ {renderStatusButton()} +
)}
diff --git a/frontend/src/components/thread/tool-views/BrowserToolView.tsx b/frontend/src/components/thread/tool-views/BrowserToolView.tsx index d9d7336c..29fb949b 100644 --- a/frontend/src/components/thread/tool-views/BrowserToolView.tsx +++ b/frontend/src/components/thread/tool-views/BrowserToolView.tsx @@ -65,7 +65,7 @@ export function BrowserToolView({ const [imageError, setImageError] = React.useState(false); try { - const topLevelParsed = safeJsonParse<{ content?: string }>(toolContent, {}); + const topLevelParsed = safeJsonParse<{ content?: any }>(toolContent, {}); const innerContentString = topLevelParsed?.content || toolContent; if (innerContentString && typeof innerContentString === 'string') { const toolResultMatch = innerContentString.match(/ToolResult\([^)]*output='([\s\S]*?)'(?:\s*,|\s*\))/); @@ -116,7 +116,18 @@ export function BrowserToolView({ screenshotUrl = finalParsedOutput?.image_url || null; } } - } + } else if (innerContentString && typeof innerContentString === "object") { + screenshotUrl = (() => { + if (!innerContentString) return null; + if (!("tool_execution" in innerContentString)) return null; + if (!("result" in innerContentString.tool_execution)) return null; + if (!("output" in innerContentString.tool_execution.result)) return null; + if (!("image_url" in innerContentString.tool_execution.result.output)) return null; + if (typeof innerContentString.tool_execution.result.output.image_url !== "string") return null; + return innerContentString.tool_execution.result.output.image_url; + })() + } + } catch (error) { } diff --git a/frontend/src/components/thread/tool-views/ask-tool/AskToolView.tsx b/frontend/src/components/thread/tool-views/ask-tool/AskToolView.tsx index ee924e3d..18e77db8 100644 --- a/frontend/src/components/thread/tool-views/ask-tool/AskToolView.tsx +++ b/frontend/src/components/thread/tool-views/ask-tool/AskToolView.tsx @@ -35,7 +35,7 @@ export function AskToolView({ onFileClick, project, }: AskToolViewProps) { - + const { text, attachments, @@ -190,12 +190,7 @@ export function AskToolView({ })}
- {actualAssistantTimestamp && ( -
- - {formatTimestamp(actualAssistantTimestamp)} -
- )} +
) : (
diff --git a/frontend/src/components/thread/tool-views/command-tool/CommandToolView.tsx b/frontend/src/components/thread/tool-views/command-tool/CommandToolView.tsx index 34f79d48..ae9ad6b1 100644 --- a/frontend/src/components/thread/tool-views/command-tool/CommandToolView.tsx +++ b/frontend/src/components/thread/tool-views/command-tool/CommandToolView.tsx @@ -68,16 +68,16 @@ export function CommandToolView({ } } catch (e) { } - + processedOutput = String(processedOutput); processedOutput = processedOutput.replace(/\\\\/g, '\\'); - + processedOutput = processedOutput .replace(/\\n/g, '\n') .replace(/\\t/g, '\t') .replace(/\\"/g, '"') .replace(/\\'/g, "'"); - + processedOutput = processedOutput.replace(/\\u([0-9a-fA-F]{4})/g, (match, group) => { return String.fromCharCode(parseInt(group, 16)); }); @@ -102,13 +102,13 @@ export function CommandToolView({
- + {!isStreaming && ( - @@ -117,8 +117,8 @@ export function CommandToolView({ ) : ( )} - {actualIsSuccess ? - (name === 'check-command-output' ? 'Output retrieved successfully' : 'Command executed successfully') : + {actualIsSuccess ? + (name === 'check-command-output' ? 'Output retrieved successfully' : 'Command executed successfully') : (name === 'check-command-output' ? 'Failed to retrieve output' : 'Command failed') } @@ -128,7 +128,7 @@ export function CommandToolView({ {isStreaming ? ( -
-
-
- - {displayLabel} - {sessionName && cwd && ( - - {cwd} - - )} -
-
- {displayPrefix} - {displayText} -
-
+ {output && (
-
-

- - Output -

-
- {completed !== null && ( - - {completed ? 'Completed' : 'Running'} - - )} - {exitCode !== null && ( - - {exitCode === 0 ? 'Success' : `Exit ${exitCode}`} - - )} -
-
- + +
@@ -201,8 +161,8 @@ export function CommandToolView({
                         {linesToShow.map((line, index) => (
-                          
{line || ' '} @@ -218,7 +178,7 @@ export function CommandToolView({
)} - + {!output && !isStreaming && (
@@ -238,7 +198,7 @@ export function CommandToolView({ {name === 'check-command-output' ? 'No Session Found' : 'No Command Found'}

- {name === 'check-command-output' + {name === 'check-command-output' ? 'No session name was detected. Please provide a valid session name to check.' : 'No command was detected. Please provide a valid command to execute.' } @@ -246,7 +206,7 @@ export function CommandToolView({

)} - +
{!isStreaming && displayText && ( @@ -256,7 +216,7 @@ export function CommandToolView({ )}
- +
{actualToolTimestamp && !isStreaming diff --git a/frontend/src/components/ui/animated-shiny-text.tsx b/frontend/src/components/ui/animated-shiny-text.tsx new file mode 100644 index 00000000..804a2aac --- /dev/null +++ b/frontend/src/components/ui/animated-shiny-text.tsx @@ -0,0 +1,34 @@ +import { ComponentPropsWithoutRef, CSSProperties, FC } from "react"; + +import { cn } from "@/lib/utils"; + +export interface AnimatedShinyTextProps + extends ComponentPropsWithoutRef<"span"> { + shimmerWidth?: number; +} + +export const AnimatedShinyText: FC = ({ + children, + className, + shimmerWidth = 100, + ...props +}) => { + return ( + + {children} + + ); +}; diff --git a/frontend/src/flags.ts b/frontend/src/flags.ts new file mode 100644 index 00000000..54962c26 --- /dev/null +++ b/frontend/src/flags.ts @@ -0,0 +1,7 @@ +import { isLocalMode } from './lib/config'; + +export const agentPlaygroundFlagFrontend = isLocalMode(); +export const marketplaceFlagFrontend = isLocalMode(); + +export const agentPlaygroundEnabled = isLocalMode(); +export const marketplaceEnabled = isLocalMode(); diff --git a/frontend/src/hooks/react-query/agents/utils.ts b/frontend/src/hooks/react-query/agents/utils.ts index f9ed6d1f..5478ccc4 100644 --- a/frontend/src/hooks/react-query/agents/utils.ts +++ b/frontend/src/hooks/react-query/agents/utils.ts @@ -1,4 +1,5 @@ import { createClient } from "@/lib/supabase/client"; +import { isFlagEnabled } from "@/lib/feature-flags"; const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; @@ -98,6 +99,10 @@ export type AgentUpdateRequest = { export const getAgents = async (params: AgentsParams = {}): Promise => { try { + const agentPlaygroundEnabled = await isFlagEnabled('custom_agents'); + if (!agentPlaygroundEnabled) { + throw new Error('Custom agents is not enabled'); + } const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); @@ -142,6 +147,10 @@ export const getAgents = async (params: AgentsParams = {}): Promise => { try { + const agentPlaygroundEnabled = await isFlagEnabled('custom_agents'); + if (!agentPlaygroundEnabled) { + throw new Error('Custom agents is not enabled'); + } const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); @@ -173,6 +182,10 @@ export const getAgent = async (agentId: string): Promise => { export const createAgent = async (agentData: AgentCreateRequest): Promise => { try { + const agentPlaygroundEnabled = await isFlagEnabled('custom_agents'); + if (!agentPlaygroundEnabled) { + throw new Error('Custom agents is not enabled'); + } const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); @@ -205,6 +218,10 @@ export const createAgent = async (agentData: AgentCreateRequest): Promise export const updateAgent = async (agentId: string, agentData: AgentUpdateRequest): Promise => { try { + const agentPlaygroundEnabled = await isFlagEnabled('custom_agents'); + if (!agentPlaygroundEnabled) { + throw new Error('Custom agents is not enabled'); + } const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); @@ -237,6 +254,10 @@ export const updateAgent = async (agentId: string, agentData: AgentUpdateRequest export const deleteAgent = async (agentId: string): Promise => { try { + const agentPlaygroundEnabled = await isFlagEnabled('custom_agents'); + if (!agentPlaygroundEnabled) { + throw new Error('Custom agents is not enabled'); + } const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); @@ -266,6 +287,10 @@ export const deleteAgent = async (agentId: string): Promise => { export const getThreadAgent = async (threadId: string): Promise => { try { + const agentPlaygroundEnabled = await isFlagEnabled('custom_agents'); + if (!agentPlaygroundEnabled) { + throw new Error('Custom agents is not enabled'); + } const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); @@ -297,6 +322,10 @@ export const getThreadAgent = async (threadId: string): Promise => { try { + const agentPlaygroundEnabled = await isFlagEnabled('custom_agents'); + if (!agentPlaygroundEnabled) { + throw new Error('Custom agents is not enabled'); + } const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); @@ -364,6 +393,10 @@ export const startAgentBuilderChat = async ( signal?: AbortSignal ): Promise => { try { + const agentPlaygroundEnabled = await isFlagEnabled('custom_agents'); + if (!agentPlaygroundEnabled) { + throw new Error('Custom agents is not enabled'); + } const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); diff --git a/frontend/src/hooks/react-query/marketplace/use-marketplace.ts b/frontend/src/hooks/react-query/marketplace/use-marketplace.ts index d8912dd6..4610d86a 100644 --- a/frontend/src/hooks/react-query/marketplace/use-marketplace.ts +++ b/frontend/src/hooks/react-query/marketplace/use-marketplace.ts @@ -1,5 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { createClient } from '@/lib/supabase/client'; +import { isFlagEnabled } from '@/lib/feature-flags'; const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; @@ -45,6 +46,11 @@ export function useMarketplaceAgents(params: MarketplaceAgentsParams = {}) { queryKey: ['marketplace-agents', params], queryFn: async (): Promise => { try { + const marketplaceEnabled = await isFlagEnabled('agent_marketplace'); + if (!marketplaceEnabled) { + throw new Error('Marketplace is not enabled'); + } + const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); @@ -97,6 +103,11 @@ export function useAddAgentToLibrary() { return useMutation({ mutationFn: async (originalAgentId: string): Promise => { try { + const marketplaceEnabled = await isFlagEnabled('agent_marketplace'); + if (!marketplaceEnabled) { + throw new Error('Marketplace is not enabled'); + } + const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); @@ -138,6 +149,11 @@ export function usePublishAgent() { return useMutation({ mutationFn: async ({ agentId, tags = [] }: { agentId: string; tags?: string[] }): Promise => { try { + const marketplaceEnabled = await isFlagEnabled('agent_marketplace'); + if (!marketplaceEnabled) { + throw new Error('Marketplace is not enabled'); + } + const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); @@ -175,6 +191,11 @@ export function useUnpublishAgent() { return useMutation({ mutationFn: async (agentId: string): Promise => { try { + const marketplaceEnabled = await isFlagEnabled('agent_marketplace'); + if (!marketplaceEnabled) { + throw new Error('Marketplace is not enabled'); + } + const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); @@ -211,6 +232,11 @@ export function useUserAgentLibrary() { queryKey: ['user-agent-library'], queryFn: async () => { try { + const marketplaceEnabled = await isFlagEnabled('agent_marketplace'); + if (!marketplaceEnabled) { + throw new Error('Marketplace is not enabled'); + } + const supabase = createClient(); const { data: { session } } = await supabase.auth.getSession(); diff --git a/frontend/src/hooks/react-query/sidebar/use-sidebar.ts b/frontend/src/hooks/react-query/sidebar/use-sidebar.ts index 19c12739..14bb3b26 100644 --- a/frontend/src/hooks/react-query/sidebar/use-sidebar.ts +++ b/frontend/src/hooks/react-query/sidebar/use-sidebar.ts @@ -115,7 +115,7 @@ export const processThreadsWithProjects = ( threadId: thread.thread_id, projectId: projectId, projectName: project.name || 'Unnamed Project', - url: `/agents/${thread.thread_id}`, + url: `/projects/${projectId}/thread/${thread.thread_id}`, updatedAt: thread.updated_at || project.updated_at || new Date().toISOString(), }); diff --git a/frontend/src/lib/feature-flags.ts b/frontend/src/lib/feature-flags.ts new file mode 100644 index 00000000..c798d73a --- /dev/null +++ b/frontend/src/lib/feature-flags.ts @@ -0,0 +1,254 @@ +import React from 'react'; + +const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; + +export interface FeatureFlag { + flag_name: string; + enabled: boolean; + details?: { + description?: string; + updated_at?: string; + } | null; +} + +export interface FeatureFlagsResponse { + flags: Record; +} + +const flagCache = new Map(); +const CACHE_DURATION = 5 * 60 * 1000; + +let globalFlagsCache: { flags: Record; timestamp: number } | null = null; + +export class FeatureFlagManager { + private static instance: FeatureFlagManager; + + private constructor() {} + + static getInstance(): FeatureFlagManager { + if (!FeatureFlagManager.instance) { + FeatureFlagManager.instance = new FeatureFlagManager(); + } + return FeatureFlagManager.instance; + } + + async isEnabled(flagName: string): Promise { + try { + const cached = flagCache.get(flagName); + if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { + return cached.value; + } + const response = await fetch(`${API_URL}/feature-flags/${flagName}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (!response.ok) { + console.warn(`Failed to fetch feature flag ${flagName}: ${response.status}`); + return false; + } + + const data: FeatureFlag = await response.json(); + + flagCache.set(flagName, { + value: data.enabled, + timestamp: Date.now(), + }); + + return data.enabled; + } catch (error) { + console.error(`Error checking feature flag ${flagName}:`, error); + return false; + } + } + + async getFlagDetails(flagName: string): Promise { + try { + const response = await fetch(`${API_URL}/feature-flags/${flagName}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.warn(`Failed to fetch feature flag details for ${flagName}: ${response.status}`); + return null; + } + + const data: FeatureFlag = await response.json(); + return data; + } catch (error) { + console.error(`Error fetching feature flag details for ${flagName}:`, error); + return null; + } + } + + async getAllFlags(): Promise> { + try { + if (globalFlagsCache && Date.now() - globalFlagsCache.timestamp < CACHE_DURATION) { + return globalFlagsCache.flags; + } + + const response = await fetch(`${API_URL}/feature-flags`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.warn(`Failed to fetch all feature flags: ${response.status}`); + return {}; + } + + const data: FeatureFlagsResponse = await response.json(); + globalFlagsCache = { + flags: data.flags, + timestamp: Date.now(), + }; + + Object.entries(data.flags).forEach(([flagName, enabled]) => { + flagCache.set(flagName, { + value: enabled, + timestamp: Date.now(), + }); + }); + + return data.flags; + } catch (error) { + console.error('Error fetching all feature flags:', error); + return {}; + } + } + + clearCache(): void { + flagCache.clear(); + globalFlagsCache = null; + } + + async preloadFlags(flagNames: string[]): Promise { + try { + const promises = flagNames.map(flagName => this.isEnabled(flagName)); + await Promise.all(promises); + } catch (error) { + console.error('Error preloading feature flags:', error); + } + } +} + +const featureFlagManager = FeatureFlagManager.getInstance(); + +export const isEnabled = (flagName: string): Promise => { + return featureFlagManager.isEnabled(flagName); +}; + +export const isFlagEnabled = isEnabled; + +export const getFlagDetails = (flagName: string): Promise => { + return featureFlagManager.getFlagDetails(flagName); +}; + +export const getAllFlags = (): Promise> => { + return featureFlagManager.getAllFlags(); +}; + +export const clearFlagCache = (): void => { + featureFlagManager.clearCache(); +}; + +export const preloadFlags = (flagNames: string[]): Promise => { + return featureFlagManager.preloadFlags(flagNames); +}; + +export const useFeatureFlag = (flagName: string) => { + const [enabled, setEnabled] = React.useState(false); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + let mounted = true; + + const checkFlag = async () => { + try { + setLoading(true); + setError(null); + const result = await isFlagEnabled(flagName); + if (mounted) { + setEnabled(result); + } + } catch (err) { + if (mounted) { + setError(err instanceof Error ? err.message : 'Unknown error'); + setEnabled(false); + } + } finally { + if (mounted) { + setLoading(false); + } + } + }; + + checkFlag(); + + return () => { + mounted = false; + }; + }, [flagName]); + + return { enabled, loading, error }; +}; + +export const useFeatureFlags = (flagNames: string[]) => { + const [flags, setFlags] = React.useState>({}); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + let mounted = true; + + const checkFlags = async () => { + try { + setLoading(true); + setError(null); + + const results = await Promise.all( + flagNames.map(async (flagName) => { + const enabled = await isFlagEnabled(flagName); + return [flagName, enabled] as [string, boolean]; + }) + ); + + if (mounted) { + const flagsObject = Object.fromEntries(results); + setFlags(flagsObject); + } + } catch (err) { + if (mounted) { + setError(err instanceof Error ? err.message : 'Unknown error'); + const disabledFlags = Object.fromEntries( + flagNames.map(name => [name, false]) + ); + setFlags(disabledFlags); + } + } finally { + if (mounted) { + setLoading(false); + } + } + }; + + if (flagNames.length > 0) { + checkFlags(); + } else { + setLoading(false); + } + + return () => { + mounted = false; + }; + }, [flagNames.join(',')]); + + return { flags, loading, error }; +}; diff --git a/setup.py b/setup.py index fa178942..3ff6f1ff 100644 --- a/setup.py +++ b/setup.py @@ -237,7 +237,7 @@ def collect_daytona_info(): print_info("Then, generate an API key from 'Keys' menu") print_info("After that, go to Images (https://app.daytona.io/dashboard/images)") print_info("Click '+ Create Image'") - print_info(f"Enter 'kortix/suna:0.1.2.8' as the image name") + print_info(f"Enter 'kortix/suna:0.1.3' as the image name") print_info(f"Set '/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf' as the Entrypoint") input("Press Enter to continue once you've completed these steps...") diff --git a/test.json b/test.json deleted file mode 100644 index ca3d6789..00000000 --- a/test.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "mcpServers": { - "exa": { - "command": "mkdir", - "args": [ - "-p", - "C:\\Users\\91877\\pwned" - ] - } - } - } \ No newline at end of file