Merge branch 'main' into sharath/suna-254-agent-start-fails-failed-to-initialize-sandbox-404-no

This commit is contained in:
Sharath 2025-06-05 18:20:29 +05:30 committed by GitHub
commit 3f8681065b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 1960 additions and 559 deletions

View File

@ -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 <command> [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

View File

@ -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

View File

@ -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 <ask attachments="file1, file2, file3"></ask>, 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: <ask attachments="main.py,README.md,config.json,notes.txt">**
* 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

View File

@ -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")

View File

@ -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)}")

View File

@ -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}")

View File

@ -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 <tool_result> 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
return saved_message_obj

View File

@ -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)
}

View File

@ -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."""

View File

@ -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:

View File

33
backend/flags/api.py Normal file
View File

@ -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
}

151
backend/flags/flags.py Normal file
View File

@ -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()

166
backend/flags/setup.py Normal file
View File

@ -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())

View File

@ -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

54
backend/poetry.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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
aiohttp>=3.9.0
email-validator>=2.0.0
mailtrap>=2.0.1

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}

188
backend/services/email.py Normal file
View File

@ -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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Kortix Suna</title>
<style>
body {{
font-family: Arial, sans-serif;
background-color: #ffffff;
color: #000000;
margin: 0;
padding: 0;
line-height: 1.6;
}}
.container {{
max-width: 600px;
margin: 40px auto;
padding: 30px;
background-color: #ffffff;
}}
.logo-container {{
text-align: center;
margin-bottom: 30px;
padding: 10px 0;
}}
.logo {{
max-width: 100%;
height: auto;
max-height: 60px;
display: inline-block;
}}
h1 {{
font-size: 24px;
color: #000000;
margin-bottom: 20px;
}}
p {{
margin-bottom: 16px;
}}
a {{
color: #3366cc;
text-decoration: none;
}}
a:hover {{
text-decoration: underline;
}}
.button {{
display: inline-block;
margin-top: 30px;
background-color: #3B82F6;
color: white !important;
padding: 14px 24px;
text-align: center;
text-decoration: none;
font-weight: bold;
border-radius: 6px;
border: none;
}}
.button:hover {{
background-color: #2563EB;
text-decoration: none;
}}
.emoji {{
font-size: 20px;
}}
</style>
</head>
<body>
<div class="container">
<div class="logo-container">
<img src="https://i.postimg.cc/WdNtRx5Z/kortix-suna-logo.png" alt="Kortix Suna Logo" class="logo">
</div>
<h1>Welcome to Kortix Suna!</h1>
<p>Hi {user_name},</p>
<p><em><strong>Welcome to Kortix Suna we're excited to have you on board!</strong></em></p>
<p>To get started, we'd like to get to know you better: fill out this short <a href="https://docs.google.com/forms/d/e/1FAIpQLSef1EHuqmIh_iQz-kwhjnzSC3Ml-V_5wIySDpMoMU9W_j24JQ/viewform">form</a>!</p>
<p>To celebrate your arrival, here's a <strong>15% discount</strong> to try out the best version of Suna (1 month):</p>
<p>🎁 Use code <strong>WELCOME15</strong> at checkout.</p>
<p>Let us know if you need help getting started or have questions we're always here, and join our <a href="https://discord.com/invite/FjD644cfcs">Discord community</a>.</p>
<p>Thanks again, and welcome to the Suna community <span class="emoji">🌞</span></p>
<p> The Suna Team</p>
<a href="https://www.suna.so/" class="button">Go to the platform</a>
</div>
</body>
</html>"""
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()

View File

@ -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"
)

View File

@ -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

View File

@ -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

View File

@ -4,4 +4,4 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=""
NEXT_PUBLIC_BACKEND_URL=""
NEXT_PUBLIC_URL=""
NEXT_PUBLIC_GOOGLE_CLIENT_ID=""
OPENAI_API_KEY=""
OPENAI_API_KEY=""

View File

@ -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}</>;
}

View File

@ -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}</>;
}

View File

@ -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<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
// Server-side parameters
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');

View File

@ -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<string | undefined>();
const [initiatedThreadId, setInitiatedThreadId] = useState<string | null>(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<ChatInputHandles>(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 (
<>
<ModalProviders />
<div className="flex flex-col h-screen w-full">
{isMobile && (
<div className="absolute top-4 left-4 z-10">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setOpenMobile(true)}
>
<Menu className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</TooltipTrigger>
<TooltipContent>Open menu</TooltipContent>
</Tooltip>
</div>
)}
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[650px] max-w-[90%]">
<div className="flex flex-col items-center text-center w-full">
<div className="flex items-center gap-1">
<h1 className="tracking-tight text-4xl text-muted-foreground leading-tight">
Hey, I am
</h1>
<AgentSelector
selectedAgentId={selectedAgentId}
onAgentSelect={setSelectedAgentId}
variant="heading"
/>
</div>
<p className="tracking-tight text-3xl font-normal text-muted-foreground/80 mt-2">
What would you like to do today?
</p>
</div>
<div className={cn(
"w-full mb-2",
"max-w-full",
"sm:max-w-3xl"
)}>
<ChatInput
ref={chatInputRef}
onSubmit={handleSubmit}
loading={isSubmitting}
placeholder="Describe what you need help with..."
value={inputValue}
onChange={setInputValue}
hideAttachments={false}
/>
</div>
<Examples onSelectPrompt={setInputValue} />
</div>
<BillingErrorAlert
message={billingError?.message}
currentUsage={billingError?.currentUsage}
limit={billingError?.limit}
accountId={personalAccount?.account_id}
onDismiss={clearBillingError}
isOpen={!!billingError}
/>
</div>
</>
);
}

View File

@ -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<string | undefined>();
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<ChatInputHandles>(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 (
<>
<ModalProviders />
<div className="flex flex-col h-screen w-full">
{isMobile && (
<div className="absolute top-4 left-4 z-10">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setOpenMobile(true)}
>
<Menu className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</TooltipTrigger>
<TooltipContent>Open menu</TooltipContent>
</Tooltip>
</div>
)}
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[650px] max-w-[90%]">
<div className="flex flex-col items-center text-center w-full">
<div className="flex items-center gap-1">
<h1 className="tracking-tight text-4xl text-muted-foreground leading-tight">
Hey, I am
</h1>
<AgentSelector
selectedAgentId={selectedAgentId}
onAgentSelect={setSelectedAgentId}
variant="heading"
/>
</div>
<p className="tracking-tight text-3xl font-normal text-muted-foreground/80 mt-2">
What would you like to do today?
</p>
</div>
<div className={cn(
"w-full mb-2",
"max-w-full",
"sm:max-w-3xl"
)}>
<ChatInput
ref={chatInputRef}
onSubmit={handleSubmit}
loading={isSubmitting}
placeholder="Describe what you need help with..."
value={inputValue}
onChange={setInputValue}
hideAttachments={false}
/>
</div>
<Examples onSelectPrompt={setInputValue} />
</div>
<BillingErrorAlert
message={billingError?.message}
currentUsage={billingError?.currentUsage}
limit={billingError?.limit}
accountId={personalAccount?.account_id}
onDismiss={clearBillingError}
isOpen={!!billingError}
/>
</div>
</>
);
}
export default function DashboardPage() {
export default async function DashboardPage() {
return (
<Suspense
fallback={

View File

@ -1,4 +1,6 @@
import { isFlagEnabled } from '@/lib/feature-flags';
import { Metadata } from 'next';
import { redirect } from 'next/navigation';
export const metadata: Metadata = {
title: 'Agent Marketplace | Kortix Suna',
@ -10,10 +12,14 @@ export const metadata: Metadata = {
},
};
export default function MarketplaceLayout({
export default async function MarketplaceLayout({
children,
}: {
children: React.ReactNode;
}) {
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
if (!marketplaceEnabled) {
redirect('/dashboard');
}
return <>{children}</>;
}

View File

@ -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:

View File

@ -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;
}
}
}

View File

@ -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 (
<div className={cn("flex items-center", className)}>
<span className="tracking-tight text-4xl font-semibold leading-tight text-primary">
Suna
</span>
</div>
);
}
}
if (isLoading) {
if (variant === 'heading') {
return (

View File

@ -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(),
});

View File

@ -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({
</div>
</SidebarHeader>
<SidebarContent className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
<SidebarGroup>
<Link href="/agents">
<SidebarMenuButton className={cn({
'bg-primary/10 font-medium': pathname === '/agents',
})}>
<Bot className="h-4 w-4 mr-2" />
<span className="flex items-center justify-between w-full">
Agent Playground
<Badge variant="new">
New
</Badge>
</span>
</SidebarMenuButton>
</Link>
<Link href="/marketplace">
<SidebarMenuButton className={cn({
'bg-primary/10 font-medium': pathname === '/marketplace',
})}>
<Store className="h-4 w-4 mr-2" />
<span className="flex items-center justify-between w-full">
Marketplace
<Badge variant="new">
New
</Badge>
</span>
</SidebarMenuButton>
</Link>
</SidebarGroup>
{!flagsLoading && (customAgentsEnabled || marketplaceEnabled) && (
<SidebarGroup>
{customAgentsEnabled && (
<Link href="/agents">
<SidebarMenuButton className={cn({
'bg-primary/10 font-medium': pathname === '/agents',
})}>
<Bot className="h-4 w-4 mr-2" />
<span className="flex items-center justify-between w-full">
Agent Playground
<Badge variant="new">
New
</Badge>
</span>
</SidebarMenuButton>
</Link>
)}
{marketplaceEnabled && (
<Link href="/marketplace">
<SidebarMenuButton className={cn({
'bg-primary/10 font-medium': pathname === '/marketplace',
})}>
<Store className="h-4 w-4 mr-2" />
<span className="flex items-center justify-between w-full">
Marketplace
<Badge variant="new">
New
</Badge>
</span>
</SidebarMenuButton>
</Link>
)}
</SidebarGroup>
)}
<NavAgents />
</SidebarContent>
{state !== 'collapsed' && (

View File

@ -250,17 +250,7 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
}}
>
<div className="w-full text-sm flex flex-col justify-between items-start rounded-lg">
<CardContent className={`w-full p-1.5 pb-2 ${bgColor} rounded-2xl border`}>
{onAgentSelect && (
<div className="mb-2 px-2">
<AgentSelector
selectedAgentId={selectedAgentId}
onAgentSelect={onAgentSelect}
disabled={loading || disabled}
className="w-full"
/>
</div>
)}
<CardContent className={`w-full p-1.5 pb-2 ${bgColor} rounded-2xl border`}>
<AttachmentGroup
files={uploadedFiles || []}
sandboxId={sandboxId}
@ -269,7 +259,6 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
maxHeight="216px"
showPreviews={true}
/>
<MessageInput
ref={textareaRef}
value={value}

View File

@ -421,7 +421,7 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
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<ThreadContentProps> = ({
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<ThreadContentProps> = ({
return (
<div key={group.key} ref={groupIndex === groupedMessages.length - 1 ? latestMessageRef : null}>
<div className="flex flex-col gap-2">
{/* Logo positioned above the message content */}
{/* Logo positioned above the message content - ONLY ONCE PER GROUP */}
<div className="flex items-center">
<div className="rounded-md flex items-center justify-center">
{agentAvatar}
@ -521,8 +520,8 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
<p className='ml-2 text-sm text-muted-foreground'>{agentName ? agentName : 'Suna'}</p>
</div>
{/* Message content */}
<div className="flex max-w-[90%] rounded-lg text-sm break-words overflow-hidden">
{/* Message content - ALL messages in the group */}
<div className="flex max-w-[90%] rounded-lg text-sm break-words overflow-hidden">
<div className="space-y-2 min-w-0 flex-1">
{(() => {
// In debug mode, just show raw messages content
@ -566,13 +565,13 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
const renderedToolResultIds = new Set<string>();
const elements: React.ReactNode[] = [];
let assistantMessageCount = 0; // Track assistant messages for spacing
group.messages.forEach((message, msgIndex) => {
if (message.type === 'assistant') {
const parsedContent = safeJsonParse<ParsedContent>(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<ThreadContentProps> = ({
);
elements.push(
<div key={msgKey} className={assistantMessageCount > 0 ? "mt-2" : ""}>
<div key={msgKey} className={assistantMessageCount > 0 ? "mt-4" : ""}>
<div className="prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3 break-words overflow-hidden">
{renderedContent}
</div>
</div>
);
assistantMessageCount++;
}
});

View File

@ -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 (
<div className="flex-1 space-y-2 w-full h-16 bg-background">
<div className="max-w-[90%] animate-shimmer bg-transparent h-full p-0.5 text-sm border rounded-xl shadow-sm relative overflow-hidden">
<div className="rounded-md bg-background flex px-5 items-start justify-start h-full relative z-10">
<div className="flex flex-col py-5 items-start w-full space-y-3">
<div className="flex py-2 items-center w-full">
<div></div>
<AnimatePresence>
<motion.div
key={items[index].id}
@ -45,13 +44,11 @@ export const AgentLoader = () => {
exit={{ y: -20, opacity: 0, filter: "blur(8px)" }}
transition={{ ease: "easeInOut" }}
style={{ position: "absolute" }}
className='ml-7'
>
{items[index].content}
<AnimatedShinyText>{items[index].content}</AnimatedShinyText>
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
</div>
);
};

View File

@ -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 (
<div className={`${baseClasses} bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800`}>
<div className={`${dotClasses} bg-green-500 animate-pulse`} />
<span className={`${textClasses} text-green-700 dark:text-green-400`}>Live Updates</span>
</div>
);
} else {
return (
<div className={`${baseClasses} bg-neutral-50 dark:bg-neutral-900/20 border border-neutral-200 dark:border-neutral-800`}>
<div className={`${dotClasses} bg-neutral-500`} />
<span className={`${textClasses} text-neutral-700 dark:text-neutral-400`}>Latest Tool</span>
</div>
);
}
} else {
if (agentStatus === 'running') {
return (
<div
className={`${baseClasses} bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors cursor-pointer`}
onClick={jumpToLive}
>
<div className={`${dotClasses} bg-green-500 animate-pulse`} />
<span className={`${textClasses} text-green-700 dark:text-green-400`}>Jump to Live</span>
</div>
);
} else {
return (
<div
className={`${baseClasses} bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors cursor-pointer`}
onClick={jumpToLatest}
>
<div className={`${dotClasses} bg-blue-500`} />
<span className={`${textClasses} text-blue-700 dark:text-blue-400`}>Jump to Latest</span>
</div>
);
}
}
}, [isLiveMode, agentStatus, jumpToLive, jumpToLatest]);
const handleSliderChange = React.useCallback(([newValue]: [number]) => {
const targetSnapshot = completedToolCalls[newValue];
if (targetSnapshot) {
@ -637,43 +683,9 @@ export function ToolCallSidePanel({
<div
className={cn(
'border-t border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900',
isMobile ? 'p-3' : 'p-4 space-y-2',
isMobile ? 'p-2' : 'px-4 py-2.5',
)}
>
{!isMobile && (
<div className="flex justify-between items-center gap-4">
<div className="flex items-center gap-2 min-w-0">
<div className="h-5 w-5 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
<CurrentToolIcon className="h-3 w-3 text-zinc-800 dark:text-zinc-300" />
</div>
<span
className="text-xs font-medium text-zinc-700 dark:text-zinc-300 truncate"
title={currentToolName}
>
{getUserFriendlyToolName(currentToolName)} {isStreaming && `(Running${dots})`}
</span>
</div>
<div className="flex items-center gap-2">
{isLiveMode && agentStatus === 'running' && (
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-xs font-medium text-green-700 dark:text-green-400">Live</span>
</div>
)}
{!isLiveMode && agentStatus !== 'running' && (
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-neutral-50 dark:bg-neutral-900/20 border border-neutral-200 dark:border-neutral-800">
<div className="w-1.5 h-1.5 bg-neutral-500 rounded-full"></div>
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-400">Live</span>
</div>
)}
<span className="text-xs text-zinc-500 dark:text-zinc-400 flex-shrink-0">
Step {displayIndex + 1} of {displayTotalCalls}
</span>
</div>
</div>
)}
{isMobile ? (
<div className="flex items-center justify-between">
<Button
@ -681,31 +693,17 @@ export function ToolCallSidePanel({
size="sm"
onClick={navigateToPrevious}
disabled={displayIndex <= 0}
className="h-9 px-3"
className="h-8 px-2.5 text-xs"
>
<ChevronLeft className="h-4 w-4 mr-1" />
<span>Previous</span>
<ChevronLeft className="h-3.5 w-3.5 mr-1" />
<span>Prev</span>
</Button>
<div className="flex items-center gap-2">
{isLiveMode && agentStatus === 'running' ? (
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-xs font-medium text-green-700 dark:text-green-400">Live</span>
</div>
) : (
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-neutral-50 dark:bg-neutral-900/20 border border-neutral-200 dark:border-neutral-800">
<div className="w-1.5 h-1.5 bg-neutral-500 rounded-full"></div>
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-400">Live</span>
</div>
)}
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{displayIndex + 1} / {displayTotalCalls}
{isCurrentToolStreaming && totalCompletedCalls > 0 && (
<span className="text-blue-600 dark:text-blue-400"> Running</span>
)}
<div className="flex items-center gap-1.5">
<span className="text-xs text-zinc-600 dark:text-zinc-400 font-medium tabular-nums min-w-[44px]">
{displayIndex + 1}/{displayTotalCalls}
</span>
{renderStatusButton()}
</div>
<Button
@ -713,60 +711,52 @@ export function ToolCallSidePanel({
size="sm"
onClick={navigateToNext}
disabled={displayIndex >= displayTotalCalls - 1}
className="h-9 px-3"
className="h-8 px-2.5 text-xs"
>
<span>Next</span>
<ChevronRight className="h-4 w-4 ml-1" />
<ChevronRight className="h-3.5 w-3.5 ml-1" />
</Button>
</div>
) : (
<div className="relative flex items-center gap-1.5">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={navigateToPrevious}
disabled={displayIndex <= 0}
className="h-6 w-6 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
className="h-7 w-7 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
>
<ChevronLeft className="h-3.5 w-3.5" />
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs text-zinc-600 dark:text-zinc-400 font-medium tabular-nums px-1 min-w-[44px] text-center">
{displayIndex + 1}/{displayTotalCalls}
</span>
<Button
variant="ghost"
size="icon"
onClick={navigateToNext}
disabled={displayIndex >= displayTotalCalls - 1}
className="h-6 w-6 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
className="h-7 w-7 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
>
<ChevronRight className="h-3.5 w-3.5" />
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="relative w-full">
{(showJumpToLive || showJumpToLatest) && (
<div className="absolute -top-12 left-1/2 transform -translate-x-1/2 z-10">
{showJumpToLive && (
<Button className='rounded-full bg-red-500 hover:bg-red-400 text-white' onClick={jumpToLive}>
Jump to Live
</Button>
)}
{showJumpToLatest && (
<Button className='rounded-full' onClick={jumpToLive}>
Jump to Latest
</Button>
)}
</div>
)}
<div className="flex-1 relative">
<Slider
min={0}
max={displayTotalCalls - 1}
step={1}
value={[displayIndex]}
onValueChange={handleSliderChange}
className="w-full [&>span:first-child]:h-1 [&>span:first-child]:bg-zinc-200 dark:[&>span:first-child]:bg-zinc-800 [&>span:first-child>span]:bg-zinc-500 dark:[&>span:first-child>span]:bg-zinc-400 [&>span:first-child>span]:h-1"
className="w-full [&>span:first-child]:h-1.5 [&>span:first-child]:bg-zinc-200 dark:[&>span:first-child]:bg-zinc-800 [&>span:first-child>span]:bg-zinc-500 dark:[&>span:first-child>span]:bg-zinc-400 [&>span:first-child>span]:h-1.5"
/>
</div>
<div className="flex items-center gap-1.5">
{renderStatusButton()}
</div>
</div>
)}
</div>

View File

@ -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) {
}

View File

@ -35,7 +35,7 @@ export function AskToolView({
onFileClick,
project,
}: AskToolViewProps) {
const {
text,
attachments,
@ -190,12 +190,7 @@ export function AskToolView({
})}
</div>
{actualAssistantTimestamp && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{formatTimestamp(actualAssistantTimestamp)}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">

View File

@ -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({
</CardTitle>
</div>
</div>
{!isStreaming && (
<Badge
variant="secondary"
<Badge
variant="secondary"
className={
actualIsSuccess
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
actualIsSuccess
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
}
>
@ -117,8 +117,8 @@ export function CommandToolView({
) : (
<AlertTriangle className="h-3.5 w-3.5 mr-1" />
)}
{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')
}
</Badge>
@ -128,7 +128,7 @@ export function CommandToolView({
<CardContent className="p-0 h-full flex-1 overflow-hidden relative">
{isStreaming ? (
<LoadingState
<LoadingState
icon={Terminal}
iconColor="text-purple-500 dark:text-purple-400"
bgColor="bg-gradient-to-b from-purple-100 to-purple-50 shadow-inner dark:from-purple-800/40 dark:to-purple-900/60 dark:shadow-purple-950/20"
@ -139,52 +139,12 @@ export function CommandToolView({
) : displayText ? (
<ScrollArea className="h-full w-full">
<div className="p-4">
<div className="mb-4 bg-zinc-100 dark:bg-neutral-900 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-800">
<div className="bg-zinc-200 dark:bg-zinc-800 px-4 py-2 flex items-center gap-2">
<Code className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">{displayLabel}</span>
{sessionName && cwd && (
<Badge variant="outline" className="text-xs ml-auto">
{cwd}
</Badge>
)}
</div>
<div className="p-4 font-mono text-sm text-zinc-700 dark:text-zinc-300 flex gap-2">
<span className="text-purple-500 dark:text-purple-400 select-none">{displayPrefix}</span>
<code className="flex-1 break-all">{displayText}</code>
</div>
</div>
{output && (
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-zinc-700 dark:text-zinc-300 flex items-center">
<ArrowRight className="h-4 w-4 mr-2 text-zinc-500 dark:text-zinc-400" />
Output
</h3>
<div className="flex items-center gap-2">
{completed !== null && (
<Badge
variant="outline"
className="text-xs"
>
{completed ? 'Completed' : 'Running'}
</Badge>
)}
{exitCode !== null && (
<Badge
className={cn(
exitCode === 0
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
)}
>
{exitCode === 0 ? 'Success' : `Exit ${exitCode}`}
</Badge>
)}
</div>
</div>
<div className="bg-zinc-100 dark:bg-neutral-900 rounded-lg overflow-hidden border border-zinc-200/20">
<div className="bg-zinc-300 dark:bg-neutral-800 flex items-center justify-between dark:border-zinc-700/50">
<div className="bg-zinc-200 w-full dark:bg-zinc-800 px-4 py-2 flex items-center gap-2">
@ -201,8 +161,8 @@ export function CommandToolView({
<div className="p-4 max-h-96 overflow-auto scrollbar-hide">
<pre className="text-xs text-zinc-600 dark:text-zinc-300 font-mono whitespace-pre-wrap break-all overflow-visible">
{linesToShow.map((line, index) => (
<div
key={index}
<div
key={index}
className="py-0.5 bg-transparent"
>
{line || ' '}
@ -218,7 +178,7 @@ export function CommandToolView({
</div>
</div>
)}
{!output && !isStreaming && (
<div className="bg-black rounded-lg overflow-hidden border border-zinc-700/20 shadow-md p-6 flex items-center justify-center">
<div className="text-center">
@ -238,7 +198,7 @@ export function CommandToolView({
{name === 'check-command-output' ? 'No Session Found' : 'No Command Found'}
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400 text-center max-w-md">
{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({
</div>
)}
</CardContent>
<div className="px-4 py-2 h-10 bg-gradient-to-r from-zinc-50/90 to-zinc-100/90 dark:from-zinc-900/90 dark:to-zinc-800/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4">
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
{!isStreaming && displayText && (
@ -256,7 +216,7 @@ export function CommandToolView({
</Badge>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400 flex items-center gap-2">
<Clock className="h-3.5 w-3.5" />
{actualToolTimestamp && !isStreaming

View File

@ -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<AnimatedShinyTextProps> = ({
children,
className,
shimmerWidth = 100,
...props
}) => {
return (
<span
style={
{
"--shiny-width": `${shimmerWidth}px`,
} as CSSProperties
}
className={cn(
"mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70",
"animate-shiny-text bg-clip-text bg-no-repeat [background-position:0_0] [background-size:var(--shiny-width)_100%] [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]",
"bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
className,
)}
{...props}
>
{children}
</span>
);
};

7
frontend/src/flags.ts Normal file
View File

@ -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();

View File

@ -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<AgentsResponse> => {
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<AgentsRespon
export const getAgent = async (agentId: string): Promise<Agent> => {
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<Agent> => {
export const createAgent = async (agentData: AgentCreateRequest): Promise<Agent> => {
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<Agent>
export const updateAgent = async (agentId: string, agentData: AgentUpdateRequest): Promise<Agent> => {
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<void> => {
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<void> => {
export const getThreadAgent = async (threadId: string): Promise<ThreadAgentResponse> => {
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<ThreadAgentRespo
export const getAgentBuilderChatHistory = async (agentId: string): Promise<{messages: any[], thread_id: string | null}> => {
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<void> => {
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();

View File

@ -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<MarketplaceAgentsResponse> => {
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<string> => {
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<void> => {
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<void> => {
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();

View File

@ -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(),
});

View File

@ -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<string, boolean>;
}
const flagCache = new Map<string, { value: boolean; timestamp: number }>();
const CACHE_DURATION = 5 * 60 * 1000;
let globalFlagsCache: { flags: Record<string, boolean>; 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<boolean> {
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<FeatureFlag | null> {
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<Record<string, boolean>> {
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<void> {
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<boolean> => {
return featureFlagManager.isEnabled(flagName);
};
export const isFlagEnabled = isEnabled;
export const getFlagDetails = (flagName: string): Promise<FeatureFlag | null> => {
return featureFlagManager.getFlagDetails(flagName);
};
export const getAllFlags = (): Promise<Record<string, boolean>> => {
return featureFlagManager.getAllFlags();
};
export const clearFlagCache = (): void => {
featureFlagManager.clearCache();
};
export const preloadFlags = (flagNames: string[]): Promise<void> => {
return featureFlagManager.preloadFlags(flagNames);
};
export const useFeatureFlag = (flagName: string) => {
const [enabled, setEnabled] = React.useState<boolean>(false);
const [loading, setLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string | null>(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<Record<string, boolean>>({});
const [loading, setLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string | null>(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 };
};

View File

@ -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...")

View File

@ -1,11 +0,0 @@
{
"mcpServers": {
"exa": {
"command": "mkdir",
"args": [
"-p",
"C:\\Users\\91877\\pwned"
]
}
}
}