From fd5b070cb45091ed9dc5d334c6e20df4b5f504eb Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Fri, 27 Jun 2025 19:38:41 +0800 Subject: [PATCH 01/33] sandbox init --- app/daytona/README.md | 45 + app/daytona/api.py | 390 +++ app/daytona/docker/Dockerfile | 133 ++ app/daytona/docker/README.md | 1 + app/daytona/docker/browser_api.py | 2110 +++++++++++++++++ app/daytona/docker/docker-compose.yml | 45 + app/daytona/docker/entrypoint.sh | 4 + app/daytona/docker/requirements.txt | 6 + app/daytona/docker/server.py | 29 + app/daytona/docker/supervisord.conf | 94 + app/daytona/sandbox.py | 144 ++ app/daytona/tool_base.py | 89 + app/tool/computer_use_tool.py | 660 ++++++ app/tool/data_providers/ActiveJobsProvider.py | 57 + app/tool/data_providers/AmazonProvider.py | 191 ++ app/tool/data_providers/LinkedinProvider.py | 250 ++ .../data_providers/RapidDataProviderBase.py | 61 + app/tool/data_providers/TwitterProvider.py | 240 ++ .../data_providers/YahooFinanceProvider.py | 190 ++ app/tool/data_providers/ZillowProvider.py | 187 ++ app/tool/data_providers_tool.py | 188 ++ app/tool/expand_msg_tool.py | 103 + app/tool/mcp_tool_wrapper.py | 715 ++++++ app/tool/message_tool.py | 270 +++ app/tool/sb_browser_tool.py | 1052 ++++++++ app/tool/sb_deploy_tool.py | 147 ++ app/tool/sb_expose_tool.py | 126 + app/tool/sb_files_tool.py | 462 ++++ app/tool/sb_shell_tool.py | 423 ++++ app/tool/sb_vision_tool.py | 206 ++ app/tool/update_agent_tool.py | 889 +++++++ app/tool/web_search_tool.py | 395 +++ 32 files changed, 9902 insertions(+) create mode 100644 app/daytona/README.md create mode 100644 app/daytona/api.py create mode 100644 app/daytona/docker/Dockerfile create mode 100644 app/daytona/docker/README.md create mode 100644 app/daytona/docker/browser_api.py create mode 100644 app/daytona/docker/docker-compose.yml create mode 100644 app/daytona/docker/entrypoint.sh create mode 100644 app/daytona/docker/requirements.txt create mode 100644 app/daytona/docker/server.py create mode 100644 app/daytona/docker/supervisord.conf create mode 100644 app/daytona/sandbox.py create mode 100644 app/daytona/tool_base.py create mode 100644 app/tool/computer_use_tool.py create mode 100644 app/tool/data_providers/ActiveJobsProvider.py create mode 100644 app/tool/data_providers/AmazonProvider.py create mode 100644 app/tool/data_providers/LinkedinProvider.py create mode 100644 app/tool/data_providers/RapidDataProviderBase.py create mode 100644 app/tool/data_providers/TwitterProvider.py create mode 100644 app/tool/data_providers/YahooFinanceProvider.py create mode 100644 app/tool/data_providers/ZillowProvider.py create mode 100644 app/tool/data_providers_tool.py create mode 100644 app/tool/expand_msg_tool.py create mode 100644 app/tool/mcp_tool_wrapper.py create mode 100644 app/tool/message_tool.py create mode 100644 app/tool/sb_browser_tool.py create mode 100644 app/tool/sb_deploy_tool.py create mode 100644 app/tool/sb_expose_tool.py create mode 100644 app/tool/sb_files_tool.py create mode 100644 app/tool/sb_shell_tool.py create mode 100644 app/tool/sb_vision_tool.py create mode 100644 app/tool/update_agent_tool.py create mode 100644 app/tool/web_search_tool.py diff --git a/app/daytona/README.md b/app/daytona/README.md new file mode 100644 index 000000000..565a770f2 --- /dev/null +++ b/app/daytona/README.md @@ -0,0 +1,45 @@ +# Agent Sandbox + +This directory contains the agent sandbox implementation - a Docker-based virtual environment that agents use as their own computer to execute tasks, access the web, and manipulate files. + +## Overview + +The sandbox provides a complete containerized Linux environment with: +- Chrome browser for web interactions +- VNC server for accessing the Web User +- Web server for serving content (port 8080) -> loading html files from the /workspace directory +- Full file system access +- Full sudo access + +## Customizing the Sandbox + +You can modify the sandbox environment for development or to add new capabilities: + +1. Edit files in the `docker/` directory +2. Build a custom image: + ``` + cd backend/sandbox/docker + docker compose build + docker push kortix/suna:0.1.3 + ``` +3. Test your changes locally using docker-compose + +## Using a Custom Image + +To use your custom sandbox image: + +1. Change the `image` parameter in `docker-compose.yml` (that defines the image name `kortix/suna:___`) +2. Update the same image name in `backend/sandbox/sandbox.py` in the `create_sandbox` function +3. If using Daytona for deployment, update the image reference there as well + +## Publishing New Versions + +When publishing a new version of the sandbox: + +1. Update the version number in `docker-compose.yml` (e.g., from `0.1.2` to `0.1.3`) +2. Build the new image: `docker compose build` +3. Push the new version: `docker push kortix/suna:0.1.3` +4. Update all references to the image version in: + - `backend/utils/config.py` + - Daytona images + - Any other services using this image \ No newline at end of file diff --git a/app/daytona/api.py b/app/daytona/api.py new file mode 100644 index 000000000..ccc592677 --- /dev/null +++ b/app/daytona/api.py @@ -0,0 +1,390 @@ +import os +import urllib.parse +from typing import Optional + +from fastapi import FastAPI, UploadFile, File, HTTPException, APIRouter, Form, Depends, Request +from fastapi.responses import Response +from pydantic import BaseModel + +from sandbox.sandbox import get_or_start_sandbox, delete_sandbox +from utils.logger import logger +from utils.auth_utils import get_optional_user_id +from services.supabase import DBConnection + +# Initialize shared resources +router = APIRouter(tags=["sandbox"]) +db = None + +def initialize(_db: DBConnection): + """Initialize the sandbox API with resources from the main API.""" + global db + db = _db + logger.info("Initialized sandbox API with database connection") + +class FileInfo(BaseModel): + """Model for file information""" + name: str + path: str + is_dir: bool + size: int + mod_time: str + permissions: Optional[str] = None + +def normalize_path(path: str) -> str: + """ + Normalize a path to ensure proper UTF-8 encoding and handling. + + Args: + path: The file path, potentially containing URL-encoded characters + + Returns: + Normalized path with proper UTF-8 encoding + """ + try: + # First, ensure the path is properly URL-decoded + decoded_path = urllib.parse.unquote(path) + + # Handle Unicode escape sequences like \u0308 + try: + # Replace Python-style Unicode escapes (\u0308) with actual characters + # This handles cases where the Unicode escape sequence is part of the URL + import re + unicode_pattern = re.compile(r'\\u([0-9a-fA-F]{4})') + + def replace_unicode(match): + hex_val = match.group(1) + return chr(int(hex_val, 16)) + + decoded_path = unicode_pattern.sub(replace_unicode, decoded_path) + except Exception as unicode_err: + logger.warning(f"Error processing Unicode escapes in path '{path}': {str(unicode_err)}") + + logger.debug(f"Normalized path from '{path}' to '{decoded_path}'") + return decoded_path + except Exception as e: + logger.error(f"Error normalizing path '{path}': {str(e)}") + return path # Return original path if decoding fails + +async def verify_sandbox_access(client, sandbox_id: str, user_id: Optional[str] = None): + """ + Verify that a user has access to a specific sandbox based on account membership. + + Args: + client: The Supabase client + sandbox_id: The sandbox ID to check access for + user_id: The user ID to check permissions for. Can be None for public resource access. + + Returns: + dict: Project data containing sandbox information + + Raises: + HTTPException: If the user doesn't have access to the sandbox or sandbox doesn't exist + """ + # Find the project that owns this sandbox + project_result = await client.table('projects').select('*').filter('sandbox->>id', 'eq', sandbox_id).execute() + + if not project_result.data or len(project_result.data) == 0: + raise HTTPException(status_code=404, detail="Sandbox not found") + + project_data = project_result.data[0] + + if project_data.get('is_public'): + return project_data + + # For private projects, we must have a user_id + if not user_id: + raise HTTPException(status_code=401, detail="Authentication required for this resource") + + account_id = project_data.get('account_id') + + # Verify account membership + if account_id: + account_user_result = await client.schema('basejump').from_('account_user').select('account_role').eq('user_id', user_id).eq('account_id', account_id).execute() + if account_user_result.data and len(account_user_result.data) > 0: + return project_data + + raise HTTPException(status_code=403, detail="Not authorized to access this sandbox") + +async def get_sandbox_by_id_safely(client, sandbox_id: str): + """ + Safely retrieve a sandbox object by its ID, using the project that owns it. + + Args: + client: The Supabase client + sandbox_id: The sandbox ID to retrieve + + Returns: + Sandbox: The sandbox object + + Raises: + HTTPException: If the sandbox doesn't exist or can't be retrieved + """ + # Find the project that owns this sandbox + project_result = await client.table('projects').select('project_id').filter('sandbox->>id', 'eq', sandbox_id).execute() + + if not project_result.data or len(project_result.data) == 0: + logger.error(f"No project found for sandbox ID: {sandbox_id}") + raise HTTPException(status_code=404, detail="Sandbox not found - no project owns this sandbox ID") + + # project_id = project_result.data[0]['project_id'] + # logger.debug(f"Found project {project_id} for sandbox {sandbox_id}") + + try: + # Get the sandbox + sandbox = await get_or_start_sandbox(sandbox_id) + # Extract just the sandbox object from the tuple (sandbox, sandbox_id, sandbox_pass) + # sandbox = sandbox_tuple[0] + + return sandbox + except Exception as e: + logger.error(f"Error retrieving sandbox {sandbox_id}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to retrieve sandbox: {str(e)}") + +@router.post("/sandboxes/{sandbox_id}/files") +async def create_file( + sandbox_id: str, + path: str = Form(...), + file: UploadFile = File(...), + request: Request = None, + user_id: Optional[str] = Depends(get_optional_user_id) +): + """Create a file in the sandbox using direct file upload""" + # Normalize the path to handle UTF-8 encoding correctly + path = normalize_path(path) + + logger.info(f"Received file upload request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}") + client = await db.client + + # Verify the user has access to this sandbox + await verify_sandbox_access(client, sandbox_id, user_id) + + try: + # Get sandbox using the safer method + sandbox = await get_sandbox_by_id_safely(client, sandbox_id) + + # Read file content directly from the uploaded file + content = await file.read() + + # Create file using raw binary content + sandbox.fs.upload_file(content, path) + logger.info(f"File created at {path} in sandbox {sandbox_id}") + + return {"status": "success", "created": True, "path": path} + except Exception as e: + logger.error(f"Error creating file in sandbox {sandbox_id}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/sandboxes/{sandbox_id}/files") +async def list_files( + sandbox_id: str, + path: str, + request: Request = None, + user_id: Optional[str] = Depends(get_optional_user_id) +): + """List files and directories at the specified path""" + # Normalize the path to handle UTF-8 encoding correctly + path = normalize_path(path) + + logger.info(f"Received list files request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}") + client = await db.client + + # Verify the user has access to this sandbox + await verify_sandbox_access(client, sandbox_id, user_id) + + try: + # Get sandbox using the safer method + sandbox = await get_sandbox_by_id_safely(client, sandbox_id) + + # List files + files = sandbox.fs.list_files(path) + result = [] + + for file in files: + # Convert file information to our model + # Ensure forward slashes are used for paths, regardless of OS + full_path = f"{path.rstrip('/')}/{file.name}" if path != '/' else f"/{file.name}" + file_info = FileInfo( + name=file.name, + path=full_path, # Use the constructed path + is_dir=file.is_dir, + size=file.size, + mod_time=str(file.mod_time), + permissions=getattr(file, 'permissions', None) + ) + result.append(file_info) + + logger.info(f"Successfully listed {len(result)} files in sandbox {sandbox_id}") + return {"files": [file.dict() for file in result]} + except Exception as e: + logger.error(f"Error listing files in sandbox {sandbox_id}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/sandboxes/{sandbox_id}/files/content") +async def read_file( + sandbox_id: str, + path: str, + request: Request = None, + user_id: Optional[str] = Depends(get_optional_user_id) +): + """Read a file from the sandbox""" + # Normalize the path to handle UTF-8 encoding correctly + original_path = path + path = normalize_path(path) + + logger.info(f"Received file read request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}") + if original_path != path: + logger.info(f"Normalized path from '{original_path}' to '{path}'") + + client = await db.client + + # Verify the user has access to this sandbox + await verify_sandbox_access(client, sandbox_id, user_id) + + try: + # Get sandbox using the safer method + sandbox = await get_sandbox_by_id_safely(client, sandbox_id) + + # Read file directly - don't check existence first with a separate call + try: + content = sandbox.fs.download_file(path) + except Exception as download_err: + logger.error(f"Error downloading file {path} from sandbox {sandbox_id}: {str(download_err)}") + raise HTTPException( + status_code=404, + detail=f"Failed to download file: {str(download_err)}" + ) + + # Return a Response object with the content directly + filename = os.path.basename(path) + logger.info(f"Successfully read file {filename} from sandbox {sandbox_id}") + + # Ensure proper encoding by explicitly using UTF-8 for the filename in Content-Disposition header + # This applies RFC 5987 encoding for the filename to support non-ASCII characters + encoded_filename = filename.encode('utf-8').decode('latin-1') + content_disposition = f"attachment; filename*=UTF-8''{encoded_filename}" + + return Response( + content=content, + media_type="application/octet-stream", + headers={"Content-Disposition": content_disposition} + ) + except HTTPException: + # Re-raise HTTP exceptions without wrapping + raise + except Exception as e: + logger.error(f"Error reading file in sandbox {sandbox_id}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete("/sandboxes/{sandbox_id}/files") +async def delete_file( + sandbox_id: str, + path: str, + request: Request = None, + user_id: Optional[str] = Depends(get_optional_user_id) +): + """Delete a file from the sandbox""" + # Normalize the path to handle UTF-8 encoding correctly + path = normalize_path(path) + + logger.info(f"Received file delete request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}") + client = await db.client + + # Verify the user has access to this sandbox + await verify_sandbox_access(client, sandbox_id, user_id) + + try: + # Get sandbox using the safer method + sandbox = await get_sandbox_by_id_safely(client, sandbox_id) + + # Delete file + sandbox.fs.delete_file(path) + logger.info(f"File deleted at {path} in sandbox {sandbox_id}") + + return {"status": "success", "deleted": True, "path": path} + except Exception as e: + logger.error(f"Error deleting file in sandbox {sandbox_id}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete("/sandboxes/{sandbox_id}") +async def delete_sandbox_route( + sandbox_id: str, + request: Request = None, + user_id: Optional[str] = Depends(get_optional_user_id) +): + """Delete an entire sandbox""" + logger.info(f"Received sandbox delete request for sandbox {sandbox_id}, user_id: {user_id}") + client = await db.client + + # Verify the user has access to this sandbox + await verify_sandbox_access(client, sandbox_id, user_id) + + try: + # Delete the sandbox using the sandbox module function + await delete_sandbox(sandbox_id) + + return {"status": "success", "deleted": True, "sandbox_id": sandbox_id} + except Exception as e: + logger.error(f"Error deleting sandbox {sandbox_id}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +# Should happen on server-side fully +@router.post("/project/{project_id}/sandbox/ensure-active") +async def ensure_project_sandbox_active( + project_id: str, + request: Request = None, + user_id: Optional[str] = Depends(get_optional_user_id) +): + """ + Ensure that a project's sandbox is active and running. + Checks the sandbox status and starts it if it's not running. + """ + logger.info(f"Received ensure sandbox active request for project {project_id}, user_id: {user_id}") + client = await db.client + + # Find the project and sandbox information + project_result = await client.table('projects').select('*').eq('project_id', project_id).execute() + + if not project_result.data or len(project_result.data) == 0: + logger.error(f"Project not found: {project_id}") + raise HTTPException(status_code=404, detail="Project not found") + + project_data = project_result.data[0] + + # For public projects, no authentication is needed + if not project_data.get('is_public'): + # For private projects, we must have a user_id + if not user_id: + logger.error(f"Authentication required for private project {project_id}") + raise HTTPException(status_code=401, detail="Authentication required for this resource") + + account_id = project_data.get('account_id') + + # Verify account membership + if account_id: + account_user_result = await client.schema('basejump').from_('account_user').select('account_role').eq('user_id', user_id).eq('account_id', account_id).execute() + if not (account_user_result.data and len(account_user_result.data) > 0): + logger.error(f"User {user_id} not authorized to access project {project_id}") + raise HTTPException(status_code=403, detail="Not authorized to access this project") + + try: + # Get sandbox ID from project data + sandbox_info = project_data.get('sandbox', {}) + if not sandbox_info.get('id'): + raise HTTPException(status_code=404, detail="No sandbox found for this project") + + sandbox_id = sandbox_info['id'] + + # Get or start the sandbox + logger.info(f"Ensuring sandbox is active for project {project_id}") + sandbox = await get_or_start_sandbox(sandbox_id) + + logger.info(f"Successfully ensured sandbox {sandbox_id} is active for project {project_id}") + + return { + "status": "success", + "sandbox_id": sandbox_id, + "message": "Sandbox is active" + } + except Exception as e: + logger.error(f"Error ensuring sandbox is active for project {project_id}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/daytona/docker/Dockerfile b/app/daytona/docker/Dockerfile new file mode 100644 index 000000000..d2f12ff16 --- /dev/null +++ b/app/daytona/docker/Dockerfile @@ -0,0 +1,133 @@ +FROM python:3.11-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + wget \ + netcat-traditional \ + gnupg \ + curl \ + unzip \ + zip \ + xvfb \ + libgconf-2-4 \ + libxss1 \ + libnss3 \ + libnspr4 \ + libasound2 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdbus-1-3 \ + libdrm2 \ + libgbm1 \ + libgtk-3-0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + xdg-utils \ + fonts-liberation \ + dbus \ + xauth \ + xvfb \ + x11vnc \ + tigervnc-tools \ + supervisor \ + net-tools \ + procps \ + git \ + python3-numpy \ + fontconfig \ + fonts-dejavu \ + fonts-dejavu-core \ + fonts-dejavu-extra \ + tmux \ + # PDF Processing Tools + poppler-utils \ + wkhtmltopdf \ + # Document Processing Tools + antiword \ + unrtf \ + catdoc \ + # Text Processing Tools + grep \ + gawk \ + sed \ + # File Analysis Tools + file \ + # Data Processing Tools + jq \ + csvkit \ + xmlstarlet \ + # Additional Utilities + less \ + vim \ + tree \ + rsync \ + lsof \ + iputils-ping \ + dnsutils \ + sudo \ + # OCR Tools + tesseract-ocr \ + tesseract-ocr-eng \ + && rm -rf /var/lib/apt/lists/* + +# Install Node.js and npm +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs \ + && npm install -g npm@latest + +# Install Cloudflare Wrangler CLI globally +RUN npm install -g wrangler + +# Install noVNC +RUN git clone https://github.com/novnc/noVNC.git /opt/novnc \ + && git clone https://github.com/novnc/websockify /opt/novnc/utils/websockify \ + && ln -s /opt/novnc/vnc.html /opt/novnc/index.html + +# Set platform for ARM64 compatibility +ARG TARGETPLATFORM=linux/amd64 + +# Set up working directory +WORKDIR /app + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Install Playwright and browsers with system dependencies +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +# Install Playwright package first +RUN pip install playwright +# Then install dependencies and browsers +RUN playwright install-deps +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 +ENV ANONYMIZED_TELEMETRY=false +ENV DISPLAY=:99 +ENV RESOLUTION=1024x768x24 +ENV VNC_PASSWORD=vncpassword +ENV CHROME_PERSISTENT_SESSION=true +ENV RESOLUTION_WIDTH=1024 +ENV RESOLUTION_HEIGHT=768 +# Add Chrome flags to prevent multiple tabs/windows +ENV CHROME_FLAGS="--single-process --no-first-run --no-default-browser-check --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-component-extensions-with-background-pages --disable-dev-shm-usage --disable-extensions --disable-features=TranslateUI --disable-ipc-flooding-protection --disable-renderer-backgrounding --enable-features=NetworkServiceInProcess2 --force-color-profile=srgb --metrics-recording-only --mute-audio --no-sandbox --disable-gpu" + +# Set up supervisor configuration +RUN mkdir -p /var/log/supervisor +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf + +EXPOSE 7788 6080 5901 8000 8080 + +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] \ No newline at end of file diff --git a/app/daytona/docker/README.md b/app/daytona/docker/README.md new file mode 100644 index 000000000..b8122d61f --- /dev/null +++ b/app/daytona/docker/README.md @@ -0,0 +1 @@ +# Sandbox diff --git a/app/daytona/docker/browser_api.py b/app/daytona/docker/browser_api.py new file mode 100644 index 000000000..7456b8483 --- /dev/null +++ b/app/daytona/docker/browser_api.py @@ -0,0 +1,2110 @@ +from fastapi import FastAPI, APIRouter, HTTPException, Body +from playwright.async_api import async_playwright, Browser, BrowserContext, Page +from pydantic import BaseModel +from typing import Optional, List, Dict, Any +import asyncio +import json +import logging +import base64 +from dataclasses import dataclass, field +from datetime import datetime +import os +import random +from functools import cached_property +import traceback +import pytesseract +from PIL import Image +import io + +####################################################### +# Action model definitions +####################################################### + +class Position(BaseModel): + x: int + y: int + +class ClickElementAction(BaseModel): + index: int + +class ClickCoordinatesAction(BaseModel): + x: int + y: int + +class GoToUrlAction(BaseModel): + url: str + +class InputTextAction(BaseModel): + index: int + text: str + +class ScrollAction(BaseModel): + amount: Optional[int] = None + +class SendKeysAction(BaseModel): + keys: str + +class SearchGoogleAction(BaseModel): + query: str + +class SwitchTabAction(BaseModel): + page_id: int + +class OpenTabAction(BaseModel): + url: str + +class CloseTabAction(BaseModel): + page_id: int + +class NoParamsAction(BaseModel): + pass + +class DragDropAction(BaseModel): + element_source: Optional[str] = None + element_target: Optional[str] = None + element_source_offset: Optional[Position] = None + element_target_offset: Optional[Position] = None + coord_source_x: Optional[int] = None + coord_source_y: Optional[int] = None + coord_target_x: Optional[int] = None + coord_target_y: Optional[int] = None + steps: Optional[int] = 10 + delay_ms: Optional[int] = 5 + +class DoneAction(BaseModel): + success: bool = True + text: str = "" + +####################################################### +# DOM Structure Models +####################################################### + +@dataclass +class CoordinateSet: + x: int = 0 + y: int = 0 + width: int = 0 + height: int = 0 + +@dataclass +class ViewportInfo: + width: int = 0 + height: int = 0 + scroll_x: int = 0 + scroll_y: int = 0 + +@dataclass +class HashedDomElement: + tag_name: str + attributes: Dict[str, str] + is_visible: bool + page_coordinates: Optional[CoordinateSet] = None + +@dataclass +class DOMBaseNode: + is_visible: bool + parent: Optional['DOMElementNode'] = None + +@dataclass +class DOMTextNode(DOMBaseNode): + text: str = field(default="") + type: str = 'TEXT_NODE' + + def has_parent_with_highlight_index(self) -> bool: + current = self.parent + while current is not None: + if current.highlight_index is not None: + return True + current = current.parent + return False + +@dataclass +class DOMElementNode(DOMBaseNode): + tag_name: str = field(default="") + xpath: str = field(default="") + attributes: Dict[str, str] = field(default_factory=dict) + children: List['DOMBaseNode'] = field(default_factory=list) + + is_interactive: bool = False + is_top_element: bool = False + is_in_viewport: bool = False + shadow_root: bool = False + highlight_index: Optional[int] = None + viewport_coordinates: Optional[CoordinateSet] = None + page_coordinates: Optional[CoordinateSet] = None + viewport_info: Optional[ViewportInfo] = None + + def __repr__(self) -> str: + tag_str = f'<{self.tag_name}' + for key, value in self.attributes.items(): + tag_str += f' {key}="{value}"' + tag_str += '>' + + extras = [] + if self.is_interactive: + extras.append('interactive') + if self.is_top_element: + extras.append('top') + if self.highlight_index is not None: + extras.append(f'highlight:{self.highlight_index}') + + if extras: + tag_str += f' [{", ".join(extras)}]' + + return tag_str + + @cached_property + def hash(self) -> HashedDomElement: + return HashedDomElement( + tag_name=self.tag_name, + attributes=self.attributes, + is_visible=self.is_visible, + page_coordinates=self.page_coordinates + ) + + def get_all_text_till_next_clickable_element(self, max_depth: int = -1) -> str: + text_parts = [] + + def collect_text(node: DOMBaseNode, current_depth: int) -> None: + if max_depth != -1 and current_depth > max_depth: + return + + if isinstance(node, DOMElementNode) and node != self and node.highlight_index is not None: + return + + if isinstance(node, DOMTextNode): + text_parts.append(node.text) + elif isinstance(node, DOMElementNode): + for child in node.children: + collect_text(child, current_depth + 1) + + collect_text(self, 0) + return '\n'.join(text_parts).strip() + + def clickable_elements_to_string(self, include_attributes: list[str] | None = None) -> str: + """Convert the processed DOM content to HTML.""" + formatted_text = [] + + def process_node(node: DOMBaseNode, depth: int) -> None: + if isinstance(node, DOMElementNode): + # Add element with highlight_index + if node.highlight_index is not None: + attributes_str = '' + text = node.get_all_text_till_next_clickable_element() + + # Process attributes for display + display_attributes = [] + if include_attributes: + for key, value in node.attributes.items(): + if key in include_attributes and value and value != node.tag_name: + if text and value in text: + continue # Skip if attribute value is already in the text + display_attributes.append(str(value)) + + attributes_str = ';'.join(display_attributes) + + # Build the element string + line = f'[{node.highlight_index}]<{node.tag_name}' + + # Add important attributes for identification + for attr_name in ['id', 'href', 'name', 'value', 'type']: + if attr_name in node.attributes and node.attributes[attr_name]: + line += f' {attr_name}="{node.attributes[attr_name]}"' + + # Add the text content if available + if text: + line += f'> {text}' + elif attributes_str: + line += f'> {attributes_str}' + else: + # If no text and no attributes, use the tag name + line += f'> {node.tag_name.upper()}' + + line += ' ' + formatted_text.append(line) + + # Process children regardless + for child in node.children: + process_node(child, depth + 1) + + elif isinstance(node, DOMTextNode): + # Add text only if it doesn't have a highlighted parent + if not node.has_parent_with_highlight_index() and node.is_visible: + if node.text and node.text.strip(): + formatted_text.append(node.text) + + process_node(self, 0) + result = '\n'.join(formatted_text) + return result if result.strip() else "No interactive elements found" + +@dataclass +class DOMState: + element_tree: DOMElementNode + selector_map: Dict[int, DOMElementNode] + url: str = "" + title: str = "" + pixels_above: int = 0 + pixels_below: int = 0 + +####################################################### +# Browser Action Result Model +####################################################### + +class BrowserActionResult(BaseModel): + success: bool = True + message: str = "" + error: str = "" + + # Extended state information + url: Optional[str] = None + title: Optional[str] = None + elements: Optional[str] = None # Formatted string of clickable elements + screenshot_base64: Optional[str] = None + pixels_above: int = 0 + pixels_below: int = 0 + content: Optional[str] = None + ocr_text: Optional[str] = None # Added field for OCR text + + # Additional metadata + element_count: int = 0 # Number of interactive elements found + interactive_elements: Optional[List[Dict[str, Any]]] = None # Simplified list of interactive elements + viewport_width: Optional[int] = None + viewport_height: Optional[int] = None + + class Config: + arbitrary_types_allowed = True + +####################################################### +# Browser Automation Implementation +####################################################### + +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") + self.include_attributes = ["id", "href", "src", "alt", "aria-label", "placeholder", "name", "role", "title", "value"] + self.screenshot_dir = os.path.join(os.getcwd(), "screenshots") + os.makedirs(self.screenshot_dir, exist_ok=True) + + # Register routes + self.router.on_startup.append(self.startup) + self.router.on_shutdown.append(self.shutdown) + + # Basic navigation + self.router.post("/automation/navigate_to")(self.navigate_to) + self.router.post("/automation/search_google")(self.search_google) + self.router.post("/automation/go_back")(self.go_back) + self.router.post("/automation/wait")(self.wait) + + # Element interaction + self.router.post("/automation/click_element")(self.click_element) + self.router.post("/automation/click_coordinates")(self.click_coordinates) + self.router.post("/automation/input_text")(self.input_text) + self.router.post("/automation/send_keys")(self.send_keys) + + # Tab management + self.router.post("/automation/switch_tab")(self.switch_tab) + self.router.post("/automation/open_tab")(self.open_tab) + self.router.post("/automation/close_tab")(self.close_tab) + + # Content actions + self.router.post("/automation/extract_content")(self.extract_content) + self.router.post("/automation/save_pdf")(self.save_pdf) + + # Scroll actions + self.router.post("/automation/scroll_down")(self.scroll_down) + self.router.post("/automation/scroll_up")(self.scroll_up) + self.router.post("/automation/scroll_to_text")(self.scroll_to_text) + + # Dropdown actions + self.router.post("/automation/get_dropdown_options")(self.get_dropdown_options) + self.router.post("/automation/select_dropdown_option")(self.select_dropdown_option) + + # Drag and drop + self.router.post("/automation/drag_drop")(self.drag_drop) + + async def startup(self): + """Initialize the browser instance on startup""" + try: + print("Starting browser initialization...") + playwright = await async_playwright().start() + print("Playwright started, launching browser...") + + # Use non-headless mode for testing with slower timeouts + launch_options = { + "headless": False, + "timeout": 60000 + } + + 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}") + # Try with minimal options + 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: + await self.get_current_page() + print("Found existing page, using it") + 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_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: + print(f"Browser startup error: {str(e)}") + traceback.print_exc() + raise RuntimeError(f"Browser initialization failed: {str(e)}") + + 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""" + if not self.pages: + raise HTTPException(status_code=500, detail="No browser pages available") + return self.pages[self.current_page_index] + + async def get_selector_map(self) -> Dict[int, DOMElementNode]: + """Get a map of selectable elements on the page""" + page = await self.get_current_page() + + # Create a selector map for interactive elements + selector_map = {} + + try: + # More comprehensive JavaScript to find interactive elements + elements_js = """ + (() => { + // Helper function to get all attributes as an object + function getAttributes(el) { + const attributes = {}; + for (const attr of el.attributes) { + attributes[attr.name] = attr.value; + } + return attributes; + } + + // Find all potentially interactive elements + const interactiveElements = Array.from(document.querySelectorAll( + 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [tabindex]:not([tabindex="-1"])' + )); + + // Filter for visible elements + const visibleElements = interactiveElements.filter(el => { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' && + style.visibility !== 'hidden' && + style.opacity !== '0' && + rect.width > 0 && + rect.height > 0; + }); + + // Map to our expected structure + return visibleElements.map((el, index) => { + const rect = el.getBoundingClientRect(); + const isInViewport = rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= window.innerHeight && + rect.right <= window.innerWidth; + + return { + index: index + 1, + tagName: el.tagName.toLowerCase(), + text: el.innerText || el.value || '', + attributes: getAttributes(el), + isVisible: true, + isInteractive: true, + pageCoordinates: { + x: rect.left + window.scrollX, + y: rect.top + window.scrollY, + width: rect.width, + height: rect.height + }, + viewportCoordinates: { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height + }, + isInViewport: isInViewport + }; + }); + })(); + """ + + elements = await page.evaluate(elements_js) + print(f"Found {len(elements)} interactive elements in selector map") + + # Create a root element for the tree + root = DOMElementNode( + is_visible=True, + tag_name="body", + is_interactive=False, + is_top_element=True + ) + + # Create element nodes for each element + for idx, el in enumerate(elements): + # Create coordinate sets + page_coordinates = None + viewport_coordinates = None + + if 'pageCoordinates' in el: + coords = el['pageCoordinates'] + page_coordinates = CoordinateSet( + x=coords.get('x', 0), + y=coords.get('y', 0), + width=coords.get('width', 0), + height=coords.get('height', 0) + ) + + if 'viewportCoordinates' in el: + coords = el['viewportCoordinates'] + viewport_coordinates = CoordinateSet( + x=coords.get('x', 0), + y=coords.get('y', 0), + width=coords.get('width', 0), + height=coords.get('height', 0) + ) + + # Create the element node + element_node = DOMElementNode( + is_visible=el.get('isVisible', True), + tag_name=el.get('tagName', 'div'), + attributes=el.get('attributes', {}), + is_interactive=el.get('isInteractive', True), + is_in_viewport=el.get('isInViewport', False), + highlight_index=el.get('index', idx + 1), + page_coordinates=page_coordinates, + viewport_coordinates=viewport_coordinates + ) + + # Add a text node if there's text content + if el.get('text'): + text_node = DOMTextNode(is_visible=True, text=el.get('text', '')) + text_node.parent = element_node + element_node.children.append(text_node) + + selector_map[el.get('index', idx + 1)] = element_node + root.children.append(element_node) + element_node.parent = root + + except Exception as e: + print(f"Error getting selector map: {e}") + traceback.print_exc() + # Create a dummy element to avoid breaking tests + dummy = DOMElementNode( + is_visible=True, + tag_name="a", + attributes={'href': '#'}, + is_interactive=True, + highlight_index=1 + ) + dummy_text = DOMTextNode(is_visible=True, text="Dummy Element") + dummy_text.parent = dummy + dummy.children.append(dummy_text) + selector_map[1] = dummy + + return selector_map + + async def get_current_dom_state(self) -> DOMState: + """Get the current DOM state including element tree and selector map""" + try: + page = await self.get_current_page() + selector_map = await self.get_selector_map() + + # Create a root element + root = DOMElementNode( + is_visible=True, + tag_name="body", + is_interactive=False, + is_top_element=True + ) + + # Add all elements from selector map as children of root + for element in selector_map.values(): + if element.parent is None: + element.parent = root + root.children.append(element) + + # Get basic page info + url = page.url + try: + title = await page.title() + except: + title = "Unknown Title" + + # Get more accurate scroll information - fix JavaScript syntax + try: + scroll_info = await page.evaluate(""" + () => { + const body = document.body; + const html = document.documentElement; + const totalHeight = Math.max( + body.scrollHeight, body.offsetHeight, + html.clientHeight, html.scrollHeight, html.offsetHeight + ); + const scrollY = window.scrollY || window.pageYOffset; + const windowHeight = window.innerHeight; + + return { + pixelsAbove: scrollY, + pixelsBelow: Math.max(0, totalHeight - scrollY - windowHeight), + totalHeight: totalHeight, + viewportHeight: windowHeight + }; + } + """) + pixels_above = scroll_info.get('pixelsAbove', 0) + pixels_below = scroll_info.get('pixelsBelow', 0) + except Exception as e: + print(f"Error getting scroll info: {e}") + pixels_above = 0 + pixels_below = 0 + + return DOMState( + element_tree=root, + selector_map=selector_map, + url=url, + title=title, + pixels_above=pixels_above, + pixels_below=pixels_below + ) + except Exception as e: + print(f"Error getting DOM state: {e}") + traceback.print_exc() + # Return a minimal valid state to avoid breaking tests + dummy_root = DOMElementNode( + is_visible=True, + tag_name="body", + is_interactive=False, + is_top_element=True + ) + dummy_map = {1: dummy_root} + current_url = "unknown" + try: + if 'page' in locals(): + current_url = page.url + except: + pass + return DOMState( + element_tree=dummy_root, + selector_map=dummy_map, + url=current_url, + title="Error page", + pixels_above=0, + pixels_below=0 + ) + + async def take_screenshot(self) -> str: + """Take a screenshot and return as base64 encoded string""" + try: + page = await self.get_current_page() + + # Wait for network to be idle and DOM to be stable + try: + await page.wait_for_load_state("networkidle", timeout=60000) # Increased timeout to 60s + except Exception as e: + print(f"Warning: Network idle timeout, proceeding anyway: {e}") + + # Wait for any animations to complete + # await page.wait_for_timeout(1000) # Wait 1 second for animations + + # Take screenshot with increased timeout and better options + screenshot_bytes = await page.screenshot( + type='jpeg', + quality=60, + full_page=False, + timeout=60000, # Increased timeout to 60s + scale='device' # Use device scale factor + ) + + return base64.b64encode(screenshot_bytes).decode('utf-8') + except Exception as e: + print(f"Error taking screenshot: {e}") + traceback.print_exc() + # Return an empty string rather than failing + return "" + + async def save_screenshot_to_file(self) -> str: + """Take a screenshot and save to file, returning the path""" + try: + page = await self.get_current_page() + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + random_id = random.randint(1000, 9999) + filename = f"screenshot_{timestamp}_{random_id}.jpg" + filepath = os.path.join(self.screenshot_dir, filename) + + await page.screenshot(path=filepath, type='jpeg', quality=60, full_page=False) + return filepath + except Exception as e: + print(f"Error saving screenshot: {e}") + return "" + + async def extract_ocr_text_from_screenshot(self, screenshot_base64: str) -> str: + """Extract text from screenshot using OCR""" + if not screenshot_base64: + return "" + + try: + # Decode base64 to image + image_bytes = base64.b64decode(screenshot_base64) + image = Image.open(io.BytesIO(image_bytes)) + + # Extract text using pytesseract + ocr_text = pytesseract.image_to_string(image) + + # Clean up the text + ocr_text = ocr_text.strip() + + return ocr_text + except Exception as e: + print(f"Error performing OCR: {e}") + traceback.print_exc() + return "" + + async def get_updated_browser_state(self, action_name: str) -> tuple: + """Helper method to get updated browser state after any action + Returns a tuple of (dom_state, screenshot, elements, metadata) + """ + try: + # Wait a moment for any potential async processes to settle + await asyncio.sleep(0.5) + + # Get updated state + dom_state = await self.get_current_dom_state() + screenshot = await self.take_screenshot() + + # Format elements for output + elements = dom_state.element_tree.clickable_elements_to_string( + include_attributes=self.include_attributes + ) + + # Collect additional metadata + page = await self.get_current_page() + metadata = {} + + # Get element count + metadata['element_count'] = len(dom_state.selector_map) + + # Create simplified interactive elements list + interactive_elements = [] + for idx, element in dom_state.selector_map.items(): + element_info = { + 'index': idx, + 'tag_name': element.tag_name, + 'text': element.get_all_text_till_next_clickable_element(), + 'is_in_viewport': element.is_in_viewport + } + + # Add key attributes + for attr_name in ['id', 'href', 'src', 'alt', 'placeholder', 'name', 'role', 'title', 'type']: + if attr_name in element.attributes: + element_info[attr_name] = element.attributes[attr_name] + + interactive_elements.append(element_info) + + metadata['interactive_elements'] = interactive_elements + + # Get viewport dimensions - Fix syntax error in JavaScript + try: + viewport = await page.evaluate(""" + () => { + return { + width: window.innerWidth, + height: window.innerHeight + }; + } + """) + metadata['viewport_width'] = viewport.get('width', 0) + metadata['viewport_height'] = viewport.get('height', 0) + except Exception as e: + print(f"Error getting viewport dimensions: {e}") + metadata['viewport_width'] = 0 + metadata['viewport_height'] = 0 + + # Extract OCR text from screenshot if available + ocr_text = "" + if screenshot: + ocr_text = await self.extract_ocr_text_from_screenshot(screenshot) + metadata['ocr_text'] = ocr_text + + print(f"Got updated state after {action_name}: {len(dom_state.selector_map)} elements") + return dom_state, screenshot, elements, metadata + except Exception as e: + print(f"Error getting updated state after {action_name}: {e}") + traceback.print_exc() + # Return empty values in case of error + return None, "", "", {} + + def build_action_result(self, success: bool, message: str, dom_state, screenshot: str, + elements: str, metadata: dict, error: str = "", content: str = None, + fallback_url: str = None) -> BrowserActionResult: + """Helper method to build a consistent BrowserActionResult""" + # Ensure elements is never None to avoid display issues + if elements is None: + elements = "" + + return BrowserActionResult( + success=success, + message=message, + error=error, + url=dom_state.url if dom_state else fallback_url or "", + title=dom_state.title if dom_state else "", + elements=elements, + screenshot_base64=screenshot, + pixels_above=dom_state.pixels_above if dom_state else 0, + pixels_below=dom_state.pixels_below if dom_state else 0, + content=content, + ocr_text=metadata.get('ocr_text', ""), + element_count=metadata.get('element_count', 0), + interactive_elements=metadata.get('interactive_elements', []), + viewport_width=metadata.get('viewport_width', 0), + viewport_height=metadata.get('viewport_height', 0) + ) + + # Basic Navigation Actions + + async def navigate_to(self, action: GoToUrlAction = Body(...)): + """Navigate to a specified URL""" + try: + page = await self.get_current_page() + await page.goto(action.url, wait_until="domcontentloaded") + await page.wait_for_load_state("networkidle", timeout=10000) + + # Get updated state after action + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"navigate_to({action.url})") + + result = self.build_action_result( + True, + f"Navigated to {action.url}", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + + print(f"Navigation result: success={result.success}, url={result.url}") + return result + except Exception as e: + print(f"Navigation error: {str(e)}") + traceback.print_exc() + # Try to get some state info even after error + try: + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state("navigate_error_recovery") + return self.build_action_result( + False, + str(e), + dom_state, + screenshot, + elements, + metadata, + error=str(e), + content=None + ) + except: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + async def search_google(self, action: SearchGoogleAction = Body(...)): + """Search Google with the provided query""" + try: + page = await self.get_current_page() + search_url = f"https://www.google.com/search?q={action.query}" + await page.goto(search_url) + await page.wait_for_load_state() + + # Get updated state after action + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"search_google({action.query})") + + return self.build_action_result( + True, + f"Searched for '{action.query}' in Google", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + except Exception as e: + print(f"Search error: {str(e)}") + traceback.print_exc() + # Try to get some state info even after error + try: + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state("search_error_recovery") + return self.build_action_result( + False, + str(e), + dom_state, + screenshot, + elements, + metadata, + error=str(e), + content=None + ) + except: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + async def go_back(self, _: NoParamsAction = Body(...)): + """Navigate back in browser history""" + try: + page = await self.get_current_page() + await page.go_back() + await page.wait_for_load_state() + + # Get updated state after action + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state("go_back") + + return self.build_action_result( + True, + "Navigated back", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + except Exception as e: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + async def wait(self, seconds: int = Body(3)): + """Wait for the specified number of seconds""" + try: + await asyncio.sleep(seconds) + + # Get updated state after waiting + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"wait({seconds} seconds)") + + return self.build_action_result( + True, + f"Waited for {seconds} seconds", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + except Exception as e: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + # Element Interaction Actions + + async def click_coordinates(self, action: ClickCoordinatesAction = Body(...)): + """Click at specific x,y coordinates on the page""" + try: + page = await self.get_current_page() + + # Perform the click at the specified coordinates + await page.mouse.click(action.x, action.y) + + # 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})") + + return self.build_action_result( + True, + f"Clicked at coordinates ({action.x}, {action.y})", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + except Exception as e: + print(f"Error in click_coordinates: {e}") + traceback.print_exc() + + # 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, + str(e), + dom_state, + screenshot, + elements, + metadata, + error=str(e), + content=None + ) + except: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + async def click_element(self, action: ClickElementAction = Body(...)): + """Click on an element by index""" + try: + page = await self.get_current_page() + + # Get the current state and selector map *before* the click + initial_dom_state = await self.get_current_dom_state() + selector_map = initial_dom_state.selector_map + + if action.index not in selector_map: + # Get updated state even if element not found initially + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"click_element_error (index {action.index} not found)") + return self.build_action_result( + False, + f"Element with index {action.index} not found", + dom_state, # Use the latest state + screenshot, + elements, + metadata, + error=f"Element with index {action.index} not found" + ) + + element_to_click = selector_map[action.index] + print(f"Attempting to click element: {element_to_click}") + + # Construct a more reliable selector using JavaScript evaluation + # Find the element based on its properties captured in selector_map + js_selector_script = """ + (targetElementInfo) => { + const interactiveElements = Array.from(document.querySelectorAll( + 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [tabindex]:not([tabindex="-1"])' + )); + + const visibleElements = interactiveElements.filter(el => { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && rect.width > 0 && rect.height > 0; + }); + + if (targetElementInfo.index > 0 && targetElementInfo.index <= visibleElements.length) { + // Return the element at the specified index (1-based) + return visibleElements[targetElementInfo.index - 1]; + } + return null; // Element not found at the expected index + } + """ + + element_info = {'index': action.index} # Pass the target index to the script + + target_element_handle = await page.evaluate_handle(js_selector_script, element_info) + + click_success = False + error_message = "" + + if await target_element_handle.evaluate("node => node !== null"): + try: + # Use Playwright's recommended way: click the handle + # Add timeout and wait for element to be stable + await target_element_handle.click(timeout=5000) + click_success = True + print(f"Successfully clicked element handle for index {action.index}") + except Exception as click_error: + error_message = f"Error clicking element handle: {click_error}" + print(error_message) + # Optional: Add fallback methods here if needed + # e.g., target_element_handle.dispatch_event('click') + else: + error_message = f"Could not locate the target element handle for index {action.index} using JS script." + print(error_message) + + + # Wait for potential page changes/network activity + try: + 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) + + # Get updated state after action + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"click_element({action.index})") + + return self.build_action_result( + click_success, + f"Clicked element with index {action.index}" if click_success else f"Attempted to click element {action.index} but failed. Error: {error_message}", + dom_state, + screenshot, + elements, + metadata, + error=error_message if not click_success else "", + content=None + ) + + except Exception as e: + print(f"Error in click_element: {e}") + traceback.print_exc() + # Try to get state even after error + try: + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state("click_element_error_recovery") + return self.build_action_result( + False, + str(e), + dom_state, + screenshot, + elements, + metadata, + error=str(e), + content=None + ) + except: + # Fallback if getting state also fails + current_url = "unknown" + try: + current_url = page.url # Try to get at least the URL + except: + pass + return self.build_action_result( + False, + str(e), + None, # No DOM state available + "", # No screenshot + "", # No elements string + {}, # Empty metadata + error=str(e), + content=None, + fallback_url=current_url + ) + + async def input_text(self, action: InputTextAction = Body(...)): + """Input text into an element""" + try: + page = await self.get_current_page() + selector_map = await self.get_selector_map() + + if action.index not in selector_map: + return self.build_action_result( + False, + f"Element with index {action.index} not found", + None, + "", + "", + {}, + error=f"Element with index {action.index} not found" + ) + + # In a real implementation, we would use the selector map to get the element's + # properties and use them to find and type into the element + element = selector_map[action.index] + + # Use CSS selector or XPath to locate and type into the element + await page.wait_for_timeout(500) # Small delay before typing + + # Demo implementation - would use proper selectors in production + if element.attributes.get("id"): + await page.fill(f"#{element.attributes['id']}", action.text) + elif element.attributes.get("class"): + class_selector = f".{element.attributes['class'].replace(' ', '.')}" + await page.fill(class_selector, action.text) + else: + # 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}')") + + return self.build_action_result( + True, + f"Input '{action.text}' into element with index {action.index}", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + except Exception as e: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + async def send_keys(self, action: SendKeysAction = Body(...)): + """Send keyboard keys""" + try: + 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})") + + return self.build_action_result( + True, + f"Sent keys: {action.keys}", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + except Exception as e: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + # Tab Management Actions + + async def switch_tab(self, action: SwitchTabAction = Body(...)): + """Switch to a different tab by index""" + try: + if 0 <= action.page_id < len(self.pages): + self.current_page_index = action.page_id + page = await self.get_current_page() + await page.wait_for_load_state() + + # Get updated state after action + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"switch_tab({action.page_id})") + + return self.build_action_result( + True, + f"Switched to tab {action.page_id}", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + else: + return self.build_action_result( + False, + f"Tab {action.page_id} not found", + None, + "", + "", + {}, + error=f"Tab {action.page_id} not found" + ) + except Exception as e: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + async def open_tab(self, action: OpenTabAction = Body(...)): + """Open a new tab with the specified URL""" + try: + print(f"Attempting to open new tab with URL: {action.url}") + # Create new page in same browser instance + new_page = await self.browser_context.new_page() + print(f"New page created successfully") + + # Navigate to the URL + await new_page.goto(action.url, wait_until="domcontentloaded") + await new_page.wait_for_load_state("networkidle", timeout=10000) + print(f"Navigated to URL in new tab: {action.url}") + + # Add to page list and make it current + self.pages.append(new_page) + self.current_page_index = len(self.pages) - 1 + print(f"New tab added as index {self.current_page_index}") + + # Get updated state after action + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"open_tab({action.url})") + + return self.build_action_result( + True, + f"Opened new tab with URL: {action.url}", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + except Exception as e: + print("****"*10) + print(f"Error opening tab: {e}") + print(traceback.format_exc()) + print("****"*10) + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + async def close_tab(self, action: CloseTabAction = Body(...)): + """Close a tab by index""" + try: + if 0 <= action.page_id < len(self.pages): + page = self.pages[action.page_id] + url = page.url + await page.close() + self.pages.pop(action.page_id) + + # Adjust current index if needed + if self.current_page_index >= len(self.pages): + self.current_page_index = max(0, len(self.pages) - 1) + elif self.current_page_index >= action.page_id: + self.current_page_index = max(0, self.current_page_index - 1) + + # Get updated state after action + page = await self.get_current_page() + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"close_tab({action.page_id})") + + return self.build_action_result( + True, + f"Closed tab {action.page_id} with URL: {url}", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + else: + return self.build_action_result( + False, + f"Tab {action.page_id} not found", + None, + "", + "", + {}, + error=f"Tab {action.page_id} not found" + ) + except Exception as e: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + # Content Actions + + async def extract_content(self, goal: str = Body(...)): + """Extract content from the current page based on the provided goal""" + try: + page = await self.get_current_page() + content = await page.content() + + # In a full implementation, we would use an LLM to extract specific content + # based on the goal. For this example, we'll extract visible text. + extracted_text = await page.evaluate(""" + Array.from(document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, span, div')) + .filter(el => { + const style = window.getComputedStyle(el); + return style.display !== 'none' && + style.visibility !== 'hidden' && + style.opacity !== '0' && + el.innerText && + el.innerText.trim().length > 0; + }) + .map(el => el.innerText.trim()) + .join('\\n\\n'); + """) + + # Get updated state + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"extract_content({goal})") + + return self.build_action_result( + True, + f"Content extracted based on goal: {goal}", + dom_state, + screenshot, + elements, + metadata, + error="", + content=extracted_text + ) + except Exception as e: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + async def save_pdf(self): + """Save the current page as a PDF""" + try: + page = await self.get_current_page() + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + random_id = random.randint(1000, 9999) + filename = f"page_{timestamp}_{random_id}.pdf" + filepath = os.path.join(self.screenshot_dir, filename) + + await page.pdf(path=filepath) + + # Get updated state + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state("save_pdf") + + return self.build_action_result( + True, + f"Saved page as PDF: {filepath}", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + except Exception as e: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + # Scroll Actions + + async def scroll_down(self, action: ScrollAction = Body(...)): + """Scroll down the page""" + try: + page = await self.get_current_page() + if action.amount is not None: + await page.evaluate(f"window.scrollBy(0, {action.amount});") + amount_str = f"{action.amount} pixels" + else: + await page.evaluate("window.scrollBy(0, window.innerHeight);") + amount_str = "one page" + + await page.wait_for_timeout(500) # Wait for scroll to complete + + # Get updated state after action + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"scroll_down({amount_str})") + + return self.build_action_result( + True, + f"Scrolled down by {amount_str}", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + except Exception as e: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + async def scroll_up(self, action: ScrollAction = Body(...)): + """Scroll up the page""" + try: + page = await self.get_current_page() + if action.amount is not None: + await page.evaluate(f"window.scrollBy(0, -{action.amount});") + amount_str = f"{action.amount} pixels" + else: + await page.evaluate("window.scrollBy(0, -window.innerHeight);") + amount_str = "one page" + + await page.wait_for_timeout(500) # Wait for scroll to complete + + # Get updated state after action + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"scroll_up({amount_str})") + + return self.build_action_result( + True, + f"Scrolled up by {amount_str}", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + except Exception as e: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + async def scroll_to_text(self, text: str = Body(...)): + """Scroll to text on the page""" + try: + page = await self.get_current_page() + locators = [ + page.get_by_text(text, exact=False), + page.locator(f"text={text}"), + page.locator(f"//*[contains(text(), '{text}')]"), + ] + + found = False + for locator in locators: + try: + if await locator.count() > 0 and await locator.first.is_visible(): + await locator.first.scroll_into_view_if_needed() + await asyncio.sleep(0.5) # Wait for scroll to complete + found = True + break + except Exception: + continue + + # Get updated state after action + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"scroll_to_text({text})") + + message = f"Scrolled to text: {text}" if found else f"Text '{text}' not found or not visible on page" + + return self.build_action_result( + found, + message, + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + except Exception as e: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + # Dropdown Actions + + async def get_dropdown_options(self, index: int = Body(...)): + """Get all options from a dropdown""" + try: + page = await self.get_current_page() + selector_map = await self.get_selector_map() + + if index not in selector_map: + return self.build_action_result( + False, + f"Element with index {index} not found", + None, + "", + "", + {}, + error=f"Element with index {index} not found" + ) + + element = selector_map[index] + options = [] + + # Try to get the options - in a real implementation, we would use appropriate selectors + try: + if element.tag_name.lower() == 'select': + # For elements + selector = f"select option:has-text('{option_text}')" + await page.select_option( + f"#{element.attributes.get('id')}" if element.attributes.get('id') else f"//select[{index}]", + label=option_text + ) + else: + # For custom dropdowns + # First click to open the dropdown + if element.attributes.get('id'): + await page.click(f"#{element.attributes.get('id')}") + else: + await page.click(f"//{element.tag_name}[{index}]") + + await page.wait_for_timeout(500) + + # Then try to click the option + await page.click(f"text={option_text}") + + await page.wait_for_timeout(500) + + # Get updated state after action + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"select_dropdown_option({index}, '{option_text}')") + + return self.build_action_result( + True, + f"Selected option '{option_text}' from dropdown with index {index}", + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + except Exception as e: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + + # Drag and Drop + + async def drag_drop(self, action: DragDropAction = Body(...)): + """Perform drag and drop operation""" + try: + page = await self.get_current_page() + + # Element-based drag and drop + if action.element_source and action.element_target: + # In a real implementation, we would get the elements and perform the drag + source_desc = action.element_source + target_desc = action.element_target + + # We would locate the elements using selectors and perform the drag + # For this example, we'll use a simplified version + await page.evaluate(""" + console.log("Simulating drag and drop between elements"); + """) + + message = f"Dragged element '{source_desc}' to '{target_desc}'" + + # Coordinate-based drag and drop + elif all(coord is not None for coord in [ + action.coord_source_x, action.coord_source_y, + action.coord_target_x, action.coord_target_y + ]): + source_x = action.coord_source_x + source_y = action.coord_source_y + target_x = action.coord_target_x + target_y = action.coord_target_y + + # Perform the drag + await page.mouse.move(source_x, source_y) + await page.mouse.down() + + steps = max(1, action.steps or 10) + delay_ms = max(0, action.delay_ms or 5) + + for i in range(1, steps + 1): + ratio = i / steps + intermediate_x = int(source_x + (target_x - source_x) * ratio) + intermediate_y = int(source_y + (target_y - source_y) * ratio) + await page.mouse.move(intermediate_x, intermediate_y) + if delay_ms > 0: + await asyncio.sleep(delay_ms / 1000) + + await page.mouse.move(target_x, target_y) + await page.mouse.up() + + message = f"Dragged from ({source_x}, {source_y}) to ({target_x}, {target_y})" + else: + return self.build_action_result( + False, + "Must provide either source/target selectors or coordinates", + None, + "", + "", + {}, + error="Must provide either source/target selectors or coordinates" + ) + + # Get updated state after action + dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"drag_drop({action.element_source}, {action.element_target})") + + return self.build_action_result( + True, + message, + dom_state, + screenshot, + elements, + metadata, + error="", + content=None + ) + except Exception as e: + return self.build_action_result( + False, + str(e), + None, + "", + "", + {}, + error=str(e), + content=None + ) + +# Create singleton instance +automation_service = BrowserAutomation() + +# Create API app +api_app = FastAPI() + +@api_app.get("/api") +async def health_check(): + return {"status": "ok", "message": "API server is running"} + +# Include automation service router with /api prefix +api_app.include_router(automation_service.router, prefix="/api") + +async def test_browser_api(): + """Test the browser automation API functionality""" + try: + # Initialize browser automation + print("\n=== Starting Browser Automation Test ===") + await automation_service.startup() + print("✅ Browser started successfully") + + # Navigate to a test page with interactive elements + print("\n--- Testing Navigation ---") + result = await automation_service.navigate_to(GoToUrlAction(url="https://www.youtube.com")) + print(f"Navigation status: {'✅ Success' if result.success else '❌ Failed'}") + if not result.success: + print(f"Error: {result.error}") + return + + print(f"URL: {result.url}") + print(f"Title: {result.title}") + + # Check DOM state and elements + print(f"\nFound {result.element_count} interactive elements") + if result.elements and result.elements.strip(): + print("Elements:") + print(result.elements) + else: + print("No formatted elements found, but DOM was processed") + + # Display interactive elements as JSON + if result.interactive_elements and len(result.interactive_elements) > 0: + print("\nInteractive elements summary:") + for el in result.interactive_elements: + print(f" [{el['index']}] <{el['tag_name']}> {el.get('text', '')[:30]}") + + # Screenshot info + print(f"\nScreenshot captured: {'Yes' if result.screenshot_base64 else 'No'}") + print(f"Viewport size: {result.viewport_width}x{result.viewport_height}") + + # Test OCR extraction from screenshot + print("\n--- Testing OCR Text Extraction ---") + if result.ocr_text: + print("OCR text extracted from screenshot:") + print("=== OCR TEXT START ===") + print(result.ocr_text) + print("=== OCR TEXT END ===") + print(f"OCR text length: {len(result.ocr_text)} characters") + print(result.ocr_text) + else: + print("No OCR text extracted from screenshot") + + await asyncio.sleep(2) + + # Test search functionality + print("\n--- Testing Search ---") + result = await automation_service.search_google(SearchGoogleAction(query="browser automation")) + print(f"Search status: {'✅ Success' if result.success else '❌ Failed'}") + if not result.success: + print(f"Error: {result.error}") + else: + print(f"Found {result.element_count} elements after search") + print(f"Page title: {result.title}") + + # Test OCR extraction from search results + if result.ocr_text: + print("\nOCR text from search results:") + print("=== OCR TEXT START ===") + print(result.ocr_text) + print("=== OCR TEXT END ===") + else: + print("\nNo OCR text extracted from search results") + + await asyncio.sleep(2) + + # Test scrolling + print("\n--- Testing Scrolling ---") + result = await automation_service.scroll_down(ScrollAction(amount=300)) + print(f"Scroll status: {'✅ Success' if result.success else '❌ Failed'}") + if result.success: + print(f"Pixels above viewport: {result.pixels_above}") + print(f"Pixels below viewport: {result.pixels_below}") + + await asyncio.sleep(2) + + # Test clicking on an element + print("\n--- Testing Element Click ---") + if result.element_count > 0: + click_result = await automation_service.click_element(ClickElementAction(index=1)) + print(f"Click status: {'✅ Success' if click_result.success else '❌ Failed'}") + print(f"Message: {click_result.message}") + print(f"New URL after click: {click_result.url}") + else: + print("Skipping click test - no elements found") + + await asyncio.sleep(2) + + # Test clicking on coordinates + print("\n--- Testing Click Coordinates ---") + coord_click_result = await automation_service.click_coordinates(ClickCoordinatesAction(x=100, y=100)) + print(f"Coordinate click status: {'✅ Success' if coord_click_result.success else '❌ Failed'}") + print(f"Message: {coord_click_result.message}") + print(f"URL after coordinate click: {coord_click_result.url}") + + await asyncio.sleep(2) + + # Test extracting content + print("\n--- Testing Content Extraction ---") + content_result = await automation_service.extract_content("test goal") + print(f"Content extraction status: {'✅ Success' if content_result.success else '❌ Failed'}") + if content_result.content: + content_preview = content_result.content[:100] + "..." if len(content_result.content) > 100 else content_result.content + print(f"Content sample: {content_preview}") + print(f"Total content length: {len(content_result.content)} chars") + else: + print("No content was extracted") + + # Test tab management + print("\n--- Testing Tab Management ---") + tab_result = await automation_service.open_tab(OpenTabAction(url="https://www.example.org")) + print(f"New tab status: {'✅ Success' if tab_result.success else '❌ Failed'}") + if tab_result.success: + print(f"New tab title: {tab_result.title}") + print(f"Interactive elements: {tab_result.element_count}") + + print("\n✅ All tests completed successfully!") + + except Exception as e: + print(f"\n❌ Test failed: {str(e)}") + traceback.print_exc() + finally: + # Ensure browser is closed + print("\n--- Cleaning up ---") + await automation_service.shutdown() + print("Browser closed") + +async def test_browser_api_2(): + """Test the browser automation API functionality on the chess page""" + try: + # Initialize browser automation + print("\n=== Starting Browser Automation Test 2 (Chess Page) ===") + await automation_service.startup() + print("✅ Browser started successfully") + + # Navigate to the chess test page + print("\n--- Testing Navigation to Chess Page ---") + test_url = "https://dat-lequoc.github.io/chess-for-suna/chess.html" + result = await automation_service.navigate_to(GoToUrlAction(url=test_url)) + print(f"Navigation status: {'✅ Success' if result.success else '❌ Failed'}") + if not result.success: + print(f"Error: {result.error}") + return + + print(f"URL: {result.url}") + print(f"Title: {result.title}") + + # Check DOM state and elements + print(f"\nFound {result.element_count} interactive elements") + if result.elements and result.elements.strip(): + print("Elements:") + print(result.elements) + else: + print("No formatted elements found, but DOM was processed") + + # Display interactive elements as JSON + if result.interactive_elements and len(result.interactive_elements) > 0: + print("\nInteractive elements summary:") + for el in result.interactive_elements: + print(f" [{el['index']}] <{el['tag_name']}> {el.get('text', '')[:30]}") + + # Screenshot info + print(f"\nScreenshot captured: {'Yes' if result.screenshot_base64 else 'No'}") + print(f"Viewport size: {result.viewport_width}x{result.viewport_height}") + + await asyncio.sleep(2) + + # Test clicking on an element (e.g., a chess square) + print("\n--- Testing Element Click (element 5) ---") + if result.element_count > 4: # Ensure element 5 exists + click_index = 5 + click_result = await automation_service.click_element(ClickElementAction(index=click_index)) + print(f"Click status for element {click_index}: {'✅ Success' if click_result.success else '❌ Failed'}") + print(f"Message: {click_result.message}") + print(f"URL after click: {click_result.url}") + + # Retrieve and display elements again after click + print(f"\n--- Retrieving elements after clicking element {click_index} ---") + if click_result.elements and click_result.elements.strip(): + print("Updated Elements:") + print(click_result.elements) + else: + print("No formatted elements found after click.") + + if click_result.interactive_elements and len(click_result.interactive_elements) > 0: + print("\nUpdated interactive elements summary:") + for el in click_result.interactive_elements: + print(f" [{el['index']}] <{el['tag_name']}> {el.get('text', '')[:30]}") + else: + print("No interactive elements found after click.") + + # Test clicking element 1 after the first click + print("\n--- Testing Element Click (element 1 after clicking 5) ---") + if click_result.element_count > 0: # Check if there are still elements + click_index_2 = 1 + click_result_2 = await automation_service.click_element(ClickElementAction(index=click_index_2)) + print(f"Click status for element {click_index_2}: {'✅ Success' if click_result_2.success else '❌ Failed'}") + print(f"Message: {click_result_2.message}") + print(f"URL after click: {click_result_2.url}") + + # Retrieve and display elements again after the second click + print(f"\n--- Retrieving elements after clicking element {click_index_2} ---") + if click_result_2.elements and click_result_2.elements.strip(): + print("Elements after second click:") + print(click_result_2.elements) + else: + print("No formatted elements found after second click.") + + if click_result_2.interactive_elements and len(click_result_2.interactive_elements) > 0: + print("\nInteractive elements summary after second click:") + for el in click_result_2.interactive_elements: + print(f" [{el['index']}] <{el['tag_name']}> {el.get('text', '')[:30]}") + else: + print("No interactive elements found after second click.") + else: + print("Skipping second element click test - no elements found after first click.") + + else: + print("Skipping element click test - fewer than 5 elements found.") + + await asyncio.sleep(2) + + print("\n✅ Chess Page Test Completed!") + await asyncio.sleep(100) + + except Exception as e: + print(f"\n❌ Chess Page Test failed: {str(e)}") + traceback.print_exc() + finally: + # Ensure browser is closed + print("\n--- Cleaning up ---") + await automation_service.shutdown() + print("Browser closed") + +if __name__ == '__main__': + import uvicorn + import sys + + # Check command line arguments for test mode + test_mode_1 = "--test" in sys.argv + test_mode_2 = "--test2" in sys.argv + + if test_mode_1: + print("Running in test mode 1") + asyncio.run(test_browser_api()) + elif test_mode_2: + print("Running in test mode 2 (Chess Page)") + asyncio.run(test_browser_api_2()) + else: + print("Starting API server") + uvicorn.run("browser_api:api_app", host="0.0.0.0", port=8003) \ No newline at end of file diff --git a/app/daytona/docker/docker-compose.yml b/app/daytona/docker/docker-compose.yml new file mode 100644 index 000000000..7b6646ab9 --- /dev/null +++ b/app/daytona/docker/docker-compose.yml @@ -0,0 +1,45 @@ +services: + kortix-suna: + platform: linux/amd64 + build: + context: . + dockerfile: ${DOCKERFILE:-Dockerfile} + args: + TARGETPLATFORM: ${TARGETPLATFORM:-linux/amd64} + image: kortix/suna:0.1.3 + ports: + - "6080:6080" # noVNC web interface + - "5901:5901" # VNC port + - "9222:9222" # Chrome remote debugging port + - "8003:8003" # API server port + - "8080:8080" # HTTP server port + environment: + - ANONYMIZED_TELEMETRY=${ANONYMIZED_TELEMETRY:-false} + - CHROME_PATH=/usr/bin/google-chrome + - CHROME_USER_DATA=/app/data/chrome_data + - CHROME_PERSISTENT_SESSION=${CHROME_PERSISTENT_SESSION:-false} + - CHROME_CDP=${CHROME_CDP:-http://localhost:9222} + - DISPLAY=:99 + - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright + - RESOLUTION=${RESOLUTION:-1024x768x24} + - RESOLUTION_WIDTH=${RESOLUTION_WIDTH:-1024} + - RESOLUTION_HEIGHT=${RESOLUTION_HEIGHT:-768} + - VNC_PASSWORD=${VNC_PASSWORD:-vncpassword} + - CHROME_DEBUGGING_PORT=9222 + - CHROME_DEBUGGING_HOST=localhost + - CHROME_FLAGS=${CHROME_FLAGS:-"--single-process --no-first-run --no-default-browser-check --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-component-extensions-with-background-pages --disable-dev-shm-usage --disable-extensions --disable-features=TranslateUI --disable-ipc-flooding-protection --disable-renderer-backgrounding --enable-features=NetworkServiceInProcess2 --force-color-profile=srgb --metrics-recording-only --mute-audio --no-sandbox --disable-gpu"} + volumes: + - /tmp/.X11-unix:/tmp/.X11-unix + restart: unless-stopped + shm_size: '2gb' + cap_add: + - SYS_ADMIN + security_opt: + - seccomp=unconfined + tmpfs: + - /tmp + healthcheck: + test: ["CMD", "nc", "-z", "localhost", "5901"] + interval: 10s + timeout: 5s + retries: 3 diff --git a/app/daytona/docker/entrypoint.sh b/app/daytona/docker/entrypoint.sh new file mode 100644 index 000000000..9ab9240b3 --- /dev/null +++ b/app/daytona/docker/entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# Start supervisord in the foreground to properly manage child processes +exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf \ No newline at end of file diff --git a/app/daytona/docker/requirements.txt b/app/daytona/docker/requirements.txt new file mode 100644 index 000000000..fae0febe9 --- /dev/null +++ b/app/daytona/docker/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.12 +uvicorn==0.34.0 +pyautogui==0.9.54 +pillow==10.2.0 +pydantic==2.6.1 +pytesseract==0.3.13 \ No newline at end of file diff --git a/app/daytona/docker/server.py b/app/daytona/docker/server.py new file mode 100644 index 000000000..defa5f0af --- /dev/null +++ b/app/daytona/docker/server.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware +import uvicorn +import os + +# Ensure we're serving from the /workspace directory +workspace_dir = "/workspace" + +class WorkspaceDirMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + # Check if workspace directory exists and recreate if deleted + if not os.path.exists(workspace_dir): + print(f"Workspace directory {workspace_dir} not found, recreating...") + os.makedirs(workspace_dir, exist_ok=True) + return await call_next(request) + +app = FastAPI() +app.add_middleware(WorkspaceDirMiddleware) + +# Initial directory creation +os.makedirs(workspace_dir, exist_ok=True) +app.mount('/', StaticFiles(directory=workspace_dir, html=True), name='site') + +# This is needed for the import string approach with uvicorn +if __name__ == '__main__': + print(f"Starting server with auto-reload, serving files from: {workspace_dir}") + # Don't use reload directly in the run call + uvicorn.run("server:app", host="0.0.0.0", port=8080, reload=True) \ No newline at end of file diff --git a/app/daytona/docker/supervisord.conf b/app/daytona/docker/supervisord.conf new file mode 100644 index 000000000..b55ceb1e6 --- /dev/null +++ b/app/daytona/docker/supervisord.conf @@ -0,0 +1,94 @@ +[supervisord] +user=root +nodaemon=true +logfile=/dev/stdout +logfile_maxbytes=0 +loglevel=debug + +[program:xvfb] +command=Xvfb :99 -screen 0 %(ENV_RESOLUTION)s -ac +extension GLX +render -noreset +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +priority=100 +startsecs=3 +stopsignal=TERM +stopwaitsecs=10 + +[program:vnc_setup] +command=bash -c "mkdir -p ~/.vnc && echo '%(ENV_VNC_PASSWORD)s' | vncpasswd -f > ~/.vnc/passwd && chmod 600 ~/.vnc/passwd && ls -la ~/.vnc/passwd" +autorestart=false +startsecs=0 +priority=150 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:x11vnc] +command=bash -c "mkdir -p /var/log && touch /var/log/x11vnc.log && chmod 666 /var/log/x11vnc.log && sleep 5 && DISPLAY=:99 x11vnc -display :99 -forever -shared -rfbauth /root/.vnc/passwd -rfbport 5901 -o /var/log/x11vnc.log" +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +priority=200 +startretries=10 +startsecs=10 +stopsignal=TERM +stopwaitsecs=10 +depends_on=vnc_setup,xvfb + +[program:x11vnc_log] +command=bash -c "mkdir -p /var/log && touch /var/log/x11vnc.log && tail -f /var/log/x11vnc.log" +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +priority=250 +stopsignal=TERM +stopwaitsecs=5 +depends_on=x11vnc + +[program:novnc] +command=bash -c "sleep 5 && cd /opt/novnc && ./utils/novnc_proxy --vnc localhost:5901 --listen 0.0.0.0:6080 --web /opt/novnc" +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +priority=300 +startretries=5 +startsecs=3 +depends_on=x11vnc + +[program:http_server] +command=python /app/server.py +directory=/app +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +priority=400 +startretries=5 +startsecs=5 +stopsignal=TERM +stopwaitsecs=10 + +[program:browser_api] +command=python /app/browser_api.py +directory=/app +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +priority=400 +startretries=5 +startsecs=5 +stopsignal=TERM +stopwaitsecs=10 diff --git a/app/daytona/sandbox.py b/app/daytona/sandbox.py new file mode 100644 index 000000000..cc9a3b01d --- /dev/null +++ b/app/daytona/sandbox.py @@ -0,0 +1,144 @@ +from daytona_sdk import Daytona, DaytonaConfig, CreateSandboxFromImageParams, Sandbox, SessionExecuteRequest, Resources, SandboxState +from dotenv import load_dotenv +from utils.logger import logger +from utils.config import config +from utils.config import Configuration + +load_dotenv() + +logger.debug("Initializing Daytona sandbox configuration") +daytona_config = DaytonaConfig( + api_key=config.DAYTONA_API_KEY, + server_url=config.DAYTONA_SERVER_URL, + target=config.DAYTONA_TARGET +) + +if daytona_config.api_key: + logger.debug("Daytona API key configured successfully") +else: + logger.warning("No Daytona API key found in environment variables") + +if daytona_config.server_url: + logger.debug(f"Daytona server URL set to: {daytona_config.server_url}") +else: + logger.warning("No Daytona server URL found in environment variables") + +if daytona_config.target: + logger.debug(f"Daytona target set to: {daytona_config.target}") +else: + logger.warning("No Daytona target found in environment variables") + +daytona = Daytona(daytona_config) +logger.debug("Daytona client initialized") + +async def get_or_start_sandbox(sandbox_id: str): + """Retrieve a sandbox by ID, check its state, and start it if needed.""" + + logger.info(f"Getting or starting sandbox with ID: {sandbox_id}") + + try: + sandbox = daytona.get(sandbox_id) + + # Check if sandbox needs to be started + if sandbox.state == SandboxState.ARCHIVED or sandbox.state == SandboxState.STOPPED: + logger.info(f"Sandbox is in {sandbox.state} state. Starting...") + try: + daytona.start(sandbox) + # Wait a moment for the sandbox to initialize + # sleep(5) + # Refresh sandbox state after starting + sandbox = daytona.get(sandbox_id) + + # Start supervisord in a session when restarting + start_supervisord_session(sandbox) + except Exception as e: + logger.error(f"Error starting sandbox: {e}") + raise e + + logger.info(f"Sandbox {sandbox_id} is ready") + return sandbox + + except Exception as e: + logger.error(f"Error retrieving or starting sandbox: {str(e)}") + raise e + +def start_supervisord_session(sandbox: Sandbox): + """Start supervisord in a session.""" + session_id = "supervisord-session" + try: + logger.info(f"Creating session {session_id} for supervisord") + sandbox.process.create_session(session_id) + + # Execute supervisord command + sandbox.process.execute_session_command(session_id, SessionExecuteRequest( + command="exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf", + var_async=True + )) + logger.info(f"Supervisord started in session {session_id}") + except Exception as e: + logger.error(f"Error starting supervisord session: {str(e)}") + raise e + +def create_sandbox(password: str, project_id: str = None): + """Create a new sandbox with all required services configured and running.""" + + logger.debug("Creating new Daytona sandbox environment") + logger.debug("Configuring sandbox with browser-use image and environment variables") + + labels = None + if project_id: + logger.debug(f"Using sandbox_id as label: {project_id}") + labels = {'id': project_id} + + params = CreateSandboxFromImageParams( + image=Configuration.SANDBOX_IMAGE_NAME, + public=True, + labels=labels, + env_vars={ + "CHROME_PERSISTENT_SESSION": "true", + "RESOLUTION": "1024x768x24", + "RESOLUTION_WIDTH": "1024", + "RESOLUTION_HEIGHT": "768", + "VNC_PASSWORD": password, + "ANONYMIZED_TELEMETRY": "false", + "CHROME_PATH": "", + "CHROME_USER_DATA": "", + "CHROME_DEBUGGING_PORT": "9222", + "CHROME_DEBUGGING_HOST": "localhost", + "CHROME_CDP": "" + }, + resources=Resources( + cpu=2, + memory=4, + disk=5, + ), + auto_stop_interval=15, + auto_archive_interval=24 * 60, + ) + + # Create the sandbox + sandbox = daytona.create(params) + logger.debug(f"Sandbox created with ID: {sandbox.id}") + + # Start supervisord in a session for new sandbox + start_supervisord_session(sandbox) + + logger.debug(f"Sandbox environment successfully initialized") + return sandbox + +async def delete_sandbox(sandbox_id: str): + """Delete a sandbox by its ID.""" + logger.info(f"Deleting sandbox with ID: {sandbox_id}") + + try: + # Get the sandbox + sandbox = daytona.get(sandbox_id) + + # Delete the sandbox + daytona.delete(sandbox) + + logger.info(f"Successfully deleted sandbox {sandbox_id}") + return True + except Exception as e: + logger.error(f"Error deleting sandbox {sandbox_id}: {str(e)}") + raise e \ No newline at end of file diff --git a/app/daytona/tool_base.py b/app/daytona/tool_base.py new file mode 100644 index 000000000..90446e4c9 --- /dev/null +++ b/app/daytona/tool_base.py @@ -0,0 +1,89 @@ + +from typing import Optional + +from agentpress.thread_manager import ThreadManager +from app.tool.base import BaseTool, ToolResult +from daytona_sdk import Sandbox +from sandbox.sandbox import get_or_start_sandbox +from utils.logger import logger +from utils.files_utils import clean_path + +class SandboxToolsBase(BaseTool): + """Base class for all sandbox tools that provides project-based sandbox access.""" + + # Class variable to track if sandbox URLs have been printed + _urls_printed = False + + def __init__(self, project_id: str, thread_manager: Optional[ThreadManager] = None): + super().__init__() + self.project_id = project_id + self.thread_manager = thread_manager + self.workspace_path = "/workspace" + self._sandbox = None + self._sandbox_id = None + self._sandbox_pass = None + + async def _ensure_sandbox(self) -> Sandbox: + """Ensure we have a valid sandbox instance, retrieving it from the project if needed.""" + if self._sandbox is None: + try: + # Get database client + client = await self.thread_manager.db.client + + # Get project data + project = await client.table('projects').select('*').eq('project_id', self.project_id).execute() + if not project.data or len(project.data) == 0: + raise ValueError(f"Project {self.project_id} not found") + + project_data = project.data[0] + sandbox_info = project_data.get('sandbox', {}) + + if not sandbox_info.get('id'): + raise ValueError(f"No sandbox found for project {self.project_id}") + + # Store sandbox info + self._sandbox_id = sandbox_info['id'] + self._sandbox_pass = sandbox_info.get('pass') + + # Get or start the sandbox + self._sandbox = await get_or_start_sandbox(self._sandbox_id) + + # # Log URLs if not already printed + # if not SandboxToolsBase._urls_printed: + # vnc_link = self._sandbox.get_preview_link(6080) + # website_link = self._sandbox.get_preview_link(8080) + + # vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link) + # website_url = website_link.url if hasattr(website_link, 'url') else str(website_link) + + # print("\033[95m***") + # print(f"VNC URL: {vnc_url}") + # print(f"Website URL: {website_url}") + # print("***\033[0m") + # SandboxToolsBase._urls_printed = True + + except Exception as e: + logger.error(f"Error retrieving sandbox for project {self.project_id}: {str(e)}", exc_info=True) + raise e + + return self._sandbox + + @property + def sandbox(self) -> Sandbox: + """Get the sandbox instance, ensuring it exists.""" + if self._sandbox is None: + raise RuntimeError("Sandbox not initialized. Call _ensure_sandbox() first.") + return self._sandbox + + @property + def sandbox_id(self) -> str: + """Get the sandbox ID, ensuring it exists.""" + if self._sandbox_id is None: + raise RuntimeError("Sandbox ID not initialized. Call _ensure_sandbox() first.") + return self._sandbox_id + + def clean_path(self, path: str) -> str: + """Clean and normalize a path to be relative to /workspace.""" + cleaned_path = clean_path(path, self.workspace_path) + logger.debug(f"Cleaned path: {path} -> {cleaned_path}") + return cleaned_path diff --git a/app/tool/computer_use_tool.py b/app/tool/computer_use_tool.py new file mode 100644 index 000000000..2ff720b74 --- /dev/null +++ b/app/tool/computer_use_tool.py @@ -0,0 +1,660 @@ +import os +import time +import base64 +import aiohttp +import asyncio +import logging +from typing import Optional, Dict +import os + +# from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema +from daytona.tool_base import SandboxToolsBase, Sandbox + +KEYBOARD_KEYS = [ + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', + 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'enter', 'esc', 'backspace', 'tab', 'space', 'delete', + 'ctrl', 'alt', 'shift', 'win', + 'up', 'down', 'left', 'right', + 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', + 'ctrl+c', 'ctrl+v', 'ctrl+x', 'ctrl+z', 'ctrl+a', 'ctrl+s', + 'alt+tab', 'alt+f4', 'ctrl+alt+delete' +] + +class ComputerUseTool(SandboxToolsBase): + """Computer automation tool for controlling the sandbox browser and GUI.""" + + def __init__(self, sandbox: Sandbox): + """Initialize automation tool with sandbox connection.""" + super().__init__(sandbox) + self.session = None + self.mouse_x = 0 # Track current mouse position + self.mouse_y = 0 + # Get automation service URL using port 8000 + self.api_base_url = self.sandbox.get_preview_link(8000) + logging.info(f"Initialized Computer Use Tool with API URL: {self.api_base_url}") + + async def _get_session(self) -> aiohttp.ClientSession: + """Get or create aiohttp session for API requests.""" + if self.session is None or self.session.closed: + self.session = aiohttp.ClientSession() + return self.session + + async def _api_request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Dict: + """Send request to automation service API.""" + try: + session = await self._get_session() + url = f"{self.api_base_url}/api{endpoint}" + + logging.debug(f"API request: {method} {url} {data}") + + if method.upper() == "GET": + async with session.get(url) as response: + result = await response.json() + else: # POST + async with session.post(url, json=data) as response: + result = await response.json() + + logging.debug(f"API response: {result}") + return result + + except Exception as e: + logging.error(f"API request failed: {str(e)}") + return {"success": False, "error": str(e)} + + async def cleanup(self): + """Clean up resources.""" + if self.session and not self.session.closed: + await self.session.close() + self.session = None + + @openapi_schema({ + "type": "function", + "function": { + "name": "move_to", + "description": "Move cursor to specified position", + "parameters": { + "type": "object", + "properties": { + "x": { + "type": "number", + "description": "X coordinate" + }, + "y": { + "type": "number", + "description": "Y coordinate" + } + }, + "required": ["x", "y"] + } + } + }) + @xml_schema( + tag_name="move-to", + mappings=[ + {"param_name": "x", "node_type": "attribute", "path": "."}, + {"param_name": "y", "node_type": "attribute", "path": "."} + ], + example=''' + + + 100 + 200 + + + ''' + ) + async def move_to(self, x: float, y: float) -> ToolResult: + """Move cursor to specified position.""" + try: + x_int = int(round(float(x))) + y_int = int(round(float(y))) + + result = await self._api_request("POST", "/automation/mouse/move", { + "x": x_int, + "y": y_int + }) + + if result.get("success", False): + self.mouse_x = x_int + self.mouse_y = y_int + return ToolResult(success=True, output=f"Moved to ({x_int}, {y_int})") + else: + return ToolResult(success=False, output=f"Failed to move: {result.get('error', 'Unknown error')}") + + except Exception as e: + return ToolResult(success=False, output=f"Failed to move: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "click", + "description": "Click at current or specified position", + "parameters": { + "type": "object", + "properties": { + "button": { + "type": "string", + "description": "Mouse button to click", + "enum": ["left", "right", "middle"], + "default": "left" + }, + "x": { + "type": "number", + "description": "Optional X coordinate" + }, + "y": { + "type": "number", + "description": "Optional Y coordinate" + }, + "num_clicks": { + "type": "integer", + "description": "Number of clicks", + "enum": [1, 2, 3], + "default": 1 + } + } + } + } + }) + @xml_schema( + tag_name="click", + mappings=[ + {"param_name": "x", "node_type": "attribute", "path": "x"}, + {"param_name": "y", "node_type": "attribute", "path": "y"}, + {"param_name": "button", "node_type": "attribute", "path": "button"}, + {"param_name": "num_clicks", "node_type": "attribute", "path": "num_clicks"} + ], + example=''' + + + 100 + 200 + left + 1 + + + ''' + ) + async def click(self, x: Optional[float] = None, y: Optional[float] = None, + button: str = "left", num_clicks: int = 1) -> ToolResult: + """Click at current or specified position.""" + try: + x_val = x if x is not None else self.mouse_x + y_val = y if y is not None else self.mouse_y + + x_int = int(round(float(x_val))) + y_int = int(round(float(y_val))) + num_clicks = int(num_clicks) + + result = await self._api_request("POST", "/automation/mouse/click", { + "x": x_int, + "y": y_int, + "clicks": num_clicks, + "button": button.lower() + }) + + if result.get("success", False): + self.mouse_x = x_int + self.mouse_y = y_int + return ToolResult(success=True, + output=f"{num_clicks} {button} click(s) performed at ({x_int}, {y_int})") + else: + return ToolResult(success=False, output=f"Failed to click: {result.get('error', 'Unknown error')}") + except Exception as e: + return ToolResult(success=False, output=f"Failed to click: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "scroll", + "description": "Scroll the mouse wheel at current position", + "parameters": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "description": "Scroll amount (positive for up, negative for down)", + "minimum": -10, + "maximum": 10 + } + }, + "required": ["amount"] + } + } + }) + @xml_schema( + tag_name="scroll", + mappings=[ + {"param_name": "amount", "node_type": "attribute", "path": "amount"} + ], + example=''' + + + -3 + + + ''' + ) + async def scroll(self, amount: int) -> ToolResult: + """ + Scroll the mouse wheel at current position. + Positive values scroll up, negative values scroll down. + """ + try: + amount = int(float(amount)) + amount = max(-10, min(10, amount)) + + result = await self._api_request("POST", "/automation/mouse/scroll", { + "clicks": amount, + "x": self.mouse_x, + "y": self.mouse_y + }) + + if result.get("success", False): + direction = "up" if amount > 0 else "down" + steps = abs(amount) + return ToolResult(success=True, + output=f"Scrolled {direction} {steps} step(s) at position ({self.mouse_x}, {self.mouse_y})") + else: + return ToolResult(success=False, output=f"Failed to scroll: {result.get('error', 'Unknown error')}") + except Exception as e: + return ToolResult(success=False, output=f"Failed to scroll: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "typing", + "description": "Type specified text", + "parameters": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text to type" + } + }, + "required": ["text"] + } + } + }) + @xml_schema( + tag_name="typing", + mappings=[ + {"param_name": "text", "node_type": "content", "path": "text"} + ], + example=''' + + + Hello World! + + + ''' + ) + async def typing(self, text: str) -> ToolResult: + """Type specified text.""" + try: + text = str(text) + + result = await self._api_request("POST", "/automation/keyboard/write", { + "message": text, + "interval": 0.01 + }) + + if result.get("success", False): + return ToolResult(success=True, output=f"Typed: {text}") + else: + return ToolResult(success=False, output=f"Failed to type: {result.get('error', 'Unknown error')}") + except Exception as e: + return ToolResult(success=False, output=f"Failed to type: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "press", + "description": "Press and release a key", + "parameters": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Key to press", + "enum": KEYBOARD_KEYS + } + }, + "required": ["key"] + } + } + }) + @xml_schema( + tag_name="press", + mappings=[ + {"param_name": "key", "node_type": "attribute", "path": "key"} + ], + example=''' + + + enter + + + ''' + ) + async def press(self, key: str) -> ToolResult: + """Press and release a key.""" + try: + key = str(key).lower() + + result = await self._api_request("POST", "/automation/keyboard/press", { + "keys": key, + "presses": 1 + }) + + if result.get("success", False): + return ToolResult(success=True, output=f"Pressed key: {key}") + else: + return ToolResult(success=False, output=f"Failed to press key: {result.get('error', 'Unknown error')}") + except Exception as e: + return ToolResult(success=False, output=f"Failed to press key: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "wait", + "description": "Wait for specified duration", + "parameters": { + "type": "object", + "properties": { + "duration": { + "type": "number", + "description": "Duration in seconds", + "default": 0.5 + } + } + } + } + }) + @xml_schema( + tag_name="wait", + mappings=[ + {"param_name": "duration", "node_type": "attribute", "path": "duration"} + ], + example=''' + + + 1.5 + + + ''' + ) + async def wait(self, duration: float = 0.5) -> ToolResult: + """Wait for specified duration.""" + try: + duration = float(duration) + duration = max(0, min(10, duration)) + await asyncio.sleep(duration) + return ToolResult(success=True, output=f"Waited {duration} seconds") + except Exception as e: + return ToolResult(success=False, output=f"Failed to wait: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "mouse_down", + "description": "Press a mouse button", + "parameters": { + "type": "object", + "properties": { + "button": { + "type": "string", + "description": "Mouse button to press", + "enum": ["left", "right", "middle"], + "default": "left" + } + } + } + } + }) + @xml_schema( + tag_name="mouse-down", + mappings=[ + {"param_name": "button", "node_type": "attribute", "path": "button"} + ], + example=''' + + + left + + + ''' + ) + async def mouse_down(self, button: str = "left", x: Optional[float] = None, y: Optional[float] = None) -> ToolResult: + """Press a mouse button at current or specified position.""" + try: + x_val = x if x is not None else self.mouse_x + y_val = y if y is not None else self.mouse_y + + x_int = int(round(float(x_val))) + y_int = int(round(float(y_val))) + + result = await self._api_request("POST", "/automation/mouse/down", { + "x": x_int, + "y": y_int, + "button": button.lower() + }) + + if result.get("success", False): + self.mouse_x = x_int + self.mouse_y = y_int + return ToolResult(success=True, output=f"{button} button pressed at ({x_int}, {y_int})") + else: + return ToolResult(success=False, output=f"Failed to press button: {result.get('error', 'Unknown error')}") + except Exception as e: + return ToolResult(success=False, output=f"Failed to press button: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "mouse_up", + "description": "Release a mouse button", + "parameters": { + "type": "object", + "properties": { + "button": { + "type": "string", + "description": "Mouse button to release", + "enum": ["left", "right", "middle"], + "default": "left" + } + } + } + } + }) + @xml_schema( + tag_name="mouse-up", + mappings=[ + {"param_name": "button", "node_type": "attribute", "path": "button"} + ], + example=''' + + + left + + + ''' + ) + async def mouse_up(self, button: str = "left", x: Optional[float] = None, y: Optional[float] = None) -> ToolResult: + """Release a mouse button at current or specified position.""" + try: + x_val = x if x is not None else self.mouse_x + y_val = y if y is not None else self.mouse_y + + x_int = int(round(float(x_val))) + y_int = int(round(float(y_val))) + + result = await self._api_request("POST", "/automation/mouse/up", { + "x": x_int, + "y": y_int, + "button": button.lower() + }) + + if result.get("success", False): + self.mouse_x = x_int + self.mouse_y = y_int + return ToolResult(success=True, output=f"{button} button released at ({x_int}, {y_int})") + else: + return ToolResult(success=False, output=f"Failed to release button: {result.get('error', 'Unknown error')}") + except Exception as e: + return ToolResult(success=False, output=f"Failed to release button: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "drag_to", + "description": "Drag cursor to specified position", + "parameters": { + "type": "object", + "properties": { + "x": { + "type": "number", + "description": "Target X coordinate" + }, + "y": { + "type": "number", + "description": "Target Y coordinate" + } + }, + "required": ["x", "y"] + } + } + }) + @xml_schema( + tag_name="drag-to", + mappings=[ + {"param_name": "x", "node_type": "attribute", "path": "x"}, + {"param_name": "y", "node_type": "attribute", "path": "y"} + ], + example=''' + + + 500 + 50 + + + ''' + ) + async def drag_to(self, x: float, y: float) -> ToolResult: + """Click and drag from current position to target position.""" + try: + target_x = int(round(float(x))) + target_y = int(round(float(y))) + start_x = self.mouse_x + start_y = self.mouse_y + + result = await self._api_request("POST", "/automation/mouse/drag", { + "x": target_x, + "y": target_y, + "duration": 0.3, + "button": "left" + }) + + if result.get("success", False): + self.mouse_x = target_x + self.mouse_y = target_y + return ToolResult(success=True, + output=f"Dragged from ({start_x}, {start_y}) to ({target_x}, {target_y})") + else: + return ToolResult(success=False, output=f"Failed to drag: {result.get('error', 'Unknown error')}") + except Exception as e: + return ToolResult(success=False, output=f"Failed to drag: {str(e)}") + + async def get_screenshot_base64(self) -> Optional[dict]: + """Capture screen and return as base64 encoded image.""" + try: + result = await self._api_request("POST", "/automation/screenshot") + + if "image" in result: + base64_str = result["image"] + timestamp = time.strftime("%Y%m%d_%H%M%S") + + # Save screenshot to file + screenshots_dir = "screenshots" + if not os.path.exists(screenshots_dir): + os.makedirs(screenshots_dir) + + timestamped_filename = os.path.join(screenshots_dir, f"screenshot_{timestamp}.png") + latest_filename = "latest_screenshot.png" + + # Decode base64 string and save to file + img_data = base64.b64decode(base64_str) + with open(timestamped_filename, 'wb') as f: + f.write(img_data) + + # Save a copy as the latest screenshot + with open(latest_filename, 'wb') as f: + f.write(img_data) + + return { + "content_type": "image/png", + "base64": base64_str, + "timestamp": timestamp, + "filename": timestamped_filename + } + else: + return None + + except Exception as e: + print(f"[Screenshot] Error during screenshot process: {str(e)}") + return None + + @openapi_schema({ + "type": "function", + "function": { + "name": "hotkey", + "description": "Press a key combination", + "parameters": { + "type": "object", + "properties": { + "keys": { + "type": "string", + "description": "Key combination to press", + "enum": KEYBOARD_KEYS + } + }, + "required": ["keys"] + } + } + }) + @xml_schema( + tag_name="hotkey", + mappings=[ + {"param_name": "keys", "node_type": "attribute", "path": "keys"} + ], + example=''' + + + ctrl+a + + + ''' + ) + async def hotkey(self, keys: str) -> ToolResult: + """Press a key combination.""" + try: + keys = str(keys).lower().strip() + key_sequence = keys.split('+') + + result = await self._api_request("POST", "/automation/keyboard/hotkey", { + "keys": key_sequence, + "interval": 0.01 + }) + + if result.get("success", False): + return ToolResult(success=True, output=f"Pressed key combination: {keys}") + else: + return ToolResult(success=False, output=f"Failed to press keys: {result.get('error', 'Unknown error')}") + except Exception as e: + return ToolResult(success=False, output=f"Failed to press keys: {str(e)}") + +if __name__ == "__main__": + print("This module should be imported, not run directly.") diff --git a/app/tool/data_providers/ActiveJobsProvider.py b/app/tool/data_providers/ActiveJobsProvider.py new file mode 100644 index 000000000..0b09aae17 --- /dev/null +++ b/app/tool/data_providers/ActiveJobsProvider.py @@ -0,0 +1,57 @@ +from typing import Dict + +from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema + + +class ActiveJobsProvider(RapidDataProviderBase): + def __init__(self): + endpoints: Dict[str, EndpointSchema] = { + "active_jobs": { + "route": "/active-ats-7d", + "method": "GET", + "name": "Active Jobs Search", + "description": "Get active job listings with various filter options.", + "payload": { + "limit": "Optional. Number of jobs per API call (10-100). Default is 100.", + "offset": "Optional. Offset for pagination. Default is 0.", + "title_filter": "Optional. Search terms for job title.", + "advanced_title_filter": "Optional. Advanced title filter with operators (can't be used with title_filter).", + "location_filter": "Optional. Filter by location(s). Use full names like 'United States' not 'US'.", + "description_filter": "Optional. Filter on job description content.", + "organization_filter": "Optional. Filter by company name(s).", + "description_type": "Optional. Return format for description: 'text' or 'html'. Leave empty to exclude descriptions.", + "source": "Optional. Filter by ATS source.", + "date_filter": "Optional. Filter by posting date (greater than).", + "ai_employment_type_filter": "Optional. Filter by employment type (FULL_TIME, PART_TIME, etc).", + "ai_work_arrangement_filter": "Optional. Filter by work arrangement (On-site, Hybrid, Remote OK, Remote Solely).", + "ai_experience_level_filter": "Optional. Filter by experience level (0-2, 2-5, 5-10, 10+).", + "li_organization_slug_filter": "Optional. Filter by LinkedIn company slug.", + "li_organization_slug_exclusion_filter": "Optional. Exclude LinkedIn company slugs.", + "li_industry_filter": "Optional. Filter by LinkedIn industry.", + "li_organization_specialties_filter": "Optional. Filter by LinkedIn company specialties.", + "li_organization_description_filter": "Optional. Filter by LinkedIn company description." + } + } + } + + base_url = "https://active-jobs-db.p.rapidapi.com" + super().__init__(base_url, endpoints) + + +if __name__ == "__main__": + from dotenv import load_dotenv + load_dotenv() + tool = ActiveJobsProvider() + + # Example for searching active jobs + jobs = tool.call_endpoint( + route="active_jobs", + payload={ + "limit": "10", + "offset": "0", + "title_filter": "\"Data Engineer\"", + "location_filter": "\"United States\" OR \"United Kingdom\"", + "description_type": "text" + } + ) + print("Active Jobs:", jobs) \ No newline at end of file diff --git a/app/tool/data_providers/AmazonProvider.py b/app/tool/data_providers/AmazonProvider.py new file mode 100644 index 000000000..b29720893 --- /dev/null +++ b/app/tool/data_providers/AmazonProvider.py @@ -0,0 +1,191 @@ +from typing import Dict + +from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema + + +class AmazonProvider(RapidDataProviderBase): + def __init__(self): + endpoints: Dict[str, EndpointSchema] = { + "search": { + "route": "/search", + "method": "GET", + "name": "Amazon Product Search", + "description": "Search for products on Amazon with various filters and parameters.", + "payload": { + "query": "Search query (supports both free-form text queries or a product asin)", + "page": "Results page to return (default: 1)", + "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", + "sort_by": "Return the results in a specific sort order (RELEVANCE, LOWEST_PRICE, HIGHEST_PRICE, REVIEWS, NEWEST, BEST_SELLERS)", + "product_condition": "Return products in a specific condition (ALL, NEW, USED, RENEWED, COLLECTIBLE)", + "is_prime": "Only return prime products (boolean)", + "deals_and_discounts": "Return deals and discounts in a specific condition (NONE, ALL_DISCOUNTS, TODAYS_DEALS)", + "category_id": "Find products in a specific category / department (optional)", + "category": "Filter by specific numeric Amazon category (optional)", + "min_price": "Only return product offers with price greater than a certain value (optional)", + "max_price": "Only return product offers with price lower than a certain value (optional)", + "brand": "Find products with a specific brand (optional)", + "seller_id": "Find products sold by specific seller (optional)", + "four_stars_and_up": "Return product listings with ratings of 4 stars & up (optional)", + "additional_filters": "Any filters available on the Amazon page but not part of this endpoint's parameters (optional)" + } + }, + "product-details": { + "route": "/product-details", + "method": "GET", + "name": "Amazon Product Details", + "description": "Get detailed information about specific Amazon products by ASIN.", + "payload": { + "asin": "Product ASIN for which to get details. Supports batching of up to 10 ASINs in a single request, separated by comma.", + "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", + "more_info_query": "A query to search and get more info about the product as part of Product Information, Customer Q&As, and Customer Reviews (optional)", + "fields": "A comma separated list of product fields to include in the response (field projection). By default all fields are returned. (optional)" + } + }, + "products-by-category": { + "route": "/products-by-category", + "method": "GET", + "name": "Amazon Products by Category", + "description": "Get products from a specific Amazon category.", + "payload": { + "category_id": "The Amazon category for which to return results. Multiple category values can be separated by comma.", + "page": "Page to return (default: 1)", + "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", + "sort_by": "Return the results in a specific sort order (RELEVANCE, LOWEST_PRICE, HIGHEST_PRICE, REVIEWS, NEWEST, BEST_SELLERS)", + "min_price": "Only return product offers with price greater than a certain value (optional)", + "max_price": "Only return product offers with price lower than a certain value (optional)", + "product_condition": "Return products in a specific condition (ALL, NEW, USED, RENEWED, COLLECTIBLE)", + "brand": "Only return products of a specific brand. Multiple brands can be specified as a comma separated list (optional)", + "is_prime": "Only return prime products (boolean)", + "deals_and_discounts": "Return deals and discounts in a specific condition (NONE, ALL_DISCOUNTS, TODAYS_DEALS)", + "four_stars_and_up": "Return product listings with ratings of 4 stars & up (optional)", + "additional_filters": "Any filters available on the Amazon page but not part of this endpoint's parameters (optional)" + } + }, + "product-reviews": { + "route": "/product-reviews", + "method": "GET", + "name": "Amazon Product Reviews", + "description": "Get customer reviews for a specific Amazon product by ASIN.", + "payload": { + "asin": "Product asin for which to get reviews.", + "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", + "page": "Results page to return (default: 1)", + "sort_by": "Return reviews in a specific sort order (TOP_REVIEWS, MOST_RECENT)", + "star_rating": "Only return reviews with a specific star rating (ALL, 5_STARS, 4_STARS, 3_STARS, 2_STARS, 1_STARS, POSITIVE, CRITICAL)", + "verified_purchases_only": "Only return reviews by reviewers who made a verified purchase (boolean)", + "images_or_videos_only": "Only return reviews containing images and / or videos (boolean)", + "current_format_only": "Only return reviews of the current format (product variant - e.g. Color) (boolean)" + } + }, + "seller-profile": { + "route": "/seller-profile", + "method": "GET", + "name": "Amazon Seller Profile", + "description": "Get detailed information about a specific Amazon seller by Seller ID.", + "payload": { + "seller_id": "The Amazon Seller ID for which to get seller profile details", + "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", + "fields": "A comma separated list of seller profile fields to include in the response (field projection). By default all fields are returned. (optional)" + } + }, + "seller-reviews": { + "route": "/seller-reviews", + "method": "GET", + "name": "Amazon Seller Reviews", + "description": "Get customer reviews for a specific Amazon seller by Seller ID.", + "payload": { + "seller_id": "The Amazon Seller ID for which to get seller reviews", + "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", + "star_rating": "Only return reviews with a specific star rating or positive / negative sentiment (ALL, 5_STARS, 4_STARS, 3_STARS, 2_STARS, 1_STARS, POSITIVE, CRITICAL)", + "page": "The page of seller feedback results to retrieve (default: 1)", + "fields": "A comma separated list of seller review fields to include in the response (field projection). By default all fields are returned. (optional)" + } + } + } + base_url = "https://real-time-amazon-data.p.rapidapi.com" + super().__init__(base_url, endpoints) + + +if __name__ == "__main__": + from dotenv import load_dotenv + load_dotenv() + tool = AmazonProvider() + + # Example for product search + search_result = tool.call_endpoint( + route="search", + payload={ + "query": "Phone", + "page": 1, + "country": "US", + "sort_by": "RELEVANCE", + "product_condition": "ALL", + "is_prime": False, + "deals_and_discounts": "NONE" + } + ) + print("Search Result:", search_result) + + # Example for product details + details_result = tool.call_endpoint( + route="product-details", + payload={ + "asin": "B07ZPKBL9V", + "country": "US" + } + ) + print("Product Details:", details_result) + + # Example for products by category + category_result = tool.call_endpoint( + route="products-by-category", + payload={ + "category_id": "2478868012", + "page": 1, + "country": "US", + "sort_by": "RELEVANCE", + "product_condition": "ALL", + "is_prime": False, + "deals_and_discounts": "NONE" + } + ) + print("Category Products:", category_result) + + # Example for product reviews + reviews_result = tool.call_endpoint( + route="product-reviews", + payload={ + "asin": "B07ZPKN6YR", + "country": "US", + "page": 1, + "sort_by": "TOP_REVIEWS", + "star_rating": "ALL", + "verified_purchases_only": False, + "images_or_videos_only": False, + "current_format_only": False + } + ) + print("Product Reviews:", reviews_result) + + # Example for seller profile + seller_result = tool.call_endpoint( + route="seller-profile", + payload={ + "seller_id": "A02211013Q5HP3OMSZC7W", + "country": "US" + } + ) + print("Seller Profile:", seller_result) + + # Example for seller reviews + seller_reviews_result = tool.call_endpoint( + route="seller-reviews", + payload={ + "seller_id": "A02211013Q5HP3OMSZC7W", + "country": "US", + "star_rating": "ALL", + "page": 1 + } + ) + print("Seller Reviews:", seller_reviews_result) + diff --git a/app/tool/data_providers/LinkedinProvider.py b/app/tool/data_providers/LinkedinProvider.py new file mode 100644 index 000000000..6a70e21ac --- /dev/null +++ b/app/tool/data_providers/LinkedinProvider.py @@ -0,0 +1,250 @@ +from typing import Dict + +from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema + + +class LinkedinProvider(RapidDataProviderBase): + def __init__(self): + endpoints: Dict[str, EndpointSchema] = { + "person": { + "route": "/person", + "method": "POST", + "name": "Person Data", + "description": "Fetches any Linkedin profiles data including skills, certificates, experiences, qualifications and much more.", + "payload": { + "link": "LinkedIn Profile URL" + } + }, + "person_urn": { + "route": "/person_urn", + "method": "POST", + "name": "Person Data (Using Urn)", + "description": "It takes profile urn instead of profile public identifier in input", + "payload": { + "link": "LinkedIn Profile URL or URN" + } + }, + "person_deep": { + "route": "/person_deep", + "method": "POST", + "name": "Person Data (Deep)", + "description": "Fetches all experiences, educations, skills, languages, publications... related to a profile.", + "payload": { + "link": "LinkedIn Profile URL" + } + }, + "profile_updates": { + "route": "/profile_updates", + "method": "GET", + "name": "Person Posts (WITH PAGINATION)", + "description": "Fetches posts of a linkedin profile alongwith reactions, comments, postLink and reposts data.", + "payload": { + "profile_url": "LinkedIn Profile URL", + "page": "Page number", + "reposts": "Include reposts (1 or 0)", + "comments": "Include comments (1 or 0)" + } + }, + "profile_recent_comments": { + "route": "/profile_recent_comments", + "method": "POST", + "name": "Person Recent Activity (Comments on Posts)", + "description": "Fetches 20 most recent comments posted by a linkedin user (per page).", + "payload": { + "profile_url": "LinkedIn Profile URL", + "page": "Page number", + "paginationToken": "Token for pagination" + } + }, + "comments_from_recent_activity": { + "route": "/comments_from_recent_activity", + "method": "GET", + "name": "Comments from recent activity", + "description": "Fetches recent comments posted by a person as per his recent activity tab.", + "payload": { + "profile_url": "LinkedIn Profile URL", + "page": "Page number" + } + }, + "person_skills": { + "route": "/person_skills", + "method": "POST", + "name": "Person Skills", + "description": "Scraper all skills of a linkedin user", + "payload": { + "link": "LinkedIn Profile URL" + } + }, + "email_to_linkedin_profile": { + "route": "/email_to_linkedin_profile", + "method": "POST", + "name": "Email to LinkedIn Profile", + "description": "Finds LinkedIn profile associated with an email address", + "payload": { + "email": "Email address to search" + } + }, + "company": { + "route": "/company", + "method": "POST", + "name": "Company Data", + "description": "Fetches LinkedIn company profile data", + "payload": { + "link": "LinkedIn Company URL" + } + }, + "web_domain": { + "route": "/web-domain", + "method": "POST", + "name": "Web Domain to Company", + "description": "Fetches LinkedIn company profile data from a web domain", + "payload": { + "link": "Website domain (e.g., huzzle.app)" + } + }, + "similar_profiles": { + "route": "/similar_profiles", + "method": "GET", + "name": "Similar Profiles", + "description": "Fetches profiles similar to a given LinkedIn profile", + "payload": { + "profileUrl": "LinkedIn Profile URL" + } + }, + "company_jobs": { + "route": "/company_jobs", + "method": "POST", + "name": "Company Jobs", + "description": "Fetches job listings from a LinkedIn company page", + "payload": { + "company_url": "LinkedIn Company URL", + "count": "Number of job listings to fetch" + } + }, + "company_updates": { + "route": "/company_updates", + "method": "GET", + "name": "Company Posts", + "description": "Fetches posts from a LinkedIn company page", + "payload": { + "company_url": "LinkedIn Company URL", + "page": "Page number", + "reposts": "Include reposts (0, 1, or 2)", + "comments": "Include comments (0, 1, or 2)" + } + }, + "company_employee": { + "route": "/company_employee", + "method": "GET", + "name": "Company Employees", + "description": "Fetches employees of a LinkedIn company using company ID", + "payload": { + "companyId": "LinkedIn Company ID", + "page": "Page number" + } + }, + "company_updates_post": { + "route": "/company_updates", + "method": "POST", + "name": "Company Posts (POST)", + "description": "Fetches posts from a LinkedIn company page with specific count parameters", + "payload": { + "company_url": "LinkedIn Company URL", + "posts": "Number of posts to fetch", + "comments": "Number of comments to fetch per post", + "reposts": "Number of reposts to fetch" + } + }, + "search_posts_with_filters": { + "route": "/search_posts_with_filters", + "method": "GET", + "name": "Search Posts With Filters", + "description": "Searches LinkedIn posts with various filtering options", + "payload": { + "query": "Keywords/Search terms (text you put in LinkedIn search bar)", + "page": "Page number (1-100, each page contains 20 results)", + "sort_by": "Sort method: 'relevance' (Top match) or 'date_posted' (Latest)", + "author_job_title": "Filter by job title of author (e.g., CEO)", + "content_type": "Type of content post contains (photos, videos, liveVideos, collaborativeArticles, documents)", + "from_member": "URN of person who posted (comma-separated for multiple)", + "from_organization": "ID of organization who posted (comma-separated for multiple)", + "author_company": "ID of company author works for (comma-separated for multiple)", + "author_industry": "URN of industry author is connected with (comma-separated for multiple)", + "mentions_member": "URN of person mentioned in post (comma-separated for multiple)", + "mentions_organization": "ID of organization mentioned in post (comma-separated for multiple)" + } + }, + "search_jobs": { + "route": "/search_jobs", + "method": "GET", + "name": "Search Jobs", + "description": "Searches LinkedIn jobs with various filtering options", + "payload": { + "query": "Job search keywords (e.g., Software developer)", + "page": "Page number", + "searchLocationId": "Location ID for job search (get from Suggestion location endpoint)", + "easyApply": "Filter for easy apply jobs (true or false)", + "experience": "Experience level required (1=Internship, 2=Entry level, 3=Associate, 4=Mid senior, 5=Director, 6=Executive, comma-separated)", + "jobType": "Job type (F=Full time, P=Part time, C=Contract, T=Temporary, V=Volunteer, I=Internship, O=Other, comma-separated)", + "postedAgo": "Time jobs were posted in seconds (e.g., 3600 for past hour)", + "workplaceType": "Workplace type (1=On-Site, 2=Remote, 3=Hybrid, comma-separated)", + "sortBy": "Sort method (DD=most recent, R=most relevant)", + "companyIdsList": "List of company IDs, comma-separated", + "industryIdsList": "List of industry IDs, comma-separated", + "functionIdsList": "List of function IDs, comma-separated", + "titleIdsList": "List of job title IDs, comma-separated", + "locationIdsList": "List of location IDs within specified searchLocationId country, comma-separated" + } + }, + "search_people_with_filters": { + "route": "/search_people_with_filters", + "method": "POST", + "name": "Search People With Filters", + "description": "Searches LinkedIn profiles with detailed filtering options", + "payload": { + "keyword": "General search keyword", + "page": "Page number", + "title_free_text": "Job title to filter by (e.g., CEO)", + "company_free_text": "Company name to filter by", + "first_name": "First name of person", + "last_name": "Last name of person", + "current_company_list": "List of current companies (comma-separated IDs)", + "past_company_list": "List of past companies (comma-separated IDs)", + "location_list": "List of locations (comma-separated IDs)", + "language_list": "List of languages (comma-separated)", + "service_catagory_list": "List of service categories (comma-separated)", + "school_free_text": "School name to filter by", + "industry_list": "List of industries (comma-separated IDs)", + "school_list": "List of schools (comma-separated IDs)" + } + }, + "search_company_with_filters": { + "route": "/search_company_with_filters", + "method": "POST", + "name": "Search Company With Filters", + "description": "Searches LinkedIn companies with detailed filtering options", + "payload": { + "keyword": "General search keyword", + "page": "Page number", + "company_size_list": "List of company sizes (comma-separated, e.g., A,D)", + "hasJobs": "Filter companies with jobs (true or false)", + "location_list": "List of location IDs (comma-separated)", + "industry_list": "List of industry IDs (comma-separated)" + } + } + } + base_url = "https://linkedin-data-scraper.p.rapidapi.com" + super().__init__(base_url, endpoints) + + +if __name__ == "__main__": + from dotenv import load_dotenv + load_dotenv() + tool = LinkedinProvider() + + result = tool.call_endpoint( + route="comments_from_recent_activity", + payload={"profile_url": "https://www.linkedin.com/in/adamcohenhillel/", "page": 1} + ) + print(result) + diff --git a/app/tool/data_providers/RapidDataProviderBase.py b/app/tool/data_providers/RapidDataProviderBase.py new file mode 100644 index 000000000..5b7ccd664 --- /dev/null +++ b/app/tool/data_providers/RapidDataProviderBase.py @@ -0,0 +1,61 @@ +import os +import requests +from typing import Dict, Any, Optional, TypedDict, Literal + + +class EndpointSchema(TypedDict): + route: str + method: Literal['GET', 'POST'] + name: str + description: str + payload: Dict[str, Any] + + +class RapidDataProviderBase: + def __init__(self, base_url: str, endpoints: Dict[str, EndpointSchema]): + self.base_url = base_url + self.endpoints = endpoints + + def get_endpoints(self): + return self.endpoints + + def call_endpoint( + self, + route: str, + payload: Optional[Dict[str, Any]] = None + ): + """ + Call an API endpoint with the given parameters and data. + + Args: + endpoint (EndpointSchema): The endpoint configuration dictionary + params (dict, optional): Query parameters for GET requests + payload (dict, optional): JSON payload for POST requests + + Returns: + dict: The JSON response from the API + """ + if route.startswith("/"): + route = route[1:] + + endpoint = self.endpoints.get(route) + if not endpoint: + raise ValueError(f"Endpoint {route} not found") + + url = f"{self.base_url}{endpoint['route']}" + + headers = { + "x-rapidapi-key": os.getenv("RAPID_API_KEY"), + "x-rapidapi-host": url.split("//")[1].split("/")[0], + "Content-Type": "application/json" + } + + method = endpoint.get('method', 'GET').upper() + + if method == 'GET': + response = requests.get(url, params=payload, headers=headers) + elif method == 'POST': + response = requests.post(url, json=payload, headers=headers) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + return response.json() diff --git a/app/tool/data_providers/TwitterProvider.py b/app/tool/data_providers/TwitterProvider.py new file mode 100644 index 000000000..df6358ebf --- /dev/null +++ b/app/tool/data_providers/TwitterProvider.py @@ -0,0 +1,240 @@ +from typing import Dict + +from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema + + +class TwitterProvider(RapidDataProviderBase): + def __init__(self): + endpoints: Dict[str, EndpointSchema] = { + "user_info": { + "route": "/screenname.php", + "method": "GET", + "name": "Twitter User Info", + "description": "Get information about a Twitter user by screenname or user ID.", + "payload": { + "screenname": "Twitter username without the @ symbol", + "rest_id": "Optional Twitter user's ID. If provided, overwrites screenname parameter." + } + }, + "timeline": { + "route": "/timeline.php", + "method": "GET", + "name": "User Timeline", + "description": "Get tweets from a user's timeline.", + "payload": { + "screenname": "Twitter username without the @ symbol", + "rest_id": "Optional parameter that overwrites the screenname", + "cursor": "Optional pagination cursor" + } + }, + "following": { + "route": "/following.php", + "method": "GET", + "name": "User Following", + "description": "Get users that a specific user follows.", + "payload": { + "screenname": "Twitter username without the @ symbol", + "rest_id": "Optional parameter that overwrites the screenname", + "cursor": "Optional pagination cursor" + } + }, + "followers": { + "route": "/followers.php", + "method": "GET", + "name": "User Followers", + "description": "Get followers of a specific user.", + "payload": { + "screenname": "Twitter username without the @ symbol", + "cursor": "Optional pagination cursor" + } + }, + "search": { + "route": "/search.php", + "method": "GET", + "name": "Twitter Search", + "description": "Search for tweets with a specific query.", + "payload": { + "query": "Search query string", + "cursor": "Optional pagination cursor", + "search_type": "Optional search type (e.g. 'Top')" + } + }, + "replies": { + "route": "/replies.php", + "method": "GET", + "name": "User Replies", + "description": "Get replies made by a user.", + "payload": { + "screenname": "Twitter username without the @ symbol", + "cursor": "Optional pagination cursor" + } + }, + "check_retweet": { + "route": "/checkretweet.php", + "method": "GET", + "name": "Check Retweet", + "description": "Check if a user has retweeted a specific tweet.", + "payload": { + "screenname": "Twitter username without the @ symbol", + "tweet_id": "ID of the tweet to check" + } + }, + "tweet": { + "route": "/tweet.php", + "method": "GET", + "name": "Get Tweet", + "description": "Get details of a specific tweet by ID.", + "payload": { + "id": "ID of the tweet" + } + }, + "tweet_thread": { + "route": "/tweet_thread.php", + "method": "GET", + "name": "Get Tweet Thread", + "description": "Get a thread of tweets starting from a specific tweet ID.", + "payload": { + "id": "ID of the tweet", + "cursor": "Optional pagination cursor" + } + }, + "retweets": { + "route": "/retweets.php", + "method": "GET", + "name": "Get Retweets", + "description": "Get users who retweeted a specific tweet.", + "payload": { + "id": "ID of the tweet", + "cursor": "Optional pagination cursor" + } + }, + "latest_replies": { + "route": "/latest_replies.php", + "method": "GET", + "name": "Get Latest Replies", + "description": "Get the latest replies to a specific tweet.", + "payload": { + "id": "ID of the tweet", + "cursor": "Optional pagination cursor" + } + } + } + base_url = "https://twitter-api45.p.rapidapi.com" + super().__init__(base_url, endpoints) + + +if __name__ == "__main__": + from dotenv import load_dotenv + load_dotenv() + tool = TwitterProvider() + + # Example for getting user info + user_info = tool.call_endpoint( + route="user_info", + payload={ + "screenname": "elonmusk", + # "rest_id": "44196397" # Optional, uncomment to use user ID instead of screenname + } + ) + print("User Info:", user_info) + + # Example for getting user timeline + timeline = tool.call_endpoint( + route="timeline", + payload={ + "screenname": "elonmusk", + # "cursor": "optional-cursor-value" # Optional for pagination + } + ) + print("Timeline:", timeline) + + # Example for getting user following + following = tool.call_endpoint( + route="following", + payload={ + "screenname": "elonmusk", + # "cursor": "optional-cursor-value" # Optional for pagination + } + ) + print("Following:", following) + + # Example for getting user followers + followers = tool.call_endpoint( + route="followers", + payload={ + "screenname": "elonmusk", + # "cursor": "optional-cursor-value" # Optional for pagination + } + ) + print("Followers:", followers) + + # Example for searching tweets + search_results = tool.call_endpoint( + route="search", + payload={ + "query": "cybertruck", + "search_type": "Top" # Optional, defaults to Top + # "cursor": "optional-cursor-value" # Optional for pagination + } + ) + print("Search Results:", search_results) + + # Example for getting user replies + replies = tool.call_endpoint( + route="replies", + payload={ + "screenname": "elonmusk", + # "cursor": "optional-cursor-value" # Optional for pagination + } + ) + print("Replies:", replies) + + # Example for checking if user retweeted a tweet + check_retweet = tool.call_endpoint( + route="check_retweet", + payload={ + "screenname": "elonmusk", + "tweet_id": "1671370010743263233" + } + ) + print("Check Retweet:", check_retweet) + + # Example for getting tweet details + tweet = tool.call_endpoint( + route="tweet", + payload={ + "id": "1671370010743263233" + } + ) + print("Tweet:", tweet) + + # Example for getting a tweet thread + tweet_thread = tool.call_endpoint( + route="tweet_thread", + payload={ + "id": "1738106896777699464", + # "cursor": "optional-cursor-value" # Optional for pagination + } + ) + print("Tweet Thread:", tweet_thread) + + # Example for getting retweets of a tweet + retweets = tool.call_endpoint( + route="retweets", + payload={ + "id": "1700199139470942473", + # "cursor": "optional-cursor-value" # Optional for pagination + } + ) + print("Retweets:", retweets) + + # Example for getting latest replies to a tweet + latest_replies = tool.call_endpoint( + route="latest_replies", + payload={ + "id": "1738106896777699464", + # "cursor": "optional-cursor-value" # Optional for pagination + } + ) + print("Latest Replies:", latest_replies) + \ No newline at end of file diff --git a/app/tool/data_providers/YahooFinanceProvider.py b/app/tool/data_providers/YahooFinanceProvider.py new file mode 100644 index 000000000..d18674e72 --- /dev/null +++ b/app/tool/data_providers/YahooFinanceProvider.py @@ -0,0 +1,190 @@ +from typing import Dict + +from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema + + +class YahooFinanceProvider(RapidDataProviderBase): + def __init__(self): + endpoints: Dict[str, EndpointSchema] = { + "get_tickers": { + "route": "/v2/markets/tickers", + "method": "GET", + "name": "Yahoo Finance Tickers", + "description": "Get financial tickers from Yahoo Finance with various filters and parameters.", + "payload": { + "page": "Page number for pagination (optional, default: 1)", + "type": "Asset class type (required): STOCKS, ETF, MUTUALFUNDS, or FUTURES", + } + }, + "search": { + "route": "/v1/markets/search", + "method": "GET", + "name": "Yahoo Finance Search", + "description": "Search for financial instruments on Yahoo Finance", + "payload": { + "search": "Search term (required)", + } + }, + "get_news": { + "route": "/v2/markets/news", + "method": "GET", + "name": "Yahoo Finance News", + "description": "Get news related to specific tickers from Yahoo Finance", + "payload": { + "tickers": "Stock symbol (optional, e.g., AAPL)", + "type": "News type (optional): ALL, VIDEO, or PRESS_RELEASE", + } + }, + "get_stock_module": { + "route": "/v1/markets/stock/modules", + "method": "GET", + "name": "Yahoo Finance Stock Module", + "description": "Get detailed information about a specific stock module", + "payload": { + "ticker": "Company ticker symbol (required, e.g., AAPL)", + "module": "Module to retrieve (required): asset-profile, financial-data, earnings, etc.", + } + }, + "get_sma": { + "route": "/v1/markets/indicators/sma", + "method": "GET", + "name": "Yahoo Finance SMA Indicator", + "description": "Get Simple Moving Average (SMA) indicator data for a stock", + "payload": { + "symbol": "Stock symbol (required, e.g., AAPL)", + "interval": "Time interval (required): 5m, 15m, 30m, 1h, 1d, 1wk, 1mo, 3mo", + "series_type": "Series type (required): open, close, high, low", + "time_period": "Number of data points used for calculation (required)", + "limit": "Limit the number of results (optional, default: 50)", + } + }, + "get_rsi": { + "route": "/v1/markets/indicators/rsi", + "method": "GET", + "name": "Yahoo Finance RSI Indicator", + "description": "Get Relative Strength Index (RSI) indicator data for a stock", + "payload": { + "symbol": "Stock symbol (required, e.g., AAPL)", + "interval": "Time interval (required): 5m, 15m, 30m, 1h, 1d, 1wk, 1mo, 3mo", + "series_type": "Series type (required): open, close, high, low", + "time_period": "Number of data points used for calculation (required)", + "limit": "Limit the number of results (optional, default: 50)", + } + }, + "get_earnings_calendar": { + "route": "/v1/markets/calendar/earnings", + "method": "GET", + "name": "Yahoo Finance Earnings Calendar", + "description": "Get earnings calendar data for a specific date", + "payload": { + "date": "Calendar date in yyyy-mm-dd format (optional, e.g., 2023-11-30)", + } + }, + "get_insider_trades": { + "route": "/v1/markets/insider-trades", + "method": "GET", + "name": "Yahoo Finance Insider Trades", + "description": "Get recent insider trading activity", + "payload": {} + }, + } + base_url = "https://yahoo-finance15.p.rapidapi.com/api" + super().__init__(base_url, endpoints) + + +if __name__ == "__main__": + from dotenv import load_dotenv + load_dotenv() + tool = YahooFinanceProvider() + + # Example for getting stock tickers + tickers_result = tool.call_endpoint( + route="get_tickers", + payload={ + "page": 1, + "type": "STOCKS" + } + ) + print("Tickers Result:", tickers_result) + + # Example for searching financial instruments + search_result = tool.call_endpoint( + route="search", + payload={ + "search": "AA" + } + ) + print("Search Result:", search_result) + + # Example for getting financial news + news_result = tool.call_endpoint( + route="get_news", + payload={ + "tickers": "AAPL", + "type": "ALL" + } + ) + print("News Result:", news_result) + + # Example for getting stock asset profile module + stock_module_result = tool.call_endpoint( + route="get_stock_module", + payload={ + "ticker": "AAPL", + "module": "asset-profile" + } + ) + print("Asset Profile Result:", stock_module_result) + + # Example for getting financial data module + financial_data_result = tool.call_endpoint( + route="get_stock_module", + payload={ + "ticker": "AAPL", + "module": "financial-data" + } + ) + print("Financial Data Result:", financial_data_result) + + # Example for getting SMA indicator data + sma_result = tool.call_endpoint( + route="get_sma", + payload={ + "symbol": "AAPL", + "interval": "5m", + "series_type": "close", + "time_period": "50", + "limit": "50" + } + ) + print("SMA Result:", sma_result) + + # Example for getting RSI indicator data + rsi_result = tool.call_endpoint( + route="get_rsi", + payload={ + "symbol": "AAPL", + "interval": "5m", + "series_type": "close", + "time_period": "50", + "limit": "50" + } + ) + print("RSI Result:", rsi_result) + + # Example for getting earnings calendar data + earnings_calendar_result = tool.call_endpoint( + route="get_earnings_calendar", + payload={ + "date": "2023-11-30" + } + ) + print("Earnings Calendar Result:", earnings_calendar_result) + + # Example for getting insider trades + insider_trades_result = tool.call_endpoint( + route="get_insider_trades", + payload={} + ) + print("Insider Trades Result:", insider_trades_result) + diff --git a/app/tool/data_providers/ZillowProvider.py b/app/tool/data_providers/ZillowProvider.py new file mode 100644 index 000000000..95597f42b --- /dev/null +++ b/app/tool/data_providers/ZillowProvider.py @@ -0,0 +1,187 @@ +from typing import Dict +import logging + +from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema + +logger = logging.getLogger(__name__) + + +class ZillowProvider(RapidDataProviderBase): + def __init__(self): + endpoints: Dict[str, EndpointSchema] = { + "search": { + "route": "/search", + "method": "GET", + "name": "Zillow Property Search", + "description": "Search for properties by neighborhood, city, or ZIP code with various filters.", + "payload": { + "location": "Location can be an address, neighborhood, city, or ZIP code (required)", + "page": "Page number for pagination (optional, default: 0)", + "output": "Output format: json, csv, xlsx (optional, default: json)", + "status": "Status of properties: forSale, forRent, recentlySold (optional, default: forSale)", + "sortSelection": "Sorting criteria (optional, default: priorityscore)", + "listing_type": "Listing type: by_agent, by_owner_other (optional, default: by_agent)", + "doz": "Days on Zillow: any, 1, 7, 14, 30, 90, 6m, 12m, 24m, 36m (optional, default: any)", + "price_min": "Minimum price (optional)", + "price_max": "Maximum price (optional)", + "sqft_min": "Minimum square footage (optional)", + "sqft_max": "Maximum square footage (optional)", + "beds_min": "Minimum number of bedrooms (optional)", + "beds_max": "Maximum number of bedrooms (optional)", + "baths_min": "Minimum number of bathrooms (optional)", + "baths_max": "Maximum number of bathrooms (optional)", + "built_min": "Minimum year built (optional)", + "built_max": "Maximum year built (optional)", + "lotSize_min": "Minimum lot size in sqft (optional)", + "lotSize_max": "Maximum lot size in sqft (optional)", + "keywords": "Keywords to search for (optional)" + } + }, + "search_address": { + "route": "/search_address", + "method": "GET", + "name": "Zillow Address Search", + "description": "Search for a specific property by its full address.", + "payload": { + "address": "Full property address (required)" + } + }, + "propertyV2": { + "route": "/propertyV2", + "method": "GET", + "name": "Zillow Property Details", + "description": "Get detailed information about a specific property by zpid or URL.", + "payload": { + "zpid": "Zillow property ID (optional if URL is provided)", + "url": "Property details URL (optional if zpid is provided)" + } + }, + "zestimate_history": { + "route": "/zestimate_history", + "method": "GET", + "name": "Zillow Zestimate History", + "description": "Get historical Zestimate values for a specific property.", + "payload": { + "zpid": "Zillow property ID (optional if URL is provided)", + "url": "Property details URL (optional if zpid is provided)" + } + }, + "similar_properties": { + "route": "/similar_properties", + "method": "GET", + "name": "Zillow Similar Properties", + "description": "Find properties similar to a specific property.", + "payload": { + "zpid": "Zillow property ID (optional if URL or address is provided)", + "url": "Property details URL (optional if zpid or address is provided)", + "address": "Property address (optional if zpid or URL is provided)" + } + }, + "mortgage_rates": { + "route": "/mortgage/rates", + "method": "GET", + "name": "Zillow Mortgage Rates", + "description": "Get current mortgage rates for different loan programs and conditions.", + "payload": { + "program": "Loan program (required): Fixed30Year, Fixed20Year, Fixed15Year, Fixed10Year, ARM3, ARM5, ARM7, etc.", + "state": "State abbreviation (optional, default: US)", + "refinance": "Whether this is for refinancing (optional, default: false)", + "loanType": "Type of loan: Conventional, etc. (optional)", + "loanAmount": "Loan amount category: Micro, SmallConforming, Conforming, SuperConforming, Jumbo (optional)", + "loanToValue": "Loan to value ratio: Normal, High, VeryHigh (optional)", + "creditScore": "Credit score category: Low, High, VeryHigh (optional)", + "duration": "Duration in days (optional, default: 30)" + } + }, + } + base_url = "https://zillow56.p.rapidapi.com" + super().__init__(base_url, endpoints) + + +if __name__ == "__main__": + from dotenv import load_dotenv + from time import sleep + load_dotenv() + tool = ZillowProvider() + + # Example for searching properties in Houston + search_result = tool.call_endpoint( + route="search", + payload={ + "location": "houston, tx", + "status": "forSale", + "sortSelection": "priorityscore", + "listing_type": "by_agent", + "doz": "any" + } + ) + logger.debug("Search Result: %s", search_result) + logger.debug("***") + logger.debug("***") + logger.debug("***") + sleep(1) + # Example for searching by address + address_result = tool.call_endpoint( + route="search_address", + payload={ + "address": "1161 Natchez Dr College Station Texas 77845" + } + ) + logger.debug("Address Search Result: %s", address_result) + logger.debug("***") + logger.debug("***") + logger.debug("***") + sleep(1) + # Example for getting property details + property_result = tool.call_endpoint( + route="propertyV2", + payload={ + "zpid": "7594920" + } + ) + logger.debug("Property Details Result: %s", property_result) + sleep(1) + logger.debug("***") + logger.debug("***") + logger.debug("***") + + # Example for getting zestimate history + zestimate_result = tool.call_endpoint( + route="zestimate_history", + payload={ + "zpid": "20476226" + } + ) + logger.debug("Zestimate History Result: %s", zestimate_result) + sleep(1) + logger.debug("***") + logger.debug("***") + logger.debug("***") + # Example for getting similar properties + similar_result = tool.call_endpoint( + route="similar_properties", + payload={ + "zpid": "28253016" + } + ) + logger.debug("Similar Properties Result: %s", similar_result) + sleep(1) + logger.debug("***") + logger.debug("***") + logger.debug("***") + # Example for getting mortgage rates + mortgage_result = tool.call_endpoint( + route="mortgage_rates", + payload={ + "program": "Fixed30Year", + "state": "US", + "refinance": "false", + "loanType": "Conventional", + "loanAmount": "Conforming", + "loanToValue": "Normal", + "creditScore": "Low", + "duration": "30" + } + ) + logger.debug("Mortgage Rates Result: %s", mortgage_result) + \ No newline at end of file diff --git a/app/tool/data_providers_tool.py b/app/tool/data_providers_tool.py new file mode 100644 index 000000000..f86ab2886 --- /dev/null +++ b/app/tool/data_providers_tool.py @@ -0,0 +1,188 @@ +import json +from typing import Union, Dict, Any + +from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema +from agent.tools.data_providers.LinkedinProvider import LinkedinProvider +from agent.tools.data_providers.YahooFinanceProvider import YahooFinanceProvider +from agent.tools.data_providers.AmazonProvider import AmazonProvider +from agent.tools.data_providers.ZillowProvider import ZillowProvider +from agent.tools.data_providers.TwitterProvider import TwitterProvider + +class DataProvidersTool(Tool): + """Tool for making requests to various data providers.""" + + def __init__(self): + super().__init__() + + self.register_data_providers = { + "linkedin": LinkedinProvider(), + "yahoo_finance": YahooFinanceProvider(), + "amazon": AmazonProvider(), + "zillow": ZillowProvider(), + "twitter": TwitterProvider() + } + + @openapi_schema({ + "type": "function", + "function": { + "name": "get_data_provider_endpoints", + "description": "Get available endpoints for a specific data provider", + "parameters": { + "type": "object", + "properties": { + "service_name": { + "type": "string", + "description": "The name of the data provider (e.g., 'linkedin', 'twitter', 'zillow', 'amazon', 'yahoo_finance')" + } + }, + "required": ["service_name"] + } + } + }) + @xml_schema( + tag_name="get-data-provider-endpoints", + mappings=[ + {"param_name": "service_name", "node_type": "attribute", "path": "."} + ], + example=''' + + + + + +linkedin + + + ''' + ) + async def get_data_provider_endpoints( + self, + service_name: str + ) -> ToolResult: + """ + Get available endpoints for a specific data provider. + + Parameters: + - service_name: The name of the data provider (e.g., 'linkedin') + """ + try: + if not service_name: + return self.fail_response("Data provider name is required.") + + if service_name not in self.register_data_providers: + return self.fail_response(f"Data provider '{service_name}' not found. Available data providers: {list(self.register_data_providers.keys())}") + + endpoints = self.register_data_providers[service_name].get_endpoints() + return self.success_response(endpoints) + + except Exception as e: + error_message = str(e) + simplified_message = f"Error getting data provider endpoints: {error_message[:200]}" + if len(error_message) > 200: + simplified_message += "..." + return self.fail_response(simplified_message) + + @openapi_schema({ + "type": "function", + "function": { + "name": "execute_data_provider_call", + "description": "Execute a call to a specific data provider endpoint", + "parameters": { + "type": "object", + "properties": { + "service_name": { + "type": "string", + "description": "The name of the API service (e.g., 'linkedin')" + }, + "route": { + "type": "string", + "description": "The key of the endpoint to call" + }, + "payload": { + "type": "object", + "description": "The payload to send with the API call" + } + }, + "required": ["service_name", "route"] + } + } + }) + @xml_schema( + tag_name="execute-data-provider-call", + mappings=[ + {"param_name": "service_name", "node_type": "attribute", "path": "service_name"}, + {"param_name": "route", "node_type": "attribute", "path": "route"}, + {"param_name": "payload", "node_type": "content", "path": "."} + ], + example=''' + + + + + + linkedin + person + {"link": "https://www.linkedin.com/in/johndoe/"} + + + ''' + ) + async def execute_data_provider_call( + self, + service_name: str, + route: str, + payload: Union[Dict[str, Any], str, None] = None + ) -> ToolResult: + """ + Execute a call to a specific data provider endpoint. + + Parameters: + - service_name: The name of the data provider (e.g., 'linkedin') + - route: The key of the endpoint to call + - payload: The payload to send with the data provider call (dict or JSON string) + """ + try: + # Handle payload - it can be either a dict or a JSON string + if isinstance(payload, str): + try: + payload = json.loads(payload) + except json.JSONDecodeError as e: + return self.fail_response(f"Invalid JSON in payload: {str(e)}") + elif payload is None: + payload = {} + # If payload is already a dict, use it as-is + + if not service_name: + return self.fail_response("service_name is required.") + + if not route: + return self.fail_response("route is required.") + + if service_name not in self.register_data_providers: + return self.fail_response(f"API '{service_name}' not found. Available APIs: {list(self.register_data_providers.keys())}") + + data_provider = self.register_data_providers[service_name] + if route == service_name: + return self.fail_response(f"route '{route}' is the same as service_name '{service_name}'. YOU FUCKING IDIOT!") + + if route not in data_provider.get_endpoints().keys(): + return self.fail_response(f"Endpoint '{route}' not found in {service_name} data provider.") + + + result = data_provider.call_endpoint(route, payload) + return self.success_response(result) + + except Exception as e: + error_message = str(e) + print(error_message) + simplified_message = f"Error executing data provider call: {error_message[:200]}" + if len(error_message) > 200: + simplified_message += "..." + return self.fail_response(simplified_message) diff --git a/app/tool/expand_msg_tool.py b/app/tool/expand_msg_tool.py new file mode 100644 index 000000000..9d653261a --- /dev/null +++ b/app/tool/expand_msg_tool.py @@ -0,0 +1,103 @@ +from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema +from agentpress.thread_manager import ThreadManager +import json + +class ExpandMessageTool(Tool): + """Tool for expanding a previous message to the user.""" + + def __init__(self, thread_id: str, thread_manager: ThreadManager): + super().__init__() + self.thread_manager = thread_manager + self.thread_id = thread_id + + @openapi_schema({ + "type": "function", + "function": { + "name": "expand_message", + "description": "Expand a message from the previous conversation with the user. Use this tool to expand a message that was truncated in the earlier conversation.", + "parameters": { + "type": "object", + "properties": { + "message_id": { + "type": "string", + "description": "The ID of the message to expand. Must be a UUID." + } + }, + "required": ["message_id"] + } + } + }) + @xml_schema( + tag_name="expand-message", + mappings=[ + {"param_name": "message_id", "node_type": "attribute", "path": "."} + ], + example=''' + + + + ecde3a4c-c7dc-4776-ae5c-8209517c5576 + + + + + + + f47ac10b-58cc-4372-a567-0e02b2c3d479 + + + + + + + 550e8400-e29b-41d4-a716-446655440000 + + + ''' + ) + async def expand_message(self, message_id: str) -> ToolResult: + """Expand a message from the previous conversation with the user. + + Args: + message_id: The ID of the message to expand + + Returns: + ToolResult indicating the message was successfully expanded + """ + try: + client = await self.thread_manager.db.client + message = await client.table('messages').select('*').eq('message_id', message_id).eq('thread_id', self.thread_id).execute() + + if not message.data or len(message.data) == 0: + return self.fail_response(f"Message with ID {message_id} not found in thread {self.thread_id}") + + message_data = message.data[0] + message_content = message_data['content'] + final_content = message_content + if isinstance(message_content, dict) and 'content' in message_content: + final_content = message_content['content'] + elif isinstance(message_content, str): + try: + parsed_content = json.loads(message_content) + if isinstance(parsed_content, dict) and 'content' in parsed_content: + final_content = parsed_content['content'] + except json.JSONDecodeError: + pass + + return self.success_response({"status": "Message expanded successfully.", "message": final_content}) + except Exception as e: + return self.fail_response(f"Error expanding message: {str(e)}") + +if __name__ == "__main__": + import asyncio + + async def test_expand_message_tool(): + expand_message_tool = ExpandMessageTool() + + # Test expand message + expand_message_result = await expand_message_tool.expand_message( + message_id="004ab969-ef9a-4656-8aba-e392345227cd" + ) + print("Expand message result:", expand_message_result) + + asyncio.run(test_expand_message_tool()) \ No newline at end of file diff --git a/app/tool/mcp_tool_wrapper.py b/app/tool/mcp_tool_wrapper.py new file mode 100644 index 000000000..31f331235 --- /dev/null +++ b/app/tool/mcp_tool_wrapper.py @@ -0,0 +1,715 @@ +""" +MCP Tool Wrapper for AgentPress + +This module provides a generic tool wrapper that handles all MCP (Model Context Protocol) +server tool calls through dynamically generated individual function methods. +""" + +import json +from typing import Any, Dict, List, Optional +from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema, ToolSchema, SchemaType +from mcp_local.client import MCPManager +from utils.logger import logger +import inspect +from mcp import ClientSession +from mcp.client.sse import sse_client +from mcp.client.stdio import stdio_client +from mcp.client.streamable_http import streamablehttp_client +from mcp import StdioServerParameters +import asyncio + + +class MCPToolWrapper(Tool): + """ + A generic tool wrapper that dynamically creates individual methods for each MCP tool. + + This tool creates separate function calls for each MCP tool while routing them all + through the same underlying implementation. + """ + + def __init__(self, mcp_configs: Optional[List[Dict[str, Any]]] = None): + """ + Initialize the MCP tool wrapper. + + Args: + mcp_configs: List of MCP configurations from agent's configured_mcps + """ + # Don't call super().__init__() yet - we need to set up dynamic methods first + self.mcp_manager = MCPManager() + self.mcp_configs = mcp_configs or [] + self._initialized = False + self._dynamic_tools = {} + self._schemas: Dict[str, List[ToolSchema]] = {} + self._custom_tools = {} # Store custom MCP tools separately + + # Now initialize the parent class which will call _register_schemas + super().__init__() + + async def _ensure_initialized(self): + """Ensure MCP servers are initialized.""" + if not self._initialized: + # Initialize standard MCP servers from Smithery + standard_configs = [cfg for cfg in self.mcp_configs if not cfg.get('isCustom', False)] + custom_configs = [cfg for cfg in self.mcp_configs if cfg.get('isCustom', False)] + + # Initialize standard MCPs through MCPManager + if standard_configs: + for config in standard_configs: + try: + logger.info(f"Attempting to connect to MCP server: {config['qualifiedName']}") + await self.mcp_manager.connect_server(config) + logger.info(f"Successfully connected to MCP server: {config['qualifiedName']}") + except Exception as e: + logger.error(f"Failed to connect to MCP server {config['qualifiedName']}: {e}") + import traceback + logger.error(f"Full traceback: {traceback.format_exc()}") + + # Initialize custom MCPs directly + if custom_configs: + await self._initialize_custom_mcps(custom_configs) + + # Create dynamic tools for all connected servers + await self._create_dynamic_tools() + self._initialized = True + + async def _connect_sse_server(self, server_name, server_config, all_tools, timeout): + url = server_config["url"] + headers = server_config.get("headers", {}) + + async with asyncio.timeout(timeout): + try: + async with sse_client(url, headers=headers) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools_result = await session.list_tools() + tools_info = [] + for tool in tools_result.tools: + tool_info = { + "name": tool.name, + "description": tool.description, + "input_schema": tool.inputSchema + } + tools_info.append(tool_info) + + all_tools[server_name] = { + "status": "connected", + "transport": "sse", + "url": url, + "tools": tools_info + } + + logger.info(f" {server_name}: Connected via SSE ({len(tools_info)} tools)") + except TypeError as e: + if "unexpected keyword argument" in str(e): + async with sse_client(url) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools_result = await session.list_tools() + tools_info = [] + for tool in tools_result.tools: + tool_info = { + "name": tool.name, + "description": tool.description, + "input_schema": tool.inputSchema + } + tools_info.append(tool_info) + + all_tools[server_name] = { + "status": "connected", + "transport": "sse", + "url": url, + "tools": tools_info + } + logger.info(f" {server_name}: Connected via SSE ({len(tools_info)} tools)") + else: + raise + + async def _connect_streamable_http_server(self, url): + async with streamablehttp_client(url) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + tool_result = await session.list_tools() + print(f"Connected via HTTP ({len(tool_result.tools)} tools)") + + tools_info = [] + for tool in tool_result.tools: + tool_info = { + "name": tool.name, + "description": tool.description, + "inputSchema": tool.inputSchema + } + tools_info.append(tool_info) + + return tools_info + + async def _connect_stdio_server(self, server_name, server_config, all_tools, timeout): + """Connect to a stdio-based MCP server.""" + server_params = StdioServerParameters( + command=server_config["command"], + args=server_config.get("args", []), + env=server_config.get("env", {}) + ) + + async with asyncio.timeout(timeout): + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools_result = await session.list_tools() + tools_info = [] + for tool in tools_result.tools: + tool_info = { + "name": tool.name, + "description": tool.description, + "input_schema": tool.inputSchema + } + tools_info.append(tool_info) + + all_tools[server_name] = { + "status": "connected", + "transport": "stdio", + "tools": tools_info + } + + logger.info(f" {server_name}: Connected via stdio ({len(tools_info)} tools)") + + async def _initialize_custom_mcps(self, custom_configs): + """Initialize custom MCP servers.""" + for config in custom_configs: + try: + logger.info(f"Initializing custom MCP: {config}") + custom_type = config.get('customType', 'sse') + server_config = config.get('config', {}) + enabled_tools = config.get('enabledTools', []) + server_name = config.get('name', 'Unknown') + + logger.info(f"Initializing custom MCP: {server_name} (type: {custom_type})") + + if custom_type == 'sse': + if 'url' not in server_config: + logger.error(f"Custom MCP {server_name}: Missing 'url' in config") + continue + + url = server_config['url'] + logger.info(f"Initializing custom MCP {url} with SSE type") + + try: + # Use the working connect_sse_server method + all_tools = {} + await self._connect_sse_server(server_name, server_config, all_tools, 15) + + # Process the results + if server_name in all_tools and all_tools[server_name].get('status') == 'connected': + tools_info = all_tools[server_name].get('tools', []) + tools_registered = 0 + + for tool_info in tools_info: + tool_name_from_server = tool_info['name'] + if not enabled_tools or tool_name_from_server in enabled_tools: + tool_name = f"custom_{server_name.replace(' ', '_').lower()}_{tool_name_from_server}" + self._custom_tools[tool_name] = { + 'name': tool_name, + 'description': tool_info['description'], + 'parameters': tool_info['input_schema'], + 'server': server_name, + 'original_name': tool_name_from_server, + 'is_custom': True, + 'custom_type': custom_type, + 'custom_config': server_config + } + tools_registered += 1 + logger.debug(f"Registered custom tool: {tool_name}") + + logger.info(f"Successfully initialized custom MCP {server_name} with {tools_registered} tools") + else: + logger.error(f"Failed to connect to custom MCP {server_name}") + + except Exception as e: + logger.error(f"Custom MCP {server_name}: Connection failed - {str(e)}") + continue + + elif custom_type == 'http': + if 'url' not in server_config: + logger.error(f"Custom MCP {server_name}: Missing 'url' in config") + continue + + url = server_config['url'] + logger.info(f"Initializing custom MCP {url} with HTTP type") + + try: + + tools_info = await self._connect_streamable_http_server(url) + tools_registered = 0 + + for tool_info in tools_info: + tool_name_from_server = tool_info['name'] + if not enabled_tools or tool_name_from_server in enabled_tools: + tool_name = f"custom_{server_name.replace(' ', '_').lower()}_{tool_name_from_server}" + self._custom_tools[tool_name] = { + 'name': tool_name, + 'description': tool_info['description'], + 'parameters': tool_info['inputSchema'], + 'server': server_name, + 'original_name': tool_name_from_server, + 'is_custom': True, + 'custom_type': custom_type, + 'custom_config': server_config + } + tools_registered += 1 + logger.debug(f"Registered custom tool: {tool_name}") + + logger.info(f"Successfully initialized custom MCP {server_name} with {tools_registered} tools") + + except Exception as e: + logger.error(f"Custom MCP {server_name}: Connection failed - {str(e)}") + continue + + elif custom_type == 'json': + if 'command' not in server_config: + logger.error(f"Custom MCP {server_name}: Missing 'command' in config") + continue + + logger.info(f"Initializing custom MCP {server_name} with JSON/stdio type") + + try: + # Use the stdio connection method + all_tools = {} + await self._connect_stdio_server(server_name, server_config, all_tools, 15) + + # Process the results + if server_name in all_tools and all_tools[server_name].get('status') == 'connected': + tools_info = all_tools[server_name].get('tools', []) + tools_registered = 0 + + for tool_info in tools_info: + tool_name_from_server = tool_info['name'] + if not enabled_tools or tool_name_from_server in enabled_tools: + tool_name = f"custom_{server_name.replace(' ', '_').lower()}_{tool_name_from_server}" + self._custom_tools[tool_name] = { + 'name': tool_name, + 'description': tool_info['description'], + 'parameters': tool_info['input_schema'], + 'server': server_name, + 'original_name': tool_name_from_server, + 'is_custom': True, + 'custom_type': custom_type, + 'custom_config': server_config + } + tools_registered += 1 + logger.debug(f"Registered custom tool: {tool_name}") + + logger.info(f"Successfully initialized custom MCP {server_name} with {tools_registered} tools") + else: + logger.error(f"Failed to connect to custom MCP {server_name}") + + except Exception as e: + logger.error(f"Custom MCP {server_name}: Connection failed - {str(e)}") + continue + + else: + logger.error(f"Custom MCP {server_name}: Unsupported type '{custom_type}', supported types are 'sse', 'http' and 'json'") + continue + + except Exception as e: + logger.error(f"Failed to initialize custom MCP {config.get('name', 'Unknown')}: {e}") + continue + + async def initialize_and_register_tools(self, tool_registry=None): + """Initialize MCP tools and optionally update the tool registry. + + This method should be called after the tool has been registered to dynamically + add the MCP tool schemas to the registry. + + Args: + tool_registry: Optional ToolRegistry instance to update with new schemas + """ + await self._ensure_initialized() + + if tool_registry and self._dynamic_tools: + logger.info(f"Updating tool registry with {len(self._dynamic_tools)} MCP tools") + for method_name, schemas in self._schemas.items(): + if method_name not in ['call_mcp_tool']: # Skip the fallback method + pass + + async def _create_dynamic_tools(self): + """Create dynamic tool methods for each available MCP tool.""" + try: + # Get standard MCP tools + available_tools = self.mcp_manager.get_all_tools_openapi() + logger.info(f"MCPManager returned {len(available_tools)} tools") + + for tool_info in available_tools: + tool_name = tool_info.get('name', '') + logger.info(f"Processing tool: {tool_name}") + if tool_name: + # Create a dynamic method for this tool with proper OpenAI schema + self._create_dynamic_method(tool_name, tool_info) + + # Get custom MCP tools + logger.info(f"Processing {len(self._custom_tools)} custom MCP tools") + for tool_name, tool_info in self._custom_tools.items(): + logger.info(f"Processing custom tool: {tool_name}") + # Convert custom tool info to the expected format + openapi_tool_info = { + "name": tool_name, + "description": tool_info['description'], + "parameters": tool_info['parameters'] + } + self._create_dynamic_method(tool_name, openapi_tool_info) + + logger.info(f"Created {len(self._dynamic_tools)} dynamic MCP tool methods") + + except Exception as e: + logger.error(f"Error creating dynamic MCP tools: {e}") + + def _create_dynamic_method(self, tool_name: str, tool_info: Dict[str, Any]): + """Create a dynamic method for a specific MCP tool with proper OpenAI schema.""" + if tool_name.startswith("custom_"): + if tool_name in self._custom_tools: + clean_tool_name = self._custom_tools[tool_name]['original_name'] + server_name = self._custom_tools[tool_name]['server'] + else: + parts = tool_name.split("_") + if len(parts) >= 3: + clean_tool_name = "_".join(parts[2:]) + server_name = parts[1] if len(parts) > 1 else "unknown" + else: + clean_tool_name = tool_name + server_name = "unknown" + else: + parts = tool_name.split("_", 2) + clean_tool_name = parts[2] if len(parts) > 2 else tool_name + server_name = parts[1] if len(parts) > 1 else "unknown" + + method_name = clean_tool_name.replace('-', '_') + + logger.info(f"Creating dynamic method for tool '{tool_name}': clean_tool_name='{clean_tool_name}', method_name='{method_name}', server='{server_name}'") + + original_full_name = tool_name + + # Create the dynamic method + async def dynamic_tool_method(**kwargs) -> ToolResult: + """Dynamically created method for MCP tool.""" + # Use the original full tool name for execution + return await self._execute_mcp_tool(original_full_name, kwargs) + + # Set the method name to match the tool name + dynamic_tool_method.__name__ = method_name + dynamic_tool_method.__qualname__ = f"{self.__class__.__name__}.{method_name}" + + # Build a more descriptive description + base_description = tool_info.get("description", f"MCP tool from {server_name}") + full_description = f"{base_description} (MCP Server: {server_name})" + + # Create the OpenAI schema for this tool + openapi_function_schema = { + "type": "function", + "function": { + "name": method_name, # Use the clean method name for function calling + "description": full_description, + "parameters": tool_info.get("parameters", { + "type": "object", + "properties": {}, + "required": [] + }) + } + } + + # Create a ToolSchema object + tool_schema = ToolSchema( + schema_type=SchemaType.OPENAPI, + schema=openapi_function_schema + ) + + # Add the schema to our schemas dict + self._schemas[method_name] = [tool_schema] + + # Also add the schema to the method itself (for compatibility) + dynamic_tool_method.tool_schemas = [tool_schema] + + # Store the method and its info + self._dynamic_tools[tool_name] = { + 'method': dynamic_tool_method, + 'method_name': method_name, + 'original_tool_name': tool_name, + 'clean_tool_name': clean_tool_name, + 'server_name': server_name, + 'info': tool_info, + 'schema': tool_schema + } + + # Add the method to this instance + setattr(self, method_name, dynamic_tool_method) + + logger.debug(f"Created dynamic method '{method_name}' for MCP tool '{tool_name}' from server '{server_name}'") + + def _register_schemas(self): + """Register schemas from all decorated methods and dynamic tools.""" + # First register static schemas from decorated methods + for name, method in inspect.getmembers(self, predicate=inspect.ismethod): + if hasattr(method, 'tool_schemas'): + self._schemas[name] = method.tool_schemas + logger.debug(f"Registered schemas for method '{name}' in {self.__class__.__name__}") + + # Note: Dynamic schemas will be added after async initialization + logger.debug(f"Initial registration complete for MCPToolWrapper") + + def get_schemas(self) -> Dict[str, List[ToolSchema]]: + """Get all registered tool schemas including dynamic ones.""" + # Return all schemas including dynamically added ones + return self._schemas + + def __getattr__(self, name: str): + """Handle calls to dynamically created MCP tool methods.""" + # Look for exact method name match first + for tool_data in self._dynamic_tools.values(): + if tool_data['method_name'] == name: + return tool_data['method'] + + # Try with underscore/hyphen conversion + name_with_hyphens = name.replace('_', '-') + for tool_name, tool_data in self._dynamic_tools.items(): + if tool_data['method_name'] == name or tool_name == name_with_hyphens: + return tool_data['method'] + + raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") + + async def get_available_tools(self) -> List[Dict[str, Any]]: + """Get all available MCP tools in OpenAPI format.""" + await self._ensure_initialized() + return self.mcp_manager.get_all_tools_openapi() + + async def _execute_mcp_tool(self, tool_name: str, arguments: Dict[str, Any]) -> ToolResult: + """Execute an MCP tool call.""" + await self._ensure_initialized() + logger.info(f"Executing MCP tool {tool_name} with arguments {arguments}") + try: + # Check if it's a custom MCP tool first + if tool_name in self._custom_tools: + tool_info = self._custom_tools[tool_name] + return await self._execute_custom_mcp_tool(tool_name, arguments, tool_info) + else: + # Use standard MCP manager for Smithery servers + result = await self.mcp_manager.execute_tool(tool_name, arguments) + + if isinstance(result, dict): + if result.get('isError', False): + return self.fail_response(result.get('content', 'Tool execution failed')) + else: + return self.success_response(result.get('content', result)) + else: + return self.success_response(result) + + except Exception as e: + logger.error(f"Error executing MCP tool {tool_name}: {str(e)}") + return self.fail_response(f"Error executing tool: {str(e)}") + + async def _execute_custom_mcp_tool(self, tool_name: str, arguments: Dict[str, Any], tool_info: Dict[str, Any]) -> ToolResult: + """Execute a custom MCP tool call.""" + try: + custom_type = tool_info['custom_type'] + custom_config = tool_info['custom_config'] + original_tool_name = tool_info['original_name'] + + if custom_type == 'sse': + # Execute SSE-based custom MCP using the same pattern as _connect_sse_server + url = custom_config['url'] + headers = custom_config.get('headers', {}) + + async with asyncio.timeout(30): # 30 second timeout for tool execution + try: + # Try with headers first (same pattern as _connect_sse_server) + async with sse_client(url, headers=headers) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool(original_tool_name, arguments) + + # Handle the result properly + if hasattr(result, 'content'): + content = result.content + if isinstance(content, list): + # Extract text from content list + text_parts = [] + for item in content: + if hasattr(item, 'text'): + text_parts.append(item.text) + else: + text_parts.append(str(item)) + content_str = "\n".join(text_parts) + elif hasattr(content, 'text'): + content_str = content.text + else: + content_str = str(content) + + return self.success_response(content_str) + else: + return self.success_response(str(result)) + + except TypeError as e: + if "unexpected keyword argument" in str(e): + # Fallback: try without headers (exact pattern from _connect_sse_server) + async with sse_client(url) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool(original_tool_name, arguments) + + # Handle the result properly + if hasattr(result, 'content'): + content = result.content + if isinstance(content, list): + # Extract text from content list + text_parts = [] + for item in content: + if hasattr(item, 'text'): + text_parts.append(item.text) + else: + text_parts.append(str(item)) + content_str = "\n".join(text_parts) + elif hasattr(content, 'text'): + content_str = content.text + else: + content_str = str(content) + + return self.success_response(content_str) + else: + return self.success_response(str(result)) + else: + raise + + elif custom_type == 'http': + # Execute HTTP-based custom MCP + url = custom_config['url'] + + async with asyncio.timeout(30): # 30 second timeout for tool execution + async with streamablehttp_client(url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool(original_tool_name, arguments) + + # Handle the result properly + if hasattr(result, 'content'): + content = result.content + if isinstance(content, list): + # Extract text from content list + text_parts = [] + for item in content: + if hasattr(item, 'text'): + text_parts.append(item.text) + else: + text_parts.append(str(item)) + content_str = "\n".join(text_parts) + elif hasattr(content, 'text'): + content_str = content.text + else: + content_str = str(content) + + return self.success_response(content_str) + else: + return self.success_response(str(result)) + + elif custom_type == 'json': + # Execute stdio-based custom MCP using the same pattern as _connect_stdio_server + server_params = StdioServerParameters( + command=custom_config["command"], + args=custom_config.get("args", []), + env=custom_config.get("env", {}) + ) + + async with asyncio.timeout(30): # 30 second timeout for tool execution + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool(original_tool_name, arguments) + + # Handle the result properly + if hasattr(result, 'content'): + content = result.content + if isinstance(content, list): + # Extract text from content list + text_parts = [] + for item in content: + if hasattr(item, 'text'): + text_parts.append(item.text) + else: + text_parts.append(str(item)) + content_str = "\n".join(text_parts) + elif hasattr(content, 'text'): + content_str = content.text + else: + content_str = str(content) + + return self.success_response(content_str) + else: + return self.success_response(str(result)) + else: + return self.fail_response(f"Unsupported custom MCP type: {custom_type}") + + except asyncio.TimeoutError: + return self.fail_response(f"Tool execution timeout for {tool_name}") + except Exception as e: + logger.error(f"Error executing custom MCP tool {tool_name}: {str(e)}") + return self.fail_response(f"Error executing custom tool: {str(e)}") + + # Keep the original call_mcp_tool method as a fallback + @openapi_schema({ + "type": "function", + "function": { + "name": "call_mcp_tool", + "description": "Execute a tool from any connected MCP server. This is a fallback wrapper that forwards calls to MCP tools. The tool_name should be in the format 'mcp_{server}_{tool}' where {server} is the MCP server's qualified name and {tool} is the specific tool name.", + "parameters": { + "type": "object", + "properties": { + "tool_name": { + "type": "string", + "description": "The full MCP tool name in format 'mcp_{server}_{tool}', e.g., 'mcp_exa_web_search_exa'" + }, + "arguments": { + "type": "object", + "description": "The arguments to pass to the MCP tool, as a JSON object. The required arguments depend on the specific tool being called.", + "additionalProperties": True + } + }, + "required": ["tool_name", "arguments"] + } + } + }) + @xml_schema( + tag_name="call-mcp-tool", + mappings=[ + {"param_name": "tool_name", "node_type": "attribute", "path": "."}, + {"param_name": "arguments", "node_type": "content", "path": "."} + ], + example=''' + + + mcp_exa_web_search_exa + {"query": "latest developments in AI", "num_results": 10} + + + ''' + ) + async def call_mcp_tool(self, tool_name: str, arguments: Dict[str, Any]) -> ToolResult: + """ + Execute an MCP tool call (fallback method). + + Args: + tool_name: The full MCP tool name (e.g., "mcp_exa_web_search_exa") + arguments: The arguments to pass to the tool + + Returns: + ToolResult with the tool execution result + """ + return await self._execute_mcp_tool(tool_name, arguments) + + async def cleanup(self): + """Disconnect all MCP servers.""" + if self._initialized: + try: + await self.mcp_manager.disconnect_all() + except Exception as e: + logger.error(f"Error during MCP cleanup: {str(e)}") + finally: + self._initialized = False \ No newline at end of file diff --git a/app/tool/message_tool.py b/app/tool/message_tool.py new file mode 100644 index 000000000..eef3ef599 --- /dev/null +++ b/app/tool/message_tool.py @@ -0,0 +1,270 @@ +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. + + This tool provides methods for asking questions, with support for + attachments and user takeover suggestions. + """ + + def __init__(self): + super().__init__() + + # Commented out as we are just doing this via prompt as there is no need to call it as a tool + + @openapi_schema({ + "type": "function", + "function": { + "name": "ask", + "description": "Ask user a question and wait for response. Use for: 1) Requesting clarification on ambiguous requirements, 2) Seeking confirmation before proceeding with high-impact changes, 3) Gathering additional information needed to complete a task, 4) Offering options and requesting user preference, 5) Validating assumptions when critical to task success. IMPORTANT: Use this tool only when user input is essential to proceed. Always provide clear context and options when applicable. Include relevant attachments when the question relates to specific files or resources.", + "parameters": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Question text to present to user - should be specific and clearly indicate what information you need. Include: 1) Clear question or request, 2) Context about why the input is needed, 3) Available options if applicable, 4) Impact of different choices, 5) Any relevant constraints or considerations." + }, + "attachments": { + "anyOf": [ + {"type": "string"}, + {"items": {"type": "string"}, "type": "array"} + ], + "description": "(Optional) List of files or URLs to attach to the question. Include when: 1) Question relates to specific files or configurations, 2) User needs to review content before answering, 3) Options or choices are documented in files, 4) Supporting evidence or context is needed. Always use relative paths to /workspace directory." + } + }, + "required": ["text"] + } + } + }) + @xml_schema( + tag_name="ask", + mappings=[ + {"param_name": "text", "node_type": "content", "path": "."}, + {"param_name": "attachments", "node_type": "attribute", "path": ".", "required": False} + ], + example=''' + + + I'm planning to bake the chocolate cake for your birthday party. The recipe mentions "rich frosting" but doesn't specify what type. Could you clarify your preferences? For example: +1. Would you prefer buttercream or cream cheese frosting? +2. Do you want any specific flavor added to the frosting (vanilla, coffee, etc.)? +3. Should I add any decorative toppings like sprinkles or fruit? +4. Do you have any dietary restrictions I should be aware of? + +This information will help me make sure the cake meets your expectations for the celebration. + recipes/chocolate_cake.txt,photos/cake_examples.jpg + + + ''' + ) + async def ask(self, text: str, attachments: Optional[Union[str, List[str]]] = None) -> ToolResult: + """Ask the user a question and wait for a response. + + Args: + text: The question to present to the user + attachments: Optional file paths or URLs to attach to the question + + Returns: + ToolResult indicating the question was successfully sent + """ + 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)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "web_browser_takeover", + "description": "Request user takeover of browser interaction. Use this tool when: 1) The page requires complex human interaction that automated tools cannot handle, 2) Authentication or verification steps require human input, 3) The page has anti-bot measures that prevent automated access, 4) Complex form filling or navigation is needed, 5) The page requires human verification (CAPTCHA, etc.). IMPORTANT: This tool should be used as a last resort after web-search and crawl-webpage have failed, and when direct browser tools are insufficient. Always provide clear context about why takeover is needed and what actions the user should take.", + "parameters": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Instructions for the user about what actions to take in the browser. Include: 1) Clear explanation of why takeover is needed, 2) Specific steps the user should take, 3) What information to look for or extract, 4) How to indicate when they're done, 5) Any important context about the current page state." + }, + "attachments": { + "anyOf": [ + {"type": "string"}, + {"items": {"type": "string"}, "type": "array"} + ], + "description": "(Optional) List of files or URLs to attach to the takeover request. Include when: 1) Screenshots or visual references are needed, 2) Previous search results or crawled content is relevant, 3) Supporting documentation is required. Always use relative paths to /workspace directory." + } + }, + "required": ["text"] + } + } + }) + @xml_schema( + tag_name="web-browser-takeover", + mappings=[ + {"param_name": "text", "node_type": "content", "path": "."}, + {"param_name": "attachments", "node_type": "attribute", "path": ".", "required": False} + ], + example=''' + + + I've encountered a CAPTCHA verification on the page. Please: +1. Solve the CAPTCHA puzzle +2. Let me know once you've completed it +3. I'll then continue with the automated process + +If you encounter any issues or need to take additional steps, please let me know. + + + ''' + ) + async def web_browser_takeover(self, text: str, attachments: Optional[Union[str, List[str]]] = None) -> ToolResult: + """Request user takeover of browser interaction. + + Args: + text: Instructions for the user about what actions to take + attachments: Optional file paths or URLs to attach to the request + + Returns: + ToolResult indicating the takeover request was successfully sent + """ + try: + # Convert single attachment to list for consistent handling + if attachments and isinstance(attachments, str): + attachments = [attachments] + + return self.success_response({"status": "Awaiting user browser takeover..."}) + except Exception as e: + return self.fail_response(f"Error requesting browser takeover: {str(e)}") + +# @openapi_schema({ +# "type": "function", +# "function": { +# "name": "inform", +# "description": "Inform the user about progress, completion of a major step, or important context. Use this tool: 1) To provide updates between major sections of work, 2) After accomplishing significant milestones, 3) When transitioning to a new phase of work, 4) To confirm actions were completed successfully, 5) To provide context about upcoming steps. IMPORTANT: Use FREQUENTLY throughout execution to provide UI context to the user. The user CANNOT respond to this tool - they can only respond to the 'ask' tool. Use this tool to keep the user informed without requiring their input.", +# "parameters": { +# "type": "object", +# "properties": { +# "text": { +# "type": "string", +# "description": "Information to present to the user. Include: 1) Clear statement of what has been accomplished or what is happening, 2) Relevant context or impact, 3) Brief indication of next steps if applicable." +# }, +# "attachments": { +# "anyOf": [ +# {"type": "string"}, +# {"items": {"type": "string"}, "type": "array"} +# ], +# "description": "(Optional) List of files or URLs to attach to the information. Include when: 1) Information relates to specific files or resources, 2) Showing intermediate results or outputs, 3) Providing supporting documentation. Always use relative paths to /workspace directory." +# } +# }, +# "required": ["text"] +# } +# } +# }) +# @xml_schema( +# tag_name="inform", +# mappings=[ +# {"param_name": "text", "node_type": "content", "path": "."}, +# {"param_name": "attachments", "node_type": "attribute", "path": ".", "required": False} +# ], +# example=''' + +# Inform the user about progress, completion of a major step, or important context. Use this tool: 1) To provide updates between major sections of work, 2) After accomplishing significant milestones, 3) When transitioning to a new phase of work, 4) To confirm actions were completed successfully, 5) To provide context about upcoming steps. IMPORTANT: Use FREQUENTLY throughout execution to provide UI context to the user. The user CANNOT respond to this tool - they can only respond to the 'ask' tool. Use this tool to keep the user informed without requiring their input." + +# +# +# +# +# +# +# +# +# + +# +# I've completed the data analysis of the sales figures. Key findings include: +# - Q4 sales were 28% higher than Q3 +# - Product line A showed the strongest performance +# - Three regions missed their targets + +# I'll now proceed with creating the executive summary report based on these findings. +# +# ''' +# ) +# async def inform(self, text: str, attachments: Optional[Union[str, List[str]]] = None) -> ToolResult: +# """Inform the user about progress or important updates without requiring a response. + +# Args: +# text: The information to present to the user +# attachments: Optional file paths or URLs to attach + +# Returns: +# ToolResult indicating the information was successfully sent +# """ +# try: +# # Convert single attachment to list for consistent handling +# if attachments and isinstance(attachments, str): +# attachments = [attachments] + +# return self.success_response({"status": "Information sent"}) +# except Exception as e: +# return self.fail_response(f"Error informing user: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "complete", + "description": "A special tool to indicate you have completed all tasks and are about to enter complete state. Use ONLY when: 1) All tasks in todo.md are marked complete [x], 2) The user's original request has been fully addressed, 3) There are no pending actions or follow-ups required, 4) You've delivered all final outputs and results to the user. IMPORTANT: This is the ONLY way to properly terminate execution. Never use this tool unless ALL tasks are complete and verified. Always ensure you've provided all necessary outputs and references before using this tool.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }) + @xml_schema( + tag_name="complete", + mappings=[], + example=''' + + + + + ''' + ) + async def complete(self) -> ToolResult: + """Indicate that the agent has completed all tasks and is entering complete state. + + Returns: + ToolResult indicating successful transition to complete state + """ + try: + return self.success_response({"status": "complete"}) + except Exception as e: + return self.fail_response(f"Error entering complete state: {str(e)}") + + +if __name__ == "__main__": + import asyncio + + async def test_message_tool(): + message_tool = MessageTool() + + # Test question + ask_result = await message_tool.ask( + text="Would you like to proceed with the next phase?", + attachments="summary.pdf" + ) + print("Question result:", ask_result) + + # Test inform + inform_result = await message_tool.inform( + text="Completed analysis of data. Processing results now.", + attachments="analysis.pdf" + ) + print("Inform result:", inform_result) + + asyncio.run(test_message_tool()) diff --git a/app/tool/sb_browser_tool.py b/app/tool/sb_browser_tool.py new file mode 100644 index 000000000..74bcfe601 --- /dev/null +++ b/app/tool/sb_browser_tool.py @@ -0,0 +1,1052 @@ +import traceback +import json +import base64 +import io +from PIL import Image + +from agentpress.tool import ToolResult, openapi_schema, xml_schema +from agentpress.thread_manager import ThreadManager +from sandbox.tool_base import SandboxToolsBase +from utils.logger import logger +from utils.s3_upload_utils import upload_base64_image + + +class SandboxBrowserTool(SandboxToolsBase): + """Tool for executing tasks in a Daytona sandbox with browser-use capabilities.""" + + def __init__(self, project_id: str, thread_id: str, thread_manager: ThreadManager): + super().__init__(project_id, thread_manager) + self.thread_id = thread_id + + def _validate_base64_image(self, base64_string: str, max_size_mb: int = 10) -> tuple[bool, str]: + """ + Comprehensive validation of base64 image data. + + Args: + base64_string (str): The base64 encoded image data + max_size_mb (int): Maximum allowed image size in megabytes + + Returns: + tuple[bool, str]: (is_valid, error_message) + """ + try: + # Check if data exists and has reasonable length + if not base64_string or len(base64_string) < 10: + return False, "Base64 string is empty or too short" + + # Remove data URL prefix if present (data:image/jpeg;base64,...) + if base64_string.startswith('data:'): + try: + base64_string = base64_string.split(',', 1)[1] + except (IndexError, ValueError): + return False, "Invalid data URL format" + + # Check if string contains only valid base64 characters + # Base64 alphabet: A-Z, a-z, 0-9, +, /, = (padding) + import re + if not re.match(r'^[A-Za-z0-9+/]*={0,2}$', base64_string): + return False, "Invalid base64 characters detected" + + # Check if base64 string length is valid (must be multiple of 4) + if len(base64_string) % 4 != 0: + return False, "Invalid base64 string length" + + # Attempt to decode base64 + try: + image_data = base64.b64decode(base64_string, validate=True) + except Exception as e: + return False, f"Base64 decoding failed: {str(e)}" + + # Check decoded data size + if len(image_data) == 0: + return False, "Decoded image data is empty" + + # Check if decoded data size exceeds limit + max_size_bytes = max_size_mb * 1024 * 1024 + if len(image_data) > max_size_bytes: + return False, f"Image size ({len(image_data)} bytes) exceeds limit ({max_size_bytes} bytes)" + + # Validate that decoded data is actually a valid image using PIL + try: + image_stream = io.BytesIO(image_data) + with Image.open(image_stream) as img: + # Verify the image by attempting to load it + img.verify() + + # Check if image format is supported + supported_formats = {'JPEG', 'PNG', 'GIF', 'BMP', 'WEBP', 'TIFF'} + if img.format not in supported_formats: + return False, f"Unsupported image format: {img.format}" + + # Re-open for dimension checks (verify() closes the image) + image_stream.seek(0) + with Image.open(image_stream) as img_check: + width, height = img_check.size + + # Check reasonable dimension limits + max_dimension = 8192 # 8K resolution limit + if width > max_dimension or height > max_dimension: + return False, f"Image dimensions ({width}x{height}) exceed limit ({max_dimension}x{max_dimension})" + + # Check minimum dimensions + if width < 1 or height < 1: + return False, f"Invalid image dimensions: {width}x{height}" + + logger.debug(f"Valid image detected: {img.format}, {width}x{height}, {len(image_data)} bytes") + + except Exception as e: + return False, f"Invalid image data: {str(e)}" + + return True, "Valid image" + + except Exception as e: + logger.error(f"Unexpected error during base64 image validation: {e}") + return False, f"Validation error: {str(e)}" + + async def _execute_browser_action(self, endpoint: str, params: dict = None, method: str = "POST") -> ToolResult: + """Execute a browser automation action through the API + + Args: + endpoint (str): The API endpoint to call + params (dict, optional): Parameters to send. Defaults to None. + method (str, optional): HTTP method to use. Defaults to "POST". + + Returns: + ToolResult: Result of the execution + """ + try: + # Ensure sandbox is initialized + await self._ensure_sandbox() + + # Build the curl command + url = f"http://localhost:8003/api/automation/{endpoint}" + + if method == "GET" and params: + query_params = "&".join([f"{k}={v}" for k, v in params.items()]) + url = f"{url}?{query_params}" + curl_cmd = f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'" + else: + curl_cmd = f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'" + if params: + json_data = json.dumps(params) + curl_cmd += f" -d '{json_data}'" + + logger.debug("\033[95mExecuting curl command:\033[0m") + logger.debug(f"{curl_cmd}") + + response = self.sandbox.process.exec(curl_cmd, timeout=30) + + if response.exit_code == 0: + try: + result = json.loads(response.result) + + if not "content" in result: + result["content"] = "" + + if not "role" in result: + result["role"] = "assistant" + + logger.info("Browser automation request completed successfully") + + if "screenshot_base64" in result: + try: + # Comprehensive validation of the base64 image data + screenshot_data = result["screenshot_base64"] + is_valid, validation_message = self._validate_base64_image(screenshot_data) + + if is_valid: + logger.debug(f"Screenshot validation passed: {validation_message}") + image_url = await upload_base64_image(screenshot_data) + result["image_url"] = image_url + logger.debug(f"Uploaded screenshot to {image_url}") + else: + logger.warning(f"Screenshot validation failed: {validation_message}") + result["image_validation_error"] = validation_message + + # Remove base64 data from result to keep it clean + del result["screenshot_base64"] + + except Exception as e: + logger.error(f"Failed to process screenshot: {e}") + result["image_upload_error"] = str(e) + + added_message = await self.thread_manager.add_message( + thread_id=self.thread_id, + type="browser_state", + content=result, + is_llm_message=False + ) + + success_response = {} + + if result.get("success"): + success_response["success"] = result["success"] + success_response["message"] = result.get("message", "Browser action completed successfully") + else: + success_response["success"] = False + success_response["message"] = result.get("message", "Browser action failed") + + if added_message and 'message_id' in added_message: + success_response['message_id'] = added_message['message_id'] + if result.get("url"): + success_response["url"] = result["url"] + if result.get("title"): + success_response["title"] = result["title"] + if result.get("element_count"): + success_response["elements_found"] = result["element_count"] + if result.get("pixels_below"): + success_response["scrollable_content"] = result["pixels_below"] > 0 + if result.get("ocr_text"): + success_response["ocr_text"] = result["ocr_text"] + if result.get("image_url"): + success_response["image_url"] = result["image_url"] + + if success_response.get("success"): + return self.success_response(success_response) + else: + return self.fail_response(success_response) + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse response JSON: {response.result} {e}") + return self.fail_response(f"Failed to parse response JSON: {response.result} {e}") + else: + logger.error(f"Browser automation request failed 2: {response}") + return self.fail_response(f"Browser automation request failed 2: {response}") + + except Exception as e: + logger.error(f"Error executing browser action: {e}") + logger.debug(traceback.format_exc()) + return self.fail_response(f"Error executing browser action: {e}") + + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_navigate_to", + "description": "Navigate to a specific url", + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The url to navigate to" + } + }, + "required": ["url"] + } + } + }) + @xml_schema( + tag_name="browser-navigate-to", + mappings=[ + {"param_name": "url", "node_type": "content", "path": "."} + ], + example=''' + + + https://example.com + + + ''' + ) + async def browser_navigate_to(self, url: str) -> ToolResult: + """Navigate to a specific url + + Args: + url (str): The url to navigate to + + Returns: + dict: Result of the execution + """ + return await self._execute_browser_action("navigate_to", {"url": url}) + + # @openapi_schema({ + # "type": "function", + # "function": { + # "name": "browser_search_google", + # "description": "Search Google with the provided query", + # "parameters": { + # "type": "object", + # "properties": { + # "query": { + # "type": "string", + # "description": "The search query to use" + # } + # }, + # "required": ["query"] + # } + # } + # }) + # @xml_schema( + # tag_name="browser-search-google", + # mappings=[ + # {"param_name": "query", "node_type": "content", "path": "."} + # ], + # example=''' + # + # artificial intelligence news + # + # ''' + # ) + # async def browser_search_google(self, query: str) -> ToolResult: + # """Search Google with the provided query + + # Args: + # query (str): The search query to use + + # Returns: + # dict: Result of the execution + # """ + # logger.debug(f"\033[95mSearching Google for: {query}\033[0m") + # return await self._execute_browser_action("search_google", {"query": query}) + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_go_back", + "description": "Navigate back in browser history", + "parameters": { + "type": "object", + "properties": {} + } + } + }) + @xml_schema( + tag_name="browser-go-back", + mappings=[], + example=''' + + + + + ''' + ) + async def browser_go_back(self) -> ToolResult: + """Navigate back in browser history + + Returns: + dict: Result of the execution + """ + logger.debug(f"\033[95mNavigating back in browser history\033[0m") + return await self._execute_browser_action("go_back", {}) + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_wait", + "description": "Wait for the specified number of seconds", + "parameters": { + "type": "object", + "properties": { + "seconds": { + "type": "integer", + "description": "Number of seconds to wait (default: 3)" + } + } + } + } + }) + @xml_schema( + tag_name="browser-wait", + mappings=[ + {"param_name": "seconds", "node_type": "content", "path": "."} + ], + example=''' + + + 5 + + + ''' + ) + async def browser_wait(self, seconds: int = 3) -> ToolResult: + """Wait for the specified number of seconds + + Args: + seconds (int, optional): Number of seconds to wait. Defaults to 3. + + Returns: + dict: Result of the execution + """ + logger.debug(f"\033[95mWaiting for {seconds} seconds\033[0m") + return await self._execute_browser_action("wait", {"seconds": seconds}) + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_click_element", + "description": "Click on an element by index", + "parameters": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "description": "The index of the element to click" + } + }, + "required": ["index"] + } + } + }) + @xml_schema( + tag_name="browser-click-element", + mappings=[ + {"param_name": "index", "node_type": "content", "path": "."} + ], + example=''' + + + 2 + + + ''' + ) + async def browser_click_element(self, index: int) -> ToolResult: + """Click on an element by index + + Args: + index (int): The index of the element to click + + Returns: + dict: Result of the execution + """ + logger.debug(f"\033[95mClicking element with index: {index}\033[0m") + return await self._execute_browser_action("click_element", {"index": index}) + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_input_text", + "description": "Input text into an element", + "parameters": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "description": "The index of the element to input text into" + }, + "text": { + "type": "string", + "description": "The text to input" + } + }, + "required": ["index", "text"] + } + } + }) + @xml_schema( + tag_name="browser-input-text", + mappings=[ + {"param_name": "index", "node_type": "attribute", "path": "."}, + {"param_name": "text", "node_type": "content", "path": "."} + ], + example=''' + + + 2 + Hello, world! + + + ''' + ) + async def browser_input_text(self, index: int, text: str) -> ToolResult: + """Input text into an element + + Args: + index (int): The index of the element to input text into + text (str): The text to input + + Returns: + dict: Result of the execution + """ + logger.debug(f"\033[95mInputting text into element {index}: {text}\033[0m") + return await self._execute_browser_action("input_text", {"index": index, "text": text}) + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_send_keys", + "description": "Send keyboard keys such as Enter, Escape, or keyboard shortcuts", + "parameters": { + "type": "object", + "properties": { + "keys": { + "type": "string", + "description": "The keys to send (e.g., 'Enter', 'Escape', 'Control+a')" + } + }, + "required": ["keys"] + } + } + }) + @xml_schema( + tag_name="browser-send-keys", + mappings=[ + {"param_name": "keys", "node_type": "content", "path": "."} + ], + example=''' + + + Enter + + + ''' + ) + async def browser_send_keys(self, keys: str) -> ToolResult: + """Send keyboard keys + + Args: + keys (str): The keys to send (e.g., 'Enter', 'Escape', 'Control+a') + + Returns: + dict: Result of the execution + """ + logger.debug(f"\033[95mSending keys: {keys}\033[0m") + return await self._execute_browser_action("send_keys", {"keys": keys}) + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_switch_tab", + "description": "Switch to a different browser tab", + "parameters": { + "type": "object", + "properties": { + "page_id": { + "type": "integer", + "description": "The ID of the tab to switch to" + } + }, + "required": ["page_id"] + } + } + }) + @xml_schema( + tag_name="browser-switch-tab", + mappings=[ + {"param_name": "page_id", "node_type": "content", "path": "."} + ], + example=''' + + + 1 + + + ''' + ) + async def browser_switch_tab(self, page_id: int) -> ToolResult: + """Switch to a different browser tab + + Args: + page_id (int): The ID of the tab to switch to + + Returns: + dict: Result of the execution + """ + logger.debug(f"\033[95mSwitching to tab: {page_id}\033[0m") + return await self._execute_browser_action("switch_tab", {"page_id": page_id}) + + # @openapi_schema({ + # "type": "function", + # "function": { + # "name": "browser_open_tab", + # "description": "Open a new browser tab with the specified URL", + # "parameters": { + # "type": "object", + # "properties": { + # "url": { + # "type": "string", + # "description": "The URL to open in the new tab" + # } + # }, + # "required": ["url"] + # } + # } + # }) + # @xml_schema( + # tag_name="browser-open-tab", + # mappings=[ + # {"param_name": "url", "node_type": "content", "path": "."} + # ], + # example=''' + # + # https://example.com + # + # ''' + # ) + # async def browser_open_tab(self, url: str) -> ToolResult: + # """Open a new browser tab with the specified URL + + # Args: + # url (str): The URL to open in the new tab + + # Returns: + # dict: Result of the execution + # """ + # logger.debug(f"\033[95mOpening new tab with URL: {url}\033[0m") + # return await self._execute_browser_action("open_tab", {"url": url}) + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_close_tab", + "description": "Close a browser tab", + "parameters": { + "type": "object", + "properties": { + "page_id": { + "type": "integer", + "description": "The ID of the tab to close" + } + }, + "required": ["page_id"] + } + } + }) + @xml_schema( + tag_name="browser-close-tab", + mappings=[ + {"param_name": "page_id", "node_type": "content", "path": "."} + ], + example=''' + + + 1 + + + ''' + ) + async def browser_close_tab(self, page_id: int) -> ToolResult: + """Close a browser tab + + Args: + page_id (int): The ID of the tab to close + + Returns: + dict: Result of the execution + """ + logger.debug(f"\033[95mClosing tab: {page_id}\033[0m") + return await self._execute_browser_action("close_tab", {"page_id": page_id}) + + # @openapi_schema({ + # "type": "function", + # "function": { + # "name": "browser_extract_content", + # "description": "Extract content from the current page based on the provided goal", + # "parameters": { + # "type": "object", + # "properties": { + # "goal": { + # "type": "string", + # "description": "The extraction goal (e.g., 'extract all links', 'find product information')" + # } + # }, + # "required": ["goal"] + # } + # } + # }) + # @xml_schema( + # tag_name="browser-extract-content", + # mappings=[ + # {"param_name": "goal", "node_type": "content", "path": "."} + # ], + # example=''' + # + # Extract all links on the page + # + # ''' + # ) + # async def browser_extract_content(self, goal: str) -> ToolResult: + # """Extract content from the current page based on the provided goal + + # Args: + # goal (str): The extraction goal + + # Returns: + # dict: Result of the execution + # """ + # logger.debug(f"\033[95mExtracting content with goal: {goal}\033[0m") + # result = await self._execute_browser_action("extract_content", {"goal": goal}) + + # # Format content for better readability + # if result.get("success"): + # logger.debug(f"\033[92mContent extraction successful\033[0m") + # content = result.data.get("content", "") + # url = result.data.get("url", "") + # title = result.data.get("title", "") + + # if content: + # content_preview = content[:200] + "..." if len(content) > 200 else content + # logger.debug(f"\033[95mExtracted content from {title} ({url}):\033[0m") + # logger.debug(f"\033[96m{content_preview}\033[0m") + # logger.debug(f"\033[95mTotal content length: {len(content)} characters\033[0m") + # else: + # logger.debug(f"\033[93mNo content extracted from {url}\033[0m") + # else: + # logger.debug(f"\033[91mFailed to extract content: {result.data.get('error', 'Unknown error')}\033[0m") + + # return result + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_scroll_down", + "description": "Scroll down the page", + "parameters": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "description": "Pixel amount to scroll (if not specified, scrolls one page)" + } + } + } + } + }) + @xml_schema( + tag_name="browser-scroll-down", + mappings=[ + {"param_name": "amount", "node_type": "content", "path": "."} + ], + example=''' + + + 500 + + + ''' + ) + async def browser_scroll_down(self, amount: int = None) -> ToolResult: + """Scroll down the page + + Args: + amount (int, optional): Pixel amount to scroll. If None, scrolls one page. + + Returns: + dict: Result of the execution + """ + params = {} + if amount is not None: + params["amount"] = amount + logger.debug(f"\033[95mScrolling down by {amount} pixels\033[0m") + else: + logger.debug(f"\033[95mScrolling down one page\033[0m") + + return await self._execute_browser_action("scroll_down", params) + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_scroll_up", + "description": "Scroll up the page", + "parameters": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "description": "Pixel amount to scroll (if not specified, scrolls one page)" + } + } + } + } + }) + @xml_schema( + tag_name="browser-scroll-up", + mappings=[ + {"param_name": "amount", "node_type": "content", "path": "."} + ], + example=''' + + + 500 + + + ''' + ) + async def browser_scroll_up(self, amount: int = None) -> ToolResult: + """Scroll up the page + + Args: + amount (int, optional): Pixel amount to scroll. If None, scrolls one page. + + Returns: + dict: Result of the execution + """ + params = {} + if amount is not None: + params["amount"] = amount + logger.debug(f"\033[95mScrolling up by {amount} pixels\033[0m") + else: + logger.debug(f"\033[95mScrolling up one page\033[0m") + + return await self._execute_browser_action("scroll_up", params) + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_scroll_to_text", + "description": "Scroll to specific text on the page", + "parameters": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The text to scroll to" + } + }, + "required": ["text"] + } + } + }) + @xml_schema( + tag_name="browser-scroll-to-text", + mappings=[ + {"param_name": "text", "node_type": "content", "path": "."} + ], + example=''' + + + Contact Us + + + ''' + ) + async def browser_scroll_to_text(self, text: str) -> ToolResult: + """Scroll to specific text on the page + + Args: + text (str): The text to scroll to + + Returns: + dict: Result of the execution + """ + logger.debug(f"\033[95mScrolling to text: {text}\033[0m") + return await self._execute_browser_action("scroll_to_text", {"text": text}) + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_get_dropdown_options", + "description": "Get all options from a dropdown element", + "parameters": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "description": "The index of the dropdown element" + } + }, + "required": ["index"] + } + } + }) + @xml_schema( + tag_name="browser-get-dropdown-options", + mappings=[ + {"param_name": "index", "node_type": "content", "path": "."} + ], + example=''' + + + 2 + + + ''' + ) + async def browser_get_dropdown_options(self, index: int) -> ToolResult: + """Get all options from a dropdown element + + Args: + index (int): The index of the dropdown element + + Returns: + dict: Result of the execution with the dropdown options + """ + logger.debug(f"\033[95mGetting options from dropdown with index: {index}\033[0m") + return await self._execute_browser_action("get_dropdown_options", {"index": index}) + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_select_dropdown_option", + "description": "Select an option from a dropdown by text", + "parameters": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "description": "The index of the dropdown element" + }, + "text": { + "type": "string", + "description": "The text of the option to select" + } + }, + "required": ["index", "text"] + } + } + }) + @xml_schema( + tag_name="browser-select-dropdown-option", + mappings=[ + {"param_name": "index", "node_type": "attribute", "path": "."}, + {"param_name": "text", "node_type": "content", "path": "."} + ], + example=''' + + + 2 + Option 1 + + + ''' + ) + async def browser_select_dropdown_option(self, index: int, text: str) -> ToolResult: + """Select an option from a dropdown by text + + Args: + index (int): The index of the dropdown element + text (str): The text of the option to select + + Returns: + dict: Result of the execution + """ + logger.debug(f"\033[95mSelecting option '{text}' from dropdown with index: {index}\033[0m") + return await self._execute_browser_action("select_dropdown_option", {"index": index, "text": text}) + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_drag_drop", + "description": "Perform drag and drop operation between elements or coordinates", + "parameters": { + "type": "object", + "properties": { + "element_source": { + "type": "string", + "description": "The source element selector" + }, + "element_target": { + "type": "string", + "description": "The target element selector" + }, + "coord_source_x": { + "type": "integer", + "description": "The source X coordinate" + }, + "coord_source_y": { + "type": "integer", + "description": "The source Y coordinate" + }, + "coord_target_x": { + "type": "integer", + "description": "The target X coordinate" + }, + "coord_target_y": { + "type": "integer", + "description": "The target Y coordinate" + } + } + } + } + }) + @xml_schema( + tag_name="browser-drag-drop", + mappings=[ + {"param_name": "element_source", "node_type": "attribute", "path": "."}, + {"param_name": "element_target", "node_type": "attribute", "path": "."}, + {"param_name": "coord_source_x", "node_type": "attribute", "path": "."}, + {"param_name": "coord_source_y", "node_type": "attribute", "path": "."}, + {"param_name": "coord_target_x", "node_type": "attribute", "path": "."}, + {"param_name": "coord_target_y", "node_type": "attribute", "path": "."} + ], + example=''' + + + #draggable + #droppable + + + ''' + ) + async def browser_drag_drop(self, element_source: str = None, element_target: str = None, + coord_source_x: int = None, coord_source_y: int = None, + coord_target_x: int = None, coord_target_y: int = None) -> ToolResult: + """Perform drag and drop operation between elements or coordinates + + Args: + element_source (str, optional): The source element selector + element_target (str, optional): The target element selector + coord_source_x (int, optional): The source X coordinate + coord_source_y (int, optional): The source Y coordinate + coord_target_x (int, optional): The target X coordinate + coord_target_y (int, optional): The target Y coordinate + + Returns: + dict: Result of the execution + """ + params = {} + + if element_source and element_target: + params["element_source"] = element_source + params["element_target"] = element_target + logger.debug(f"\033[95mDragging from element '{element_source}' to '{element_target}'\033[0m") + elif all(coord is not None for coord in [coord_source_x, coord_source_y, coord_target_x, coord_target_y]): + params["coord_source_x"] = coord_source_x + params["coord_source_y"] = coord_source_y + params["coord_target_x"] = coord_target_x + params["coord_target_y"] = coord_target_y + logger.debug(f"\033[95mDragging from coordinates ({coord_source_x}, {coord_source_y}) to ({coord_target_x}, {coord_target_y})\033[0m") + else: + return self.fail_response("Must provide either element selectors or coordinates for drag and drop") + + return await self._execute_browser_action("drag_drop", params) + + @openapi_schema({ + "type": "function", + "function": { + "name": "browser_click_coordinates", + "description": "Click at specific X,Y coordinates on the page", + "parameters": { + "type": "object", + "properties": { + "x": { + "type": "integer", + "description": "The X coordinate to click" + }, + "y": { + "type": "integer", + "description": "The Y coordinate to click" + } + }, + "required": ["x", "y"] + } + } + }) + @xml_schema( + tag_name="browser-click-coordinates", + mappings=[ + {"param_name": "x", "node_type": "attribute", "path": "."}, + {"param_name": "y", "node_type": "attribute", "path": "."} + ], + example=''' + + + 100 + 200 + + + ''' + ) + async def browser_click_coordinates(self, x: int, y: int) -> ToolResult: + """Click at specific X,Y coordinates on the page + + Args: + x (int): The X coordinate to click + y (int): The Y coordinate to click + + Returns: + dict: Result of the execution + """ + logger.debug(f"\033[95mClicking at coordinates: ({x}, {y})\033[0m") + return await self._execute_browser_action("click_coordinates", {"x": x, "y": y}) \ No newline at end of file diff --git a/app/tool/sb_deploy_tool.py b/app/tool/sb_deploy_tool.py new file mode 100644 index 000000000..5c52f394d --- /dev/null +++ b/app/tool/sb_deploy_tool.py @@ -0,0 +1,147 @@ +import os +from dotenv import load_dotenv +from agentpress.tool import ToolResult, openapi_schema, xml_schema +from sandbox.tool_base import SandboxToolsBase +from utils.files_utils import clean_path +from agentpress.thread_manager import ThreadManager + +# Load environment variables +load_dotenv() + +class SandboxDeployTool(SandboxToolsBase): + """Tool for deploying static websites from a Daytona sandbox to Cloudflare Pages.""" + + def __init__(self, project_id: str, thread_manager: ThreadManager): + super().__init__(project_id, thread_manager) + self.workspace_path = "/workspace" # Ensure we're always operating in /workspace + self.cloudflare_api_token = os.getenv("CLOUDFLARE_API_TOKEN") + + def clean_path(self, path: str) -> str: + """Clean and normalize a path to be relative to /workspace""" + return clean_path(path, self.workspace_path) + + @openapi_schema({ + "type": "function", + "function": { + "name": "deploy", + "description": "Deploy a static website (HTML+CSS+JS) from a directory in the sandbox to Cloudflare Pages. Only use this tool when permanent deployment to a production environment is needed. The directory path must be relative to /workspace. The website will be deployed to {name}.kortix.cloud.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name for the deployment, will be used in the URL as {name}.kortix.cloud" + }, + "directory_path": { + "type": "string", + "description": "Path to the directory containing the static website files to deploy, relative to /workspace (e.g., 'build')" + } + }, + "required": ["name", "directory_path"] + } + } + }) + @xml_schema( + tag_name="deploy", + mappings=[ + {"param_name": "name", "node_type": "attribute", "path": "name"}, + {"param_name": "directory_path", "node_type": "attribute", "path": "directory_path"} + ], + example=''' + + + + + my-site + website + + + ''' + ) + async def deploy(self, name: str, directory_path: str) -> ToolResult: + """ + Deploy a static website (HTML+CSS+JS) from the sandbox to Cloudflare Pages. + Only use this tool when permanent deployment to a production environment is needed. + + Args: + name: Name for the deployment, will be used in the URL as {name}.kortix.cloud + directory_path: Path to the directory to deploy, relative to /workspace + + Returns: + ToolResult containing: + - Success: Deployment information including URL + - Failure: Error message if deployment fails + """ + try: + # Ensure sandbox is initialized + await self._ensure_sandbox() + + directory_path = self.clean_path(directory_path) + full_path = f"{self.workspace_path}/{directory_path}" + + # Verify the directory exists + try: + dir_info = self.sandbox.fs.get_file_info(full_path) + if not dir_info.is_dir: + return self.fail_response(f"'{directory_path}' is not a directory") + except Exception as e: + return self.fail_response(f"Directory '{directory_path}' does not exist: {str(e)}") + + # Deploy to Cloudflare Pages directly from the container + try: + # Get Cloudflare API token from environment + if not self.cloudflare_api_token: + return self.fail_response("CLOUDFLARE_API_TOKEN environment variable not set") + + # Single command that creates the project if it doesn't exist and then deploys + project_name = f"{self.sandbox_id}-{name}" + deploy_cmd = f'''cd {self.workspace_path} && export CLOUDFLARE_API_TOKEN={self.cloudflare_api_token} && + (npx wrangler pages deploy {full_path} --project-name {project_name} || + (npx wrangler pages project create {project_name} --production-branch production && + 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(f"/bin/sh -c \"{deploy_cmd}\"", + timeout=300) + + print(f"Deployment command output: {response.result}") + + if response.exit_code == 0: + return self.success_response({ + "message": f"Website deployed successfully", + "output": response.result + }) + else: + return self.fail_response(f"Deployment failed with exit code {response.exit_code}: {response.result}") + except Exception as e: + return self.fail_response(f"Error during deployment: {str(e)}") + except Exception as e: + return self.fail_response(f"Error deploying website: {str(e)}") + +if __name__ == "__main__": + import asyncio + import sys + + async def test_deploy(): + # Replace these with actual values for testing + sandbox_id = "sandbox-ccb30b35" + password = "test-password" + + # Initialize the deploy tool + deploy_tool = SandboxDeployTool(sandbox_id, password) + + # Test deployment - replace with actual directory path and site name + result = await deploy_tool.deploy( + name="test-site-1x", + directory_path="website" # Directory containing static site files + ) + print(f"Deployment result: {result}") + + asyncio.run(test_deploy()) + diff --git a/app/tool/sb_expose_tool.py b/app/tool/sb_expose_tool.py new file mode 100644 index 000000000..ff833b32b --- /dev/null +++ b/app/tool/sb_expose_tool.py @@ -0,0 +1,126 @@ +from agentpress.tool import ToolResult, openapi_schema, xml_schema +from sandbox.tool_base import SandboxToolsBase +from agentpress.thread_manager import ThreadManager +import asyncio +import time + +class SandboxExposeTool(SandboxToolsBase): + """Tool for exposing and retrieving preview URLs for sandbox ports.""" + + def __init__(self, project_id: str, thread_manager: ThreadManager): + super().__init__(project_id, thread_manager) + + async def _wait_for_sandbox_services(self, timeout: int = 30) -> bool: + """Wait for sandbox services to be fully started before exposing ports.""" + start_time = time.time() + + while time.time() - start_time < timeout: + try: + # Check if supervisord is running and managing services + result = self.sandbox.process.exec("supervisorctl status", timeout=10) + + if result.exit_code == 0: + # Check if key services are running + status_output = result.output + if "http_server" in status_output and "RUNNING" in status_output: + return True + + # If services aren't ready, wait a bit + await asyncio.sleep(2) + + except Exception as e: + # If we can't check status, wait a bit and try again + await asyncio.sleep(2) + + return False + + @openapi_schema({ + "type": "function", + "function": { + "name": "expose_port", + "description": "Expose a port from the agent's sandbox environment to the public internet and get its preview URL. This is essential for making services running in the sandbox accessible to users, such as web applications, APIs, or other network services. The exposed URL can be shared with users to allow them to interact with the sandbox environment.", + "parameters": { + "type": "object", + "properties": { + "port": { + "type": "integer", + "description": "The port number to expose. Must be a valid port number between 1 and 65535.", + "minimum": 1, + "maximum": 65535 + } + }, + "required": ["port"] + } + } + }) + @xml_schema( + tag_name="expose-port", + mappings=[ + {"param_name": "port", "node_type": "content", "path": "."} + ], + example=''' + + + + 8000 + + + + + + + 3000 + + + + + + + 5173 + + + ''' + ) + async def expose_port(self, port: int) -> ToolResult: + try: + # Ensure sandbox is initialized + await self._ensure_sandbox() + + # Convert port to integer if it's a string + port = int(port) + + # Validate port number + if not 1 <= port <= 65535: + return self.fail_response(f"Invalid port number: {port}. Must be between 1 and 65535.") + + # Wait for sandbox services to be ready (especially important for workflows) + services_ready = await self._wait_for_sandbox_services() + if not services_ready: + return self.fail_response(f"Sandbox services are not fully started yet. Please wait a moment and try again, or ensure a service is running on port {port}.") + + # Check if something is actually listening on the port (for custom ports) + if port not in [6080, 8080, 8003]: # Skip check for known sandbox ports + try: + port_check = self.sandbox.process.exec(f"netstat -tlnp | grep :{port}", timeout=5) + if port_check.exit_code != 0: + return self.fail_response(f"No service is currently listening on port {port}. Please start a service on this port first.") + except Exception: + # If we can't check, proceed anyway - the user might be starting a service + pass + + # Get the preview link for the specified port + preview_link = self.sandbox.get_preview_link(port) + + # Extract the actual URL from the preview link object + url = preview_link.url if hasattr(preview_link, 'url') else str(preview_link) + + return self.success_response({ + "url": url, + "port": port, + "message": f"Successfully exposed port {port} to the public. Users can now access this service at: {url}" + }) + + except ValueError: + return self.fail_response(f"Invalid port number: {port}. Must be a valid integer between 1 and 65535.") + except Exception as e: + return self.fail_response(f"Error exposing port {port}: {str(e)}") diff --git a/app/tool/sb_files_tool.py b/app/tool/sb_files_tool.py new file mode 100644 index 000000000..34f334969 --- /dev/null +++ b/app/tool/sb_files_tool.py @@ -0,0 +1,462 @@ +from agentpress.tool import ToolResult, openapi_schema, xml_schema +from sandbox.tool_base import SandboxToolsBase +from utils.files_utils import should_exclude_file, clean_path +from agentpress.thread_manager import ThreadManager +from utils.logger import logger +import os + +class SandboxFilesTool(SandboxToolsBase): + """Tool for executing file system operations in a Daytona sandbox. All operations are performed relative to the /workspace directory.""" + + def __init__(self, project_id: str, thread_manager: ThreadManager): + super().__init__(project_id, thread_manager) + self.SNIPPET_LINES = 4 # Number of context lines to show around edits + self.workspace_path = "/workspace" # Ensure we're always operating in /workspace + + def clean_path(self, path: str) -> str: + """Clean and normalize a path to be relative to /workspace""" + return clean_path(path, self.workspace_path) + + def _should_exclude_file(self, rel_path: str) -> bool: + """Check if a file should be excluded based on path, name, or extension""" + return should_exclude_file(rel_path) + + def _file_exists(self, path: str) -> bool: + """Check if a file exists in the sandbox""" + try: + self.sandbox.fs.get_file_info(path) + return True + except Exception: + return False + + async def get_workspace_state(self) -> dict: + """Get the current workspace state by reading all files""" + files_state = {} + try: + # Ensure sandbox is initialized + await self._ensure_sandbox() + + files = self.sandbox.fs.list_files(self.workspace_path) + for file_info in files: + rel_path = file_info.name + + # Skip excluded files and directories + if self._should_exclude_file(rel_path) or file_info.is_dir: + continue + + try: + full_path = f"{self.workspace_path}/{rel_path}" + content = self.sandbox.fs.download_file(full_path).decode() + files_state[rel_path] = { + "content": content, + "is_dir": file_info.is_dir, + "size": file_info.size, + "modified": file_info.mod_time + } + except Exception as e: + print(f"Error reading file {rel_path}: {e}") + except UnicodeDecodeError: + print(f"Skipping binary file: {rel_path}") + + return files_state + + except Exception as e: + print(f"Error getting workspace state: {str(e)}") + return {} + + + # def _get_preview_url(self, file_path: str) -> Optional[str]: + # """Get the preview URL for a file if it's an HTML file.""" + # if file_path.lower().endswith('.html') and self._sandbox_url: + # return f"{self._sandbox_url}/{(file_path.replace('/workspace/', ''))}" + # return None + + @openapi_schema({ + "type": "function", + "function": { + "name": "create_file", + "description": "Create a new file with the provided contents at a given path in the workspace. The path must be relative to /workspace (e.g., 'src/main.py' for /workspace/src/main.py)", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the file to be created, relative to /workspace (e.g., 'src/main.py')" + }, + "file_contents": { + "type": "string", + "description": "The content to write to the file" + }, + "permissions": { + "type": "string", + "description": "File permissions in octal format (e.g., '644')", + "default": "644" + } + }, + "required": ["file_path", "file_contents"] + } + } + }) + @xml_schema( + tag_name="create-file", + mappings=[ + {"param_name": "file_path", "node_type": "attribute", "path": "."}, + {"param_name": "file_contents", "node_type": "content", "path": "."} + ], + example=''' + + + src/main.py + + # This is the file content + def main(): + print("Hello, World!") + + if __name__ == "__main__": + main() + + + + ''' + ) + async def create_file(self, file_path: str, file_contents: str, permissions: str = "644") -> ToolResult: + try: + # Ensure sandbox is initialized + await self._ensure_sandbox() + + file_path = self.clean_path(file_path) + full_path = f"{self.workspace_path}/{file_path}" + if self._file_exists(full_path): + return self.fail_response(f"File '{file_path}' already exists. Use update_file to modify existing files.") + + # Create parent directories if needed + parent_dir = '/'.join(full_path.split('/')[:-1]) + if parent_dir: + self.sandbox.fs.create_folder(parent_dir, "755") + + # Write the file content + self.sandbox.fs.upload_file(file_contents.encode(), full_path) + self.sandbox.fs.set_file_permissions(full_path, permissions) + + message = f"File '{file_path}' created successfully." + + # Check if index.html was created and add 8080 server info (only in root workspace) + if file_path.lower() == 'index.html': + try: + website_link = self.sandbox.get_preview_link(8080) + website_url = website_link.url if hasattr(website_link, 'url') else str(website_link).split("url='")[1].split("'")[0] + message += f"\n\n[Auto-detected index.html - HTTP server available at: {website_url}]" + message += "\n[Note: Use the provided HTTP server URL above instead of starting a new server]" + except Exception as e: + logger.warning(f"Failed to get website URL for index.html: {str(e)}") + + return self.success_response(message) + except Exception as e: + return self.fail_response(f"Error creating file: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "str_replace", + "description": "Replace specific text in a file. The file path must be relative to /workspace (e.g., 'src/main.py' for /workspace/src/main.py). Use this when you need to replace a unique string that appears exactly once in the file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the target file, relative to /workspace (e.g., 'src/main.py')" + }, + "old_str": { + "type": "string", + "description": "Text to be replaced (must appear exactly once)" + }, + "new_str": { + "type": "string", + "description": "Replacement text" + } + }, + "required": ["file_path", "old_str", "new_str"] + } + } + }) + @xml_schema( + tag_name="str-replace", + mappings=[ + {"param_name": "file_path", "node_type": "attribute", "path": "."}, + {"param_name": "old_str", "node_type": "element", "path": "old_str"}, + {"param_name": "new_str", "node_type": "element", "path": "new_str"} + ], + example=''' + + + src/main.py + text to replace (must appear exactly once in the file) + replacement text that will be inserted instead + + + ''' + ) + async def str_replace(self, file_path: str, old_str: str, new_str: str) -> ToolResult: + try: + # Ensure sandbox is initialized + await self._ensure_sandbox() + + file_path = self.clean_path(file_path) + full_path = f"{self.workspace_path}/{file_path}" + if not self._file_exists(full_path): + return self.fail_response(f"File '{file_path}' does not exist") + + content = self.sandbox.fs.download_file(full_path).decode() + old_str = old_str.expandtabs() + new_str = new_str.expandtabs() + + occurrences = content.count(old_str) + if occurrences == 0: + return self.fail_response(f"String '{old_str}' not found in file") + if occurrences > 1: + lines = [i+1 for i, line in enumerate(content.split('\n')) if old_str in line] + return self.fail_response(f"Multiple occurrences found in lines {lines}. Please ensure string is unique") + + # Perform replacement + new_content = content.replace(old_str, new_str) + self.sandbox.fs.upload_file(new_content.encode(), full_path) + + # Show snippet around the edit + replacement_line = content.split(old_str)[0].count('\n') + start_line = max(0, replacement_line - self.SNIPPET_LINES) + end_line = replacement_line + self.SNIPPET_LINES + new_str.count('\n') + snippet = '\n'.join(new_content.split('\n')[start_line:end_line + 1]) + + # Get preview URL if it's an HTML file + # preview_url = self._get_preview_url(file_path) + message = f"Replacement successful." + # if preview_url: + # message += f"\n\nYou can preview this HTML file at: {preview_url}" + + return self.success_response(message) + + except Exception as e: + return self.fail_response(f"Error replacing string: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "full_file_rewrite", + "description": "Completely rewrite an existing file with new content. The file path must be relative to /workspace (e.g., 'src/main.py' for /workspace/src/main.py). Use this when you need to replace the entire file content or make extensive changes throughout the file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the file to be rewritten, relative to /workspace (e.g., 'src/main.py')" + }, + "file_contents": { + "type": "string", + "description": "The new content to write to the file, replacing all existing content" + }, + "permissions": { + "type": "string", + "description": "File permissions in octal format (e.g., '644')", + "default": "644" + } + }, + "required": ["file_path", "file_contents"] + } + } + }) + @xml_schema( + tag_name="full-file-rewrite", + mappings=[ + {"param_name": "file_path", "node_type": "attribute", "path": "."}, + {"param_name": "file_contents", "node_type": "content", "path": "."} + ], + example=''' + + + src/main.py + + This completely replaces the entire file content. + Use when making major changes to a file or when the changes + are too extensive for str-replace. + All previous content will be lost and replaced with this text. + + + + ''' + ) + async def full_file_rewrite(self, file_path: str, file_contents: str, permissions: str = "644") -> ToolResult: + try: + # Ensure sandbox is initialized + await self._ensure_sandbox() + + file_path = self.clean_path(file_path) + full_path = f"{self.workspace_path}/{file_path}" + if not self._file_exists(full_path): + return self.fail_response(f"File '{file_path}' does not exist. Use create_file to create a new file.") + + self.sandbox.fs.upload_file(file_contents.encode(), full_path) + self.sandbox.fs.set_file_permissions(full_path, permissions) + + message = f"File '{file_path}' completely rewritten successfully." + + # Check if index.html was rewritten and add 8080 server info (only in root workspace) + if file_path.lower() == 'index.html': + try: + website_link = self.sandbox.get_preview_link(8080) + website_url = website_link.url if hasattr(website_link, 'url') else str(website_link).split("url='")[1].split("'")[0] + message += f"\n\n[Auto-detected index.html - HTTP server available at: {website_url}]" + message += "\n[Note: Use the provided HTTP server URL above instead of starting a new server]" + except Exception as e: + logger.warning(f"Failed to get website URL for index.html: {str(e)}") + + return self.success_response(message) + except Exception as e: + return self.fail_response(f"Error rewriting file: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "delete_file", + "description": "Delete a file at the given path. The path must be relative to /workspace (e.g., 'src/main.py' for /workspace/src/main.py)", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the file to be deleted, relative to /workspace (e.g., 'src/main.py')" + } + }, + "required": ["file_path"] + } + } + }) + @xml_schema( + tag_name="delete-file", + mappings=[ + {"param_name": "file_path", "node_type": "attribute", "path": "."} + ], + example=''' + + + src/main.py + + + ''' + ) + async def delete_file(self, file_path: str) -> ToolResult: + try: + # Ensure sandbox is initialized + await self._ensure_sandbox() + + file_path = self.clean_path(file_path) + full_path = f"{self.workspace_path}/{file_path}" + if not self._file_exists(full_path): + return self.fail_response(f"File '{file_path}' does not exist") + + self.sandbox.fs.delete_file(full_path) + return self.success_response(f"File '{file_path}' deleted successfully.") + except Exception as e: + return self.fail_response(f"Error deleting file: {str(e)}") + + # @openapi_schema({ + # "type": "function", + # "function": { + # "name": "read_file", + # "description": "Read and return the contents of a file. This tool is essential for verifying data, checking file contents, and analyzing information. Always use this tool to read file contents before processing or analyzing data. The file path must be relative to /workspace.", + # "parameters": { + # "type": "object", + # "properties": { + # "file_path": { + # "type": "string", + # "description": "Path to the file to read, relative to /workspace (e.g., 'src/main.py' for /workspace/src/main.py). Must be a valid file path within the workspace." + # }, + # "start_line": { + # "type": "integer", + # "description": "Optional starting line number (1-based). Use this to read specific sections of large files. If not specified, reads from the beginning of the file.", + # "default": 1 + # }, + # "end_line": { + # "type": "integer", + # "description": "Optional ending line number (inclusive). Use this to read specific sections of large files. If not specified, reads to the end of the file.", + # "default": None + # } + # }, + # "required": ["file_path"] + # } + # } + # }) + # @xml_schema( + # tag_name="read-file", + # mappings=[ + # {"param_name": "file_path", "node_type": "attribute", "path": "."}, + # {"param_name": "start_line", "node_type": "attribute", "path": ".", "required": False}, + # {"param_name": "end_line", "node_type": "attribute", "path": ".", "required": False} + # ], + # example=''' + # + # + # + + # + # + # + + # + # + # + + # + # + # + # ''' + # ) + # async def read_file(self, file_path: str, start_line: int = 1, end_line: Optional[int] = None) -> ToolResult: + # """Read file content with optional line range specification. + + # Args: + # file_path: Path to the file relative to /workspace + # start_line: Starting line number (1-based), defaults to 1 + # end_line: Ending line number (inclusive), defaults to None (end of file) + + # Returns: + # ToolResult containing: + # - Success: File content and metadata + # - Failure: Error message if file doesn't exist or is binary + # """ + # try: + # file_path = self.clean_path(file_path) + # full_path = f"{self.workspace_path}/{file_path}" + + # if not self._file_exists(full_path): + # return self.fail_response(f"File '{file_path}' does not exist") + + # # Download and decode file content + # content = self.sandbox.fs.download_file(full_path).decode() + + # # Split content into lines + # lines = content.split('\n') + # total_lines = len(lines) + + # # Handle line range if specified + # if start_line > 1 or end_line is not None: + # # Convert to 0-based indices + # start_idx = max(0, start_line - 1) + # end_idx = end_line if end_line is not None else total_lines + # end_idx = min(end_idx, total_lines) # Ensure we don't exceed file length + + # # Extract the requested lines + # content = '\n'.join(lines[start_idx:end_idx]) + + # return self.success_response({ + # "content": content, + # "file_path": file_path, + # "start_line": start_line, + # "end_line": end_line if end_line is not None else total_lines, + # "total_lines": total_lines + # }) + + # except UnicodeDecodeError: + # return self.fail_response(f"File '{file_path}' appears to be binary and cannot be read as text") + # except Exception as e: + # return self.fail_response(f"Error reading file: {str(e)}") + diff --git a/app/tool/sb_shell_tool.py b/app/tool/sb_shell_tool.py new file mode 100644 index 000000000..b165472ea --- /dev/null +++ b/app/tool/sb_shell_tool.py @@ -0,0 +1,423 @@ +from typing import Optional, Dict, Any +import time +from uuid import uuid4 +from agentpress.tool import ToolResult, openapi_schema, xml_schema +from sandbox.tool_base import SandboxToolsBase +from agentpress.thread_manager import ThreadManager + +class SandboxShellTool(SandboxToolsBase): + """Tool for executing tasks in a Daytona sandbox with browser-use capabilities. + Uses sessions for maintaining state between commands and provides comprehensive process management.""" + + def __init__(self, project_id: str, thread_manager: ThreadManager): + super().__init__(project_id, thread_manager) + self._sessions: Dict[str, str] = {} # Maps session names to session IDs + self.workspace_path = "/workspace" # Ensure we're always operating in /workspace + + async def _ensure_session(self, session_name: str = "default") -> str: + """Ensure a session exists and return its ID.""" + if session_name not in self._sessions: + session_id = str(uuid4()) + try: + await self._ensure_sandbox() # Ensure sandbox is initialized + self.sandbox.process.create_session(session_id) + self._sessions[session_name] = session_id + except Exception as e: + raise RuntimeError(f"Failed to create session: {str(e)}") + return self._sessions[session_name] + + async def _cleanup_session(self, session_name: str): + """Clean up a session if it exists.""" + if session_name in self._sessions: + try: + await self._ensure_sandbox() # Ensure sandbox is initialized + self.sandbox.process.delete_session(self._sessions[session_name]) + del self._sessions[session_name] + except Exception as e: + print(f"Warning: Failed to cleanup session {session_name}: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "execute_command", + "description": "Execute a shell command in the workspace directory. IMPORTANT: Commands are non-blocking by default and run in a tmux session. This is ideal for long-running operations like starting servers or build processes. Uses sessions to maintain state between commands. This tool is essential for running CLI tools, installing packages, and managing system operations.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The shell command to execute. Use this for running CLI tools, installing packages, or system operations. Commands can be chained using &&, ||, and | operators." + }, + "folder": { + "type": "string", + "description": "Optional relative path to a subdirectory of /workspace where the command should be executed. Example: 'data/pdfs'" + }, + "session_name": { + "type": "string", + "description": "Optional name of the tmux session to use. Use named sessions for related commands that need to maintain state. Defaults to a random session name.", + }, + "blocking": { + "type": "boolean", + "description": "Whether to wait for the command to complete. Defaults to false for non-blocking execution.", + "default": False + }, + "timeout": { + "type": "integer", + "description": "Optional timeout in seconds for blocking commands. Defaults to 60. Ignored for non-blocking commands.", + "default": 60 + } + }, + "required": ["command"] + } + } + }) + @xml_schema( + tag_name="execute-command", + mappings=[ + {"param_name": "command", "node_type": "content", "path": "."}, + {"param_name": "folder", "node_type": "attribute", "path": ".", "required": False}, + {"param_name": "session_name", "node_type": "attribute", "path": ".", "required": False}, + {"param_name": "blocking", "node_type": "attribute", "path": ".", "required": False}, + {"param_name": "timeout", "node_type": "attribute", "path": ".", "required": False} + ], + example=''' + + + npm run dev + dev_server + + + + + + + npm run build + frontend + build_process + + + + + + + npm install + true + 300 + + + ''' + ) + async def execute_command( + self, + command: str, + folder: Optional[str] = None, + session_name: Optional[str] = None, + blocking: bool = False, + timeout: int = 60 + ) -> ToolResult: + try: + # Ensure sandbox is initialized + await self._ensure_sandbox() + + # Set up working directory + cwd = self.workspace_path + if folder: + folder = folder.strip('/') + cwd = f"{self.workspace_path}/{folder}" + + # Generate a session name if not provided + if not session_name: + session_name = f"session_{str(uuid4())[:8]}" + + # Check if tmux session already exists + check_session = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") + session_exists = "not_exists" not in check_session.get("output", "") + + if not session_exists: + # Create a new tmux session + await self._execute_raw_command(f"tmux new-session -d -s {session_name}") + + # Ensure we're in the correct directory and send command to tmux + full_command = f"cd {cwd} && {command}" + wrapped_command = full_command.replace('"', '\\"') # Escape double quotes + + # Send command to tmux session + await self._execute_raw_command(f'tmux send-keys -t {session_name} "{wrapped_command}" Enter') + + if blocking: + # For blocking execution, wait and capture output + start_time = time.time() + while (time.time() - start_time) < timeout: + # Wait a bit before checking + time.sleep(2) + + # Check if session still exists (command might have exited) + check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'ended'") + if "ended" in check_result.get("output", ""): + break + + # Get current output and check for common completion indicators + output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") + current_output = output_result.get("output", "") + + # Check for prompt indicators that suggest command completion + last_lines = current_output.split('\n')[-3:] + completion_indicators = ['$', '#', '>', 'Done', 'Completed', 'Finished', '✓'] + if any(indicator in line for indicator in completion_indicators for line in last_lines): + break + + # Capture final output + output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") + final_output = output_result.get("output", "") + + # Kill the session after capture + await self._execute_raw_command(f"tmux kill-session -t {session_name}") + + return self.success_response({ + "output": final_output, + "session_name": session_name, + "cwd": cwd, + "completed": True + }) + else: + # For non-blocking, just return immediately + return self.success_response({ + "session_name": session_name, + "cwd": cwd, + "message": f"Command sent to tmux session '{session_name}'. Use check_command_output to view results.", + "completed": False + }) + + except Exception as e: + # Attempt to clean up session in case of error + if session_name: + try: + await self._execute_raw_command(f"tmux kill-session -t {session_name}") + except: + pass + return self.fail_response(f"Error executing command: {str(e)}") + + async def _execute_raw_command(self, command: str) -> Dict[str, Any]: + """Execute a raw command directly in the sandbox.""" + # Ensure session exists for raw commands + session_id = await self._ensure_session("raw_commands") + + # Execute command in session + from sandbox.sandbox import SessionExecuteRequest + req = SessionExecuteRequest( + command=command, + var_async=False, + cwd=self.workspace_path + ) + + response = self.sandbox.process.execute_session_command( + session_id=session_id, + req=req, + timeout=30 # Short timeout for utility commands + ) + + logs = self.sandbox.process.get_session_command_logs( + session_id=session_id, + command_id=response.cmd_id + ) + + return { + "output": logs, + "exit_code": response.exit_code + } + + @openapi_schema({ + "type": "function", + "function": { + "name": "check_command_output", + "description": "Check the output of a previously executed command in a tmux session. Use this to monitor the progress or results of non-blocking commands.", + "parameters": { + "type": "object", + "properties": { + "session_name": { + "type": "string", + "description": "The name of the tmux session to check." + }, + "kill_session": { + "type": "boolean", + "description": "Whether to terminate the tmux session after checking. Set to true when you're done with the command.", + "default": False + } + }, + "required": ["session_name"] + } + } + }) + @xml_schema( + tag_name="check-command-output", + mappings=[ + {"param_name": "session_name", "node_type": "attribute", "path": ".", "required": True}, + {"param_name": "kill_session", "node_type": "attribute", "path": ".", "required": False} + ], + example=''' + + + dev_server + + + + + + + build_process + true + + + ''' + ) + async def check_command_output( + self, + session_name: str, + kill_session: bool = False + ) -> ToolResult: + try: + # Ensure sandbox is initialized + await self._ensure_sandbox() + + # Check if session exists + check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") + if "not_exists" in check_result.get("output", ""): + return self.fail_response(f"Tmux session '{session_name}' does not exist.") + + # Get output from tmux pane + output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") + output = output_result.get("output", "") + + # Kill session if requested + if kill_session: + await self._execute_raw_command(f"tmux kill-session -t {session_name}") + termination_status = "Session terminated." + else: + termination_status = "Session still running." + + return self.success_response({ + "output": output, + "session_name": session_name, + "status": termination_status + }) + + except Exception as e: + return self.fail_response(f"Error checking command output: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "terminate_command", + "description": "Terminate a running command by killing its tmux session.", + "parameters": { + "type": "object", + "properties": { + "session_name": { + "type": "string", + "description": "The name of the tmux session to terminate." + } + }, + "required": ["session_name"] + } + } + }) + @xml_schema( + tag_name="terminate-command", + mappings=[ + {"param_name": "session_name", "node_type": "attribute", "path": ".", "required": True} + ], + example=''' + + + dev_server + + + ''' + ) + async def terminate_command( + self, + session_name: str + ) -> ToolResult: + try: + # Ensure sandbox is initialized + await self._ensure_sandbox() + + # Check if session exists + check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") + if "not_exists" in check_result.get("output", ""): + return self.fail_response(f"Tmux session '{session_name}' does not exist.") + + # Kill the session + await self._execute_raw_command(f"tmux kill-session -t {session_name}") + + return self.success_response({ + "message": f"Tmux session '{session_name}' terminated successfully." + }) + + except Exception as e: + return self.fail_response(f"Error terminating command: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "list_commands", + "description": "List all running tmux sessions and their status.", + "parameters": { + "type": "object", + "properties": {} + } + } + }) + @xml_schema( + tag_name="list-commands", + mappings=[], + example=''' + + + + + ''' + ) + async def list_commands(self) -> ToolResult: + try: + # Ensure sandbox is initialized + await self._ensure_sandbox() + + # List all tmux sessions + result = await self._execute_raw_command("tmux list-sessions 2>/dev/null || echo 'No sessions'") + output = result.get("output", "") + + if "No sessions" in output or not output.strip(): + return self.success_response({ + "message": "No active tmux sessions found.", + "sessions": [] + }) + + # Parse session list + sessions = [] + for line in output.split('\n'): + if line.strip(): + parts = line.split(':') + if parts: + session_name = parts[0].strip() + sessions.append(session_name) + + return self.success_response({ + "message": f"Found {len(sessions)} active sessions.", + "sessions": sessions + }) + + except Exception as e: + return self.fail_response(f"Error listing commands: {str(e)}") + + async def cleanup(self): + """Clean up all sessions.""" + for session_name in list(self._sessions.keys()): + await self._cleanup_session(session_name) + + # Also clean up any tmux sessions + try: + await self._ensure_sandbox() + await self._execute_raw_command("tmux kill-server 2>/dev/null || true") + except: + pass \ No newline at end of file diff --git a/app/tool/sb_vision_tool.py b/app/tool/sb_vision_tool.py new file mode 100644 index 000000000..6459b2d64 --- /dev/null +++ b/app/tool/sb_vision_tool.py @@ -0,0 +1,206 @@ +import os +import base64 +import mimetypes +from typing import Optional, Tuple +from io import BytesIO +from PIL import Image + +from agentpress.tool import ToolResult, openapi_schema, xml_schema +from sandbox.tool_base import SandboxToolsBase +from agentpress.thread_manager import ThreadManager +import json + +# Add common image MIME types if mimetypes module is limited +mimetypes.add_type("image/webp", ".webp") +mimetypes.add_type("image/jpeg", ".jpg") +mimetypes.add_type("image/jpeg", ".jpeg") +mimetypes.add_type("image/png", ".png") +mimetypes.add_type("image/gif", ".gif") + +# Maximum file size in bytes (e.g., 10MB for original, 5MB for compressed) +MAX_IMAGE_SIZE = 10 * 1024 * 1024 +MAX_COMPRESSED_SIZE = 5 * 1024 * 1024 + +# Compression settings +DEFAULT_MAX_WIDTH = 1920 +DEFAULT_MAX_HEIGHT = 1080 +DEFAULT_JPEG_QUALITY = 85 +DEFAULT_PNG_COMPRESS_LEVEL = 6 + +class SandboxVisionTool(SandboxToolsBase): + """Tool for allowing the agent to 'see' images within the sandbox.""" + + def __init__(self, project_id: str, thread_id: str, thread_manager: ThreadManager): + super().__init__(project_id, thread_manager) + self.thread_id = thread_id + # Make thread_manager accessible within the tool instance + self.thread_manager = thread_manager + + def compress_image(self, image_bytes: bytes, mime_type: str, file_path: str) -> Tuple[bytes, str]: + """Compress an image to reduce its size while maintaining reasonable quality. + + Args: + image_bytes: Original image bytes + mime_type: MIME type of the image + file_path: Path to the image file (for logging) + + Returns: + Tuple of (compressed_bytes, new_mime_type) + """ + try: + # Open image from bytes + img = Image.open(BytesIO(image_bytes)) + + # Convert RGBA to RGB if necessary (for JPEG) + if img.mode in ('RGBA', 'LA', 'P'): + # Create a white background + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + + # Calculate new dimensions while maintaining aspect ratio + width, height = img.size + if width > DEFAULT_MAX_WIDTH or height > DEFAULT_MAX_HEIGHT: + ratio = min(DEFAULT_MAX_WIDTH / width, DEFAULT_MAX_HEIGHT / height) + new_width = int(width * ratio) + new_height = int(height * ratio) + img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + print(f"[SeeImage] Resized image from {width}x{height} to {new_width}x{new_height}") + + # Save to bytes with compression + output = BytesIO() + + # Determine output format based on original mime type + if mime_type == 'image/gif': + # Keep GIFs as GIFs to preserve animation + img.save(output, format='GIF', optimize=True) + output_mime = 'image/gif' + elif mime_type == 'image/png': + # Compress PNG + img.save(output, format='PNG', optimize=True, compress_level=DEFAULT_PNG_COMPRESS_LEVEL) + output_mime = 'image/png' + else: + # Convert everything else to JPEG for better compression + img.save(output, format='JPEG', quality=DEFAULT_JPEG_QUALITY, optimize=True) + output_mime = 'image/jpeg' + + compressed_bytes = output.getvalue() + + # Log compression results + original_size = len(image_bytes) + compressed_size = len(compressed_bytes) + compression_ratio = (1 - compressed_size / original_size) * 100 + print(f"[SeeImage] Compressed '{file_path}' from {original_size / 1024:.1f}KB to {compressed_size / 1024:.1f}KB ({compression_ratio:.1f}% reduction)") + + return compressed_bytes, output_mime + + except Exception as e: + print(f"[SeeImage] Failed to compress image: {str(e)}. Using original.") + return image_bytes, mime_type + + @openapi_schema({ + "type": "function", + "function": { + "name": "see_image", + "description": "Allows the agent to 'see' an image file located in the /workspace directory. Provide the relative path to the image. The image will be compressed before sending to reduce token usage. The image content will be made available in the next turn's context.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The relative path to the image file within the /workspace directory (e.g., 'screenshots/image.png'). Supported formats: JPG, PNG, GIF, WEBP. Max size: 10MB." + } + }, + "required": ["file_path"] + } + } + }) + @xml_schema( + tag_name="see-image", + mappings=[ + {"param_name": "file_path", "node_type": "attribute", "path": "."} + ], + example=''' + + + + docs/diagram.png + + + ''' + ) + async def see_image(self, file_path: str) -> ToolResult: + """Reads an image file, compresses it, converts it to base64, and adds it as a temporary message.""" + try: + # Ensure sandbox is initialized + await self._ensure_sandbox() + + # Clean and construct full path + cleaned_path = self.clean_path(file_path) + full_path = f"{self.workspace_path}/{cleaned_path}" + + # Check if file exists and get info + try: + file_info = self.sandbox.fs.get_file_info(full_path) + if file_info.is_dir: + return self.fail_response(f"Path '{cleaned_path}' is a directory, not an image file.") + except Exception as e: + return self.fail_response(f"Image file not found at path: '{cleaned_path}'") + + # Check file size + if file_info.size > MAX_IMAGE_SIZE: + return self.fail_response(f"Image file '{cleaned_path}' is too large ({file_info.size / (1024*1024):.2f}MB). Maximum size is {MAX_IMAGE_SIZE / (1024*1024)}MB.") + + # Read image file content + try: + image_bytes = self.sandbox.fs.download_file(full_path) + except Exception as e: + return self.fail_response(f"Could not read image file: {cleaned_path}") + + # Determine MIME type + mime_type, _ = mimetypes.guess_type(full_path) + if not mime_type or not mime_type.startswith('image/'): + # Basic fallback based on extension if mimetypes fails + ext = os.path.splitext(cleaned_path)[1].lower() + if ext == '.jpg' or ext == '.jpeg': mime_type = 'image/jpeg' + elif ext == '.png': mime_type = 'image/png' + elif ext == '.gif': mime_type = 'image/gif' + elif ext == '.webp': mime_type = 'image/webp' + else: + return self.fail_response(f"Unsupported or unknown image format for file: '{cleaned_path}'. Supported: JPG, PNG, GIF, WEBP.") + + # Compress the image + compressed_bytes, compressed_mime_type = self.compress_image(image_bytes, mime_type, cleaned_path) + + # Check if compressed image is still too large + if len(compressed_bytes) > MAX_COMPRESSED_SIZE: + return self.fail_response(f"Image file '{cleaned_path}' is still too large after compression ({len(compressed_bytes) / (1024*1024):.2f}MB). Maximum compressed size is {MAX_COMPRESSED_SIZE / (1024*1024)}MB.") + + # Convert to base64 + base64_image = base64.b64encode(compressed_bytes).decode('utf-8') + + # Prepare the temporary message content + image_context_data = { + "mime_type": compressed_mime_type, + "base64": base64_image, + "file_path": cleaned_path, # Include path for context + "original_size": file_info.size, + "compressed_size": len(compressed_bytes) + } + + # Add the temporary message using the thread_manager callback + # Use a distinct type like 'image_context' + await self.thread_manager.add_message( + thread_id=self.thread_id, + type="image_context", # Use a specific type for this + content=image_context_data, # Store the dict directly + is_llm_message=False # This is context generated by a tool + ) + + # Inform the agent the image will be available next turn + return self.success_response(f"Successfully loaded and compressed the image '{cleaned_path}' (reduced from {file_info.size / 1024:.1f}KB to {len(compressed_bytes) / 1024:.1f}KB).") + + except Exception as e: + return self.fail_response(f"An unexpected error occurred while trying to see the image: {str(e)}") \ No newline at end of file diff --git a/app/tool/update_agent_tool.py b/app/tool/update_agent_tool.py new file mode 100644 index 000000000..5113e56bd --- /dev/null +++ b/app/tool/update_agent_tool.py @@ -0,0 +1,889 @@ +import json +import httpx +from typing import Optional, Dict, Any, List +from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema +from agentpress.thread_manager import ThreadManager + +class UpdateAgentTool(Tool): + """Tool for updating agent configuration. + + This tool is used by the agent builder to update agent properties + based on user requirements. + """ + + def __init__(self, thread_manager: ThreadManager, db_connection, agent_id: str): + super().__init__() + self.thread_manager = thread_manager + self.db = db_connection + self.agent_id = agent_id + # Smithery API configuration + self.smithery_api_base_url = "https://registry.smithery.ai" + import os + self.smithery_api_key = os.getenv("SMITHERY_API_KEY") + + @openapi_schema({ + "type": "function", + "function": { + "name": "update_agent", + "description": "Update the agent's configuration including name, description, system prompt, tools, and MCP servers. Call this whenever the user wants to modify any aspect of the agent.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the agent. Should be descriptive and indicate the agent's purpose." + }, + "description": { + "type": "string", + "description": "A brief description of what the agent does and its capabilities." + }, + "system_prompt": { + "type": "string", + "description": "The system instructions that define the agent's behavior, expertise, and approach. This should be comprehensive and well-structured." + }, + "agentpress_tools": { + "type": "object", + "description": "Configuration for AgentPress tools. Each key is a tool name, and the value is an object with 'enabled' (boolean) and 'description' (string) properties.", + "additionalProperties": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "description": {"type": "string"} + } + } + }, + "configured_mcps": { + "type": "array", + "description": "List of configured MCP servers for external integrations.", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "qualifiedName": {"type": "string"}, + "config": {"type": "object"}, + "enabledTools": { + "type": "array", + "items": {"type": "string"} + } + } + } + }, + "avatar": { + "type": "string", + "description": "Emoji to use as the agent's avatar." + }, + "avatar_color": { + "type": "string", + "description": "Hex color code for the agent's avatar background." + } + }, + "required": [] + } + } + }) + @xml_schema( + tag_name="update-agent", + mappings=[ + {"param_name": "name", "node_type": "attribute", "path": ".", "required": False}, + {"param_name": "description", "node_type": "element", "path": "description", "required": False}, + {"param_name": "system_prompt", "node_type": "element", "path": "system_prompt", "required": False}, + {"param_name": "agentpress_tools", "node_type": "element", "path": "agentpress_tools", "required": False}, + {"param_name": "configured_mcps", "node_type": "element", "path": "configured_mcps", "required": False}, + {"param_name": "avatar", "node_type": "attribute", "path": ".", "required": False}, + {"param_name": "avatar_color", "node_type": "attribute", "path": ".", "required": False} + ], + example=''' + + + Research Assistant + An AI assistant specialized in conducting research and providing comprehensive analysis + You are a research assistant with expertise in gathering, analyzing, and synthesizing information. Your approach is thorough and methodical... + {"web_search": {"enabled": true, "description": "Search the web for information"}, "sb_files": {"enabled": true, "description": "Read and write files"}} + 🔬 + #4F46E5 + + + ''' + ) + async def update_agent( + self, + name: Optional[str] = None, + description: Optional[str] = None, + system_prompt: Optional[str] = None, + agentpress_tools: Optional[Dict[str, Dict[str, Any]]] = None, + configured_mcps: Optional[list] = None, + avatar: Optional[str] = None, + avatar_color: Optional[str] = None + ) -> ToolResult: + """Update agent configuration with provided fields. + + Args: + name: Agent name + description: Agent description + system_prompt: System instructions for the agent + agentpress_tools: AgentPress tools configuration + configured_mcps: MCP servers configuration + avatar: Emoji avatar + avatar_color: Avatar background color + + Returns: + ToolResult with updated agent data or error + """ + try: + client = await self.db.client + + update_data = {} + if name is not None: + update_data["name"] = name + if description is not None: + update_data["description"] = description + if system_prompt is not None: + update_data["system_prompt"] = system_prompt + if agentpress_tools is not None: + formatted_tools = {} + for tool_name, tool_config in agentpress_tools.items(): + if isinstance(tool_config, dict): + formatted_tools[tool_name] = { + "enabled": tool_config.get("enabled", False), + "description": tool_config.get("description", "") + } + update_data["agentpress_tools"] = formatted_tools + if configured_mcps is not None: + if isinstance(configured_mcps, str): + configured_mcps = json.loads(configured_mcps) + update_data["configured_mcps"] = configured_mcps + if avatar is not None: + update_data["avatar"] = avatar + if avatar_color is not None: + update_data["avatar_color"] = avatar_color + + if not update_data: + return self.fail_response("No fields provided to update") + + result = await client.table('agents').update(update_data).eq('agent_id', self.agent_id).execute() + + if not result.data: + return self.fail_response("Failed to update agent") + + return self.success_response({ + "message": "Agent updated successfully", + "updated_fields": list(update_data.keys()), + "agent": result.data[0] + }) + + except Exception as e: + return self.fail_response(f"Error updating agent: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "get_current_agent_config", + "description": "Get the current configuration of the agent being edited. Use this to check what's already configured before making updates.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }) + @xml_schema( + tag_name="get-current-agent-config", + mappings=[], + example=''' + + + + + ''' + ) + async def get_current_agent_config(self) -> ToolResult: + """Get the current agent configuration. + + Returns: + ToolResult with current agent configuration + """ + try: + client = await self.db.client + + result = await client.table('agents').select('*').eq('agent_id', self.agent_id).execute() + + if not result.data: + return self.fail_response("Agent not found") + + agent = result.data[0] + + config_summary = { + "agent_id": agent["agent_id"], + "name": agent.get("name", "Untitled Agent"), + "description": agent.get("description", "No description set"), + "system_prompt": agent.get("system_prompt", "No system prompt set"), + "avatar": agent.get("avatar", "🤖"), + "avatar_color": agent.get("avatar_color", "#6B7280"), + "agentpress_tools": agent.get("agentpress_tools", {}), + "configured_mcps": agent.get("configured_mcps", []), + "created_at": agent.get("created_at"), + "updated_at": agent.get("updated_at") + } + + tools_count = len([t for t, cfg in config_summary["agentpress_tools"].items() if cfg.get("enabled")]) + mcps_count = len(config_summary["configured_mcps"]) + + return self.success_response({ + "summary": f"Agent '{config_summary['name']}' has {tools_count} tools enabled and {mcps_count} MCP servers configured.", + "configuration": config_summary + }) + + except Exception as e: + return self.fail_response(f"Error getting agent configuration: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "search_mcp_servers", + "description": "Search for MCP servers from the Smithery registry based on user requirements. Use this when the user wants to add MCP tools to their agent.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query for finding relevant MCP servers (e.g., 'linear', 'github', 'database', 'search')" + }, + "category": { + "type": "string", + "description": "Optional category filter", + "enum": ["AI & Search", "Development & Version Control", "Project Management", "Communication & Collaboration", "Data & Analytics", "Cloud & Infrastructure", "File Storage", "Marketing & Sales", "Customer Support", "Finance", "Automation & Productivity", "Utilities"] + }, + "limit": { + "type": "integer", + "description": "Maximum number of servers to return (default: 10)", + "default": 10 + } + }, + "required": ["query"] + } + } + }) + @xml_schema( + tag_name="search-mcp-servers", + mappings=[ + {"param_name": "query", "node_type": "attribute", "path": "."}, + {"param_name": "category", "node_type": "attribute", "path": "."}, + {"param_name": "limit", "node_type": "attribute", "path": "."} + ], + example=''' + + + linear + 5 + + + ''' + ) + async def search_mcp_servers( + self, + query: str, + category: Optional[str] = None, + limit: int = 10 + ) -> ToolResult: + """Search for MCP servers based on user requirements. + + Args: + query: Search query for finding relevant MCP servers + category: Optional category filter + limit: Maximum number of servers to return + + Returns: + ToolResult with matching MCP servers + """ + try: + async with httpx.AsyncClient() as client: + headers = { + "Accept": "application/json", + "User-Agent": "Suna-MCP-Integration/1.0" + } + + if self.smithery_api_key: + headers["Authorization"] = f"Bearer {self.smithery_api_key}" + + params = { + "q": query, + "page": 1, + "pageSize": min(limit * 2, 50) # Get more results to filter + } + + response = await client.get( + f"{self.smithery_api_base_url}/servers", + headers=headers, + params=params, + timeout=30.0 + ) + + response.raise_for_status() + data = response.json() + servers = data.get("servers", []) + + # Filter by category if specified + if category: + filtered_servers = [] + for server in servers: + server_category = self._categorize_server(server) + if server_category == category: + filtered_servers.append(server) + servers = filtered_servers + + # Sort by useCount and limit results + servers = sorted(servers, key=lambda x: x.get("useCount", 0), reverse=True)[:limit] + + # Format results for user-friendly display + formatted_servers = [] + for server in servers: + formatted_servers.append({ + "name": server.get("displayName", server.get("qualifiedName", "Unknown")), + "qualifiedName": server.get("qualifiedName"), + "description": server.get("description", "No description available"), + "useCount": server.get("useCount", 0), + "category": self._categorize_server(server), + "homepage": server.get("homepage", ""), + "isDeployed": server.get("isDeployed", False) + }) + + if not formatted_servers: + return ToolResult( + success=False, + output=json.dumps([], ensure_ascii=False) + ) + + return ToolResult( + success=True, + output=json.dumps(formatted_servers, ensure_ascii=False) + ) + + except Exception as e: + return self.fail_response(f"Error searching MCP servers: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "get_mcp_server_tools", + "description": "Get detailed information about a specific MCP server including its available tools. Use this after the user selects a server they want to connect to.", + "parameters": { + "type": "object", + "properties": { + "qualified_name": { + "type": "string", + "description": "The qualified name of the MCP server (e.g., 'exa', '@smithery-ai/github')" + } + }, + "required": ["qualified_name"] + } + } + }) + @xml_schema( + tag_name="get-mcp-server-tools", + mappings=[ + {"param_name": "qualified_name", "node_type": "attribute", "path": ".", "required": True} + ], + example=''' + + + exa + + + ''' + ) + async def get_mcp_server_tools(self, qualified_name: str) -> ToolResult: + """Get detailed information about a specific MCP server and its tools. + + Args: + qualified_name: The qualified name of the MCP server + + Returns: + ToolResult with server details and available tools + """ + try: + # First get server metadata from registry + async with httpx.AsyncClient() as client: + headers = { + "Accept": "application/json", + "User-Agent": "Suna-MCP-Integration/1.0" + } + + if self.smithery_api_key: + headers["Authorization"] = f"Bearer {self.smithery_api_key}" + + # URL encode the qualified name if it contains special characters + from urllib.parse import quote + if '@' in qualified_name or '/' in qualified_name: + encoded_name = quote(qualified_name, safe='') + else: + encoded_name = qualified_name + + url = f"{self.smithery_api_base_url}/servers/{encoded_name}" + + response = await client.get( + url, + headers=headers, + timeout=30.0 + ) + + response.raise_for_status() + server_data = response.json() + + # Now connect to the MCP server to get actual tools using ClientSession + try: + # Import MCP components + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + import base64 + import os + + # Check if Smithery API key is available + smithery_api_key = os.getenv("SMITHERY_API_KEY") + if not smithery_api_key: + raise ValueError("SMITHERY_API_KEY environment variable is not set") + + # Create server URL with empty config for testing + config_json = json.dumps({}) + config_b64 = base64.b64encode(config_json.encode()).decode() + server_url = f"https://server.smithery.ai/{qualified_name}/mcp?config={config_b64}&api_key={smithery_api_key}" + + # Connect and get tools + async with streamablehttp_client(server_url) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + await session.initialize() + + # List available tools + tools_result = await session.list_tools() + tools = tools_result.tools if hasattr(tools_result, 'tools') else tools_result + + # Format tools for user-friendly display + formatted_tools = [] + for tool in tools: + tool_info = { + "name": tool.name, + "description": getattr(tool, 'description', 'No description available'), + } + + # Extract parameters from inputSchema if available + if hasattr(tool, 'inputSchema') and tool.inputSchema: + schema = tool.inputSchema + if isinstance(schema, dict): + tool_info["parameters"] = schema.get("properties", {}) + tool_info["required_params"] = schema.get("required", []) + else: + tool_info["parameters"] = {} + tool_info["required_params"] = [] + else: + tool_info["parameters"] = {} + tool_info["required_params"] = [] + + formatted_tools.append(tool_info) + + # Extract configuration requirements from server metadata + config_requirements = [] + security = server_data.get("security", {}) + if security: + for key, value in security.items(): + if isinstance(value, dict): + config_requirements.append({ + "name": key, + "description": value.get("description", f"Configuration for {key}"), + "required": value.get("required", False), + "type": value.get("type", "string") + }) + + server_info = { + "name": server_data.get("displayName", qualified_name), + "qualifiedName": qualified_name, + "description": server_data.get("description", "No description available"), + "homepage": server_data.get("homepage", ""), + "iconUrl": server_data.get("iconUrl", ""), + "isDeployed": server_data.get("isDeployed", False), + "tools": formatted_tools, + "config_requirements": config_requirements, + "total_tools": len(formatted_tools) + } + + return self.success_response({ + "message": f"Found {len(formatted_tools)} tools for {server_info['name']}", + "server": server_info + }) + + except Exception as mcp_error: + # If MCP connection fails, fall back to registry data + tools = server_data.get("tools", []) + formatted_tools = [] + for tool in tools: + formatted_tools.append({ + "name": tool.get("name", "Unknown"), + "description": tool.get("description", "No description available"), + "parameters": tool.get("inputSchema", {}).get("properties", {}), + "required_params": tool.get("inputSchema", {}).get("required", []) + }) + + config_requirements = [] + security = server_data.get("security", {}) + if security: + for key, value in security.items(): + if isinstance(value, dict): + config_requirements.append({ + "name": key, + "description": value.get("description", f"Configuration for {key}"), + "required": value.get("required", False), + "type": value.get("type", "string") + }) + + server_info = { + "name": server_data.get("displayName", qualified_name), + "qualifiedName": qualified_name, + "description": server_data.get("description", "No description available"), + "homepage": server_data.get("homepage", ""), + "iconUrl": server_data.get("iconUrl", ""), + "isDeployed": server_data.get("isDeployed", False), + "tools": formatted_tools, + "config_requirements": config_requirements, + "total_tools": len(formatted_tools), + "note": "Tools listed from registry metadata (MCP connection failed - may need configuration)" + } + + return self.success_response({ + "message": f"Found {len(formatted_tools)} tools for {server_info['name']} (from registry)", + "server": server_info + }) + + except Exception as e: + return self.fail_response(f"Error getting MCP server tools: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "configure_mcp_server", + "description": "Configure and add an MCP server to the agent with selected tools. Use this after the user has chosen which tools they want from a server.", + "parameters": { + "type": "object", + "properties": { + "qualified_name": { + "type": "string", + "description": "The qualified name of the MCP server" + }, + "display_name": { + "type": "string", + "description": "Display name for the server" + }, + "enabled_tools": { + "type": "array", + "description": "List of tool names to enable for this server", + "items": {"type": "string"} + }, + "config": { + "type": "object", + "description": "Configuration object with API keys and other settings", + "additionalProperties": True + } + }, + "required": ["qualified_name", "display_name", "enabled_tools"] + } + } + }) + @xml_schema( + tag_name="configure-mcp-server", + mappings=[ + {"param_name": "qualified_name", "node_type": "attribute", "path": ".", "required": True}, + {"param_name": "display_name", "node_type": "attribute", "path": ".", "required": True}, + {"param_name": "enabled_tools", "node_type": "element", "path": "enabled_tools", "required": True}, + {"param_name": "config", "node_type": "element", "path": "config", "required": False} + ], + example=''' + + + exa + Exa Search + ["search", "find_similar"] + {"exaApiKey": "user-api-key"} + + + ''' + ) + async def configure_mcp_server( + self, + qualified_name: str, + display_name: str, + enabled_tools: List[str], + config: Optional[Dict[str, Any]] = None + ) -> ToolResult: + """Configure and add an MCP server to the agent. + + Args: + qualified_name: The qualified name of the MCP server + display_name: Display name for the server + enabled_tools: List of tool names to enable + config: Configuration object with API keys and settings + + Returns: + ToolResult with configuration status + """ + try: + client = await self.db.client + + # Get current agent configuration + result = await client.table('agents').select('configured_mcps').eq('agent_id', self.agent_id).execute() + + if not result.data: + return self.fail_response("Agent not found") + + current_mcps = result.data[0].get('configured_mcps', []) + + # Check if server is already configured + existing_server_index = None + for i, mcp in enumerate(current_mcps): + if mcp.get('qualifiedName') == qualified_name: + existing_server_index = i + break + + # Create new MCP configuration + new_mcp_config = { + "name": display_name, + "qualifiedName": qualified_name, + "config": config or {}, + "enabledTools": enabled_tools + } + + # Update or add the configuration + if existing_server_index is not None: + current_mcps[existing_server_index] = new_mcp_config + action = "updated" + else: + current_mcps.append(new_mcp_config) + action = "added" + + # Save to database + update_result = await client.table('agents').update({ + 'configured_mcps': current_mcps + }).eq('agent_id', self.agent_id).execute() + + if not update_result.data: + return self.fail_response("Failed to save MCP configuration") + + return self.success_response({ + "message": f"Successfully {action} MCP server '{display_name}' with {len(enabled_tools)} tools", + "server": new_mcp_config, + "total_mcp_servers": len(current_mcps), + "action": action + }) + + except Exception as e: + return self.fail_response(f"Error configuring MCP server: {str(e)}") + + @openapi_schema({ + "type": "function", + "function": { + "name": "get_popular_mcp_servers", + "description": "Get a list of popular and recommended MCP servers organized by category. Use this to show users popular options when they want to add MCP tools.", + "parameters": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Optional category filter to show only servers from a specific category", + "enum": ["AI & Search", "Development & Version Control", "Project Management", "Communication & Collaboration", "Data & Analytics", "Cloud & Infrastructure", "File Storage", "Marketing & Sales", "Customer Support", "Finance", "Automation & Productivity", "Utilities"] + } + }, + "required": [] + } + } + }) + @xml_schema( + tag_name="get-popular-mcp-servers", + mappings=[ + {"param_name": "category", "node_type": "attribute", "path": ".", "required": False} + ], + example=''' + + + AI & Search + + + ''' + ) + async def get_popular_mcp_servers(self, category: Optional[str] = None) -> ToolResult: + """Get popular MCP servers organized by category. + + Args: + category: Optional category filter + + Returns: + ToolResult with popular MCP servers + """ + try: + async with httpx.AsyncClient() as client: + headers = { + "Accept": "application/json", + "User-Agent": "Suna-MCP-Integration/1.0" + } + + if self.smithery_api_key: + headers["Authorization"] = f"Bearer {self.smithery_api_key}" + + response = await client.get( + f"{self.smithery_api_base_url}/servers", + headers=headers, + params={"page": 1, "pageSize": 50}, + timeout=30.0 + ) + + response.raise_for_status() + data = response.json() + servers = data.get("servers", []) + + # Categorize servers + categorized = {} + for server in servers: + server_category = self._categorize_server(server) + if category and server_category != category: + continue + + if server_category not in categorized: + categorized[server_category] = [] + + categorized[server_category].append({ + "name": server.get("displayName", server.get("qualifiedName", "Unknown")), + "qualifiedName": server.get("qualifiedName"), + "description": server.get("description", "No description available"), + "useCount": server.get("useCount", 0), + "homepage": server.get("homepage", ""), + "isDeployed": server.get("isDeployed", False) + }) + + # Sort categories and servers within each category + for cat in categorized: + categorized[cat] = sorted(categorized[cat], key=lambda x: x["useCount"], reverse=True)[:5] + + return self.success_response({ + "message": f"Found popular MCP servers" + (f" in category '{category}'" if category else ""), + "categorized_servers": categorized, + "total_categories": len(categorized) + }) + + except Exception as e: + return self.fail_response(f"Error getting popular MCP servers: {str(e)}") + + def _categorize_server(self, server: Dict[str, Any]) -> str: + """Categorize a server based on its qualified name and description.""" + qualified_name = server.get("qualifiedName", "").lower() + description = server.get("description", "").lower() + + # Category mappings + category_mappings = { + "AI & Search": ["exa", "perplexity", "openai", "anthropic", "duckduckgo", "brave", "google", "search"], + "Development & Version Control": ["github", "gitlab", "bitbucket", "git"], + "Project Management": ["linear", "jira", "asana", "notion", "trello", "monday", "clickup"], + "Communication & Collaboration": ["slack", "discord", "teams", "zoom", "telegram"], + "Data & Analytics": ["postgres", "mysql", "mongodb", "bigquery", "snowflake", "sqlite", "redis", "database"], + "Cloud & Infrastructure": ["aws", "gcp", "azure", "vercel", "netlify", "cloudflare", "docker"], + "File Storage": ["gdrive", "google-drive", "dropbox", "box", "onedrive", "s3", "drive"], + "Marketing & Sales": ["hubspot", "salesforce", "mailchimp", "sendgrid"], + "Customer Support": ["zendesk", "intercom", "freshdesk", "helpscout"], + "Finance": ["stripe", "quickbooks", "xero", "plaid"], + "Automation & Productivity": ["playwright", "puppeteer", "selenium", "desktop-commander", "sequential-thinking", "automation"], + "Utilities": ["filesystem", "memory", "fetch", "time", "weather", "currency", "file"] + } + + # Check qualified name and description for category keywords + for category, keywords in category_mappings.items(): + for keyword in keywords: + if keyword in qualified_name or keyword in description: + return category + + return "Other" + + @openapi_schema({ + "type": "function", + "function": { + "name": "test_mcp_server_connection", + "description": "Test connectivity to an MCP server with provided configuration. Use this to validate that a server can be connected to before adding it to the agent.", + "parameters": { + "type": "object", + "properties": { + "qualified_name": { + "type": "string", + "description": "The qualified name of the MCP server" + }, + "config": { + "type": "object", + "description": "Configuration object with API keys and other settings", + "additionalProperties": True + } + }, + "required": ["qualified_name"] + } + } + }) + @xml_schema( + tag_name="test-mcp-server-connection", + mappings=[ + {"param_name": "qualified_name", "node_type": "attribute", "path": ".", "required": True}, + {"param_name": "config", "node_type": "element", "path": "config", "required": False} + ], + example=''' + + + exa + {"exaApiKey": "user-api-key"} + + + ''' + ) + async def test_mcp_server_connection( + self, + qualified_name: str, + config: Optional[Dict[str, Any]] = None + ) -> ToolResult: + """Test connectivity to an MCP server with provided configuration. + + Args: + qualified_name: The qualified name of the MCP server + config: Configuration object with API keys and settings + + Returns: + ToolResult with connection test results + """ + try: + # Import MCP components + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + import base64 + import os + + # Check if Smithery API key is available + smithery_api_key = os.getenv("SMITHERY_API_KEY") + if not smithery_api_key: + return self.fail_response("SMITHERY_API_KEY environment variable is not set") + + # Create server URL with provided config + config_json = json.dumps(config or {}) + config_b64 = base64.b64encode(config_json.encode()).decode() + server_url = f"https://server.smithery.ai/{qualified_name}/mcp?config={config_b64}&api_key={smithery_api_key}" + + # Test connection + async with streamablehttp_client(server_url) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + await session.initialize() + + # List available tools to verify connection + tools_result = await session.list_tools() + tools = tools_result.tools if hasattr(tools_result, 'tools') else tools_result + + tool_names = [tool.name for tool in tools] + + return self.success_response({ + "message": f"Successfully connected to {qualified_name}", + "qualified_name": qualified_name, + "connection_status": "success", + "available_tools": tool_names, + "total_tools": len(tool_names) + }) + + except Exception as e: + return self.fail_response(f"Failed to connect to {qualified_name}: {str(e)}") \ No newline at end of file diff --git a/app/tool/web_search_tool.py b/app/tool/web_search_tool.py new file mode 100644 index 000000000..3f1e9a3e3 --- /dev/null +++ b/app/tool/web_search_tool.py @@ -0,0 +1,395 @@ +from tavily import AsyncTavilyClient +import httpx +from dotenv import load_dotenv +from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema +from utils.config import config +from daytona.tool_base import SandboxToolsBase +from agentpress.thread_manager import ThreadManager +import json +import os +import datetime +import asyncio +import logging + +# TODO: add subpages, etc... in filters as sometimes its necessary + +class SandboxWebSearchTool(SandboxToolsBase): + """Tool for performing web searches using Tavily API and web scraping using Firecrawl.""" + + def __init__(self, project_id: str, thread_manager: ThreadManager): + super().__init__(project_id, thread_manager) + # Load environment variables + load_dotenv() + # Use API keys from config + self.tavily_api_key = config.TAVILY_API_KEY + self.firecrawl_api_key = config.FIRECRAWL_API_KEY + self.firecrawl_url = config.FIRECRAWL_URL + + if not self.tavily_api_key: + raise ValueError("TAVILY_API_KEY not found in configuration") + if not self.firecrawl_api_key: + raise ValueError("FIRECRAWL_API_KEY not found in configuration") + + # Tavily asynchronous search client + self.tavily_client = AsyncTavilyClient(api_key=self.tavily_api_key) + + @openapi_schema({ + "type": "function", + "function": { + "name": "web_search", + "description": "Search the web for up-to-date information on a specific topic using the Tavily API. This tool allows you to gather real-time information from the internet to answer user queries, research topics, validate facts, and find recent developments. Results include titles, URLs, and publication dates. Use this tool for discovering relevant web pages before potentially crawling them for complete content.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query to find relevant web pages. Be specific and include key terms to improve search accuracy. For best results, use natural language questions or keyword combinations that precisely describe what you're looking for." + }, + "num_results": { + "type": "integer", + "description": "The number of search results to return. Increase for more comprehensive research or decrease for focused, high-relevance results.", + "default": 20 + } + }, + "required": ["query"] + } + } + }) + @xml_schema( + tag_name="web-search", + mappings=[ + {"param_name": "query", "node_type": "attribute", "path": "."}, + {"param_name": "num_results", "node_type": "attribute", "path": "."} + ], + example=''' + + + what is Kortix AI and what are they building? + 20 + + + + + + + latest AI research on transformer models + 20 + + + ''' + ) + async def web_search( + self, + query: str, + num_results: int = 20 + ) -> ToolResult: + """ + Search the web using the Tavily API to find relevant and up-to-date information. + """ + try: + # Ensure we have a valid query + if not query or not isinstance(query, str): + return self.fail_response("A valid search query is required.") + + # Normalize num_results + if num_results is None: + num_results = 20 + elif isinstance(num_results, int): + num_results = max(1, min(num_results, 50)) + elif isinstance(num_results, str): + try: + num_results = max(1, min(int(num_results), 50)) + except ValueError: + num_results = 20 + else: + num_results = 20 + + # Execute the search with Tavily + logging.info(f"Executing web search for query: '{query}' with {num_results} results") + search_response = await self.tavily_client.search( + query=query, + max_results=num_results, + include_images=True, + include_answer="advanced", + search_depth="advanced", + ) + + # Check if we have actual results or an answer + results = search_response.get('results', []) + answer = search_response.get('answer', '') + + # Return the complete Tavily response + # This includes the query, answer, results, images and more + logging.info(f"Retrieved search results for query: '{query}' with answer and {len(results)} results") + + # Consider search successful if we have either results OR an answer + if len(results) > 0 or (answer and answer.strip()): + return ToolResult( + success=True, + output=json.dumps(search_response, ensure_ascii=False) + ) + else: + # No results or answer found + logging.warning(f"No search results or answer found for query: '{query}'") + return ToolResult( + success=False, + output=json.dumps(search_response, ensure_ascii=False) + ) + + except Exception as e: + error_message = str(e) + logging.error(f"Error performing web search for '{query}': {error_message}") + simplified_message = f"Error performing web search: {error_message[:200]}" + if len(error_message) > 200: + simplified_message += "..." + return self.fail_response(simplified_message) + + @openapi_schema({ + "type": "function", + "function": { + "name": "scrape_webpage", + "description": "Extract full text content from multiple webpages in a single operation. IMPORTANT: You should ALWAYS collect multiple relevant URLs from web-search results and scrape them all in a single call for efficiency. This tool saves time by processing multiple pages simultaneously rather than one at a time. The extracted text includes the main content of each page without HTML markup.", + "parameters": { + "type": "object", + "properties": { + "urls": { + "type": "string", + "description": "Multiple URLs to scrape, separated by commas. You should ALWAYS include several URLs when possible for efficiency. Example: 'https://example.com/page1,https://example.com/page2,https://example.com/page3'" + } + }, + "required": ["urls"] + } + } + }) + @xml_schema( + tag_name="scrape-webpage", + mappings=[ + {"param_name": "urls", "node_type": "attribute", "path": "."} + ], + example=''' + + + https://www.kortix.ai/,https://github.com/kortix-ai/suna + + + ''' + ) + async def scrape_webpage( + self, + urls: str + ) -> ToolResult: + """ + Retrieve the complete text content of multiple webpages in a single efficient operation. + + ALWAYS collect multiple relevant URLs from search results and scrape them all at once + rather than making separate calls for each URL. This is much more efficient. + + Parameters: + - urls: Multiple URLs to scrape, separated by commas + """ + try: + logging.info(f"Starting to scrape webpages: {urls}") + + # Ensure sandbox is initialized + await self._ensure_sandbox() + + # Parse the URLs parameter + if not urls: + logging.warning("Scrape attempt with empty URLs") + return self.fail_response("Valid URLs are required.") + + # Split the URLs string into a list + url_list = [url.strip() for url in urls.split(',') if url.strip()] + + if not url_list: + logging.warning("No valid URLs found in the input") + return self.fail_response("No valid URLs provided.") + + if len(url_list) == 1: + logging.warning("Only a single URL provided - for efficiency you should scrape multiple URLs at once") + + logging.info(f"Processing {len(url_list)} URLs: {url_list}") + + # Process each URL and collect results + results = [] + for url in url_list: + try: + # Add protocol if missing + if not (url.startswith('http://') or url.startswith('https://')): + url = 'https://' + url + logging.info(f"Added https:// protocol to URL: {url}") + + # Scrape this URL + result = await self._scrape_single_url(url) + results.append(result) + + except Exception as e: + logging.error(f"Error processing URL {url}: {str(e)}") + results.append({ + "url": url, + "success": False, + "error": str(e) + }) + + # Summarize results + successful = sum(1 for r in results if r.get("success", False)) + failed = len(results) - successful + + # Create success/failure message + if successful == len(results): + message = f"Successfully scraped all {len(results)} URLs. Results saved to:" + for r in results: + if r.get("file_path"): + message += f"\n- {r.get('file_path')}" + elif successful > 0: + message = f"Scraped {successful} URLs successfully and {failed} failed. Results saved to:" + for r in results: + if r.get("success", False) and r.get("file_path"): + message += f"\n- {r.get('file_path')}" + message += "\n\nFailed URLs:" + for r in results: + if not r.get("success", False): + message += f"\n- {r.get('url')}: {r.get('error', 'Unknown error')}" + else: + error_details = "; ".join([f"{r.get('url')}: {r.get('error', 'Unknown error')}" for r in results]) + return self.fail_response(f"Failed to scrape all {len(results)} URLs. Errors: {error_details}") + + return ToolResult( + success=True, + output=message + ) + + except Exception as e: + error_message = str(e) + logging.error(f"Error in scrape_webpage: {error_message}") + return self.fail_response(f"Error processing scrape request: {error_message[:200]}") + + async def _scrape_single_url(self, url: str) -> dict: + """ + Helper function to scrape a single URL and return the result information. + """ + logging.info(f"Scraping single URL: {url}") + + try: + # ---------- Firecrawl scrape endpoint ---------- + logging.info(f"Sending request to Firecrawl for URL: {url}") + async with httpx.AsyncClient() as client: + headers = { + "Authorization": f"Bearer {self.firecrawl_api_key}", + "Content-Type": "application/json", + } + payload = { + "url": url, + "formats": ["markdown"] + } + + # Use longer timeout and retry logic for more reliability + max_retries = 3 + timeout_seconds = 120 + retry_count = 0 + + while retry_count < max_retries: + try: + logging.info(f"Sending request to Firecrawl (attempt {retry_count + 1}/{max_retries})") + response = await client.post( + f"{self.firecrawl_url}/v1/scrape", + json=payload, + headers=headers, + timeout=timeout_seconds, + ) + response.raise_for_status() + data = response.json() + logging.info(f"Successfully received response from Firecrawl for {url}") + break + except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.ReadError) as timeout_err: + retry_count += 1 + logging.warning(f"Request timed out (attempt {retry_count}/{max_retries}): {str(timeout_err)}") + if retry_count >= max_retries: + raise Exception(f"Request timed out after {max_retries} attempts with {timeout_seconds}s timeout") + # Exponential backoff + logging.info(f"Waiting {2 ** retry_count}s before retry") + await asyncio.sleep(2 ** retry_count) + except Exception as e: + # Don't retry on non-timeout errors + logging.error(f"Error during scraping: {str(e)}") + raise e + + # Format the response + title = data.get("data", {}).get("metadata", {}).get("title", "") + markdown_content = data.get("data", {}).get("markdown", "") + logging.info(f"Extracted content from {url}: title='{title}', content length={len(markdown_content)}") + + formatted_result = { + "title": title, + "url": url, + "text": markdown_content + } + + # Add metadata if available + if "metadata" in data.get("data", {}): + formatted_result["metadata"] = data["data"]["metadata"] + logging.info(f"Added metadata: {data['data']['metadata'].keys()}") + + # Create a simple filename from the URL domain and date + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + + # Extract domain from URL for the filename + from urllib.parse import urlparse + parsed_url = urlparse(url) + domain = parsed_url.netloc.replace("www.", "") + + # Clean up domain for filename + domain = "".join([c if c.isalnum() else "_" for c in domain]) + safe_filename = f"{timestamp}_{domain}.json" + + logging.info(f"Generated filename: {safe_filename}") + + # Save results to a file in the /workspace/scrape directory + scrape_dir = f"{self.workspace_path}/scrape" + self.sandbox.fs.create_folder(scrape_dir, "755") + + results_file_path = f"{scrape_dir}/{safe_filename}" + json_content = json.dumps(formatted_result, ensure_ascii=False, indent=2) + logging.info(f"Saving content to file: {results_file_path}, size: {len(json_content)} bytes") + + self.sandbox.fs.upload_file( + json_content.encode(), + results_file_path, + ) + + return { + "url": url, + "success": True, + "title": title, + "file_path": results_file_path, + "content_length": len(markdown_content) + } + + except Exception as e: + error_message = str(e) + logging.error(f"Error scraping URL '{url}': {error_message}") + + # Create an error result + return { + "url": url, + "success": False, + "error": error_message + } + +if __name__ == "__main__": + async def test_web_search(): + """Test function for the web search tool""" + # This test function is not compatible with the sandbox version + print("Test function needs to be updated for sandbox version") + + async def test_scrape_webpage(): + """Test function for the webpage scrape tool""" + # This test function is not compatible with the sandbox version + print("Test function needs to be updated for sandbox version") + + async def run_tests(): + """Run all test functions""" + await test_web_search() + await test_scrape_webpage() + + asyncio.run(run_tests()) From 527fd6483baaaf554d6db03277d0435771018787 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Sun, 29 Jun 2025 10:12:23 +0800 Subject: [PATCH 02/33] add utils --- app/utils/__init__.py | 1 + app/utils/auth_utils.py | 231 ++++++++++ app/utils/config.py | 253 +++++++++++ app/utils/constants.py | 145 +++++++ app/utils/files_utils.py | 91 ++++ app/utils/logger.py | 28 ++ app/utils/retry.py | 58 +++ app/utils/s3_upload_utils.py | 51 +++ .../scripts/archive_inactive_sandboxes.py | 350 +++++++++++++++ app/utils/scripts/archive_old_sandboxes.py | 344 +++++++++++++++ app/utils/scripts/copy_project.py | 388 +++++++++++++++++ app/utils/scripts/delete_user_sandboxes.py | 138 ++++++ app/utils/scripts/export_import_project.py | 400 ++++++++++++++++++ app/utils/scripts/generate_share_links.py | 166 ++++++++ app/utils/scripts/get_monthly_usage.py | 248 +++++++++++ app/utils/scripts/set_all_customers_active.py | 142 +++++++ .../scripts/update_customer_active_status.py | 326 ++++++++++++++ 17 files changed, 3360 insertions(+) create mode 100644 app/utils/__init__.py create mode 100644 app/utils/auth_utils.py create mode 100644 app/utils/config.py create mode 100644 app/utils/constants.py create mode 100644 app/utils/files_utils.py create mode 100644 app/utils/logger.py create mode 100644 app/utils/retry.py create mode 100644 app/utils/s3_upload_utils.py create mode 100644 app/utils/scripts/archive_inactive_sandboxes.py create mode 100644 app/utils/scripts/archive_old_sandboxes.py create mode 100644 app/utils/scripts/copy_project.py create mode 100644 app/utils/scripts/delete_user_sandboxes.py create mode 100644 app/utils/scripts/export_import_project.py create mode 100644 app/utils/scripts/generate_share_links.py create mode 100644 app/utils/scripts/get_monthly_usage.py create mode 100644 app/utils/scripts/set_all_customers_active.py create mode 100644 app/utils/scripts/update_customer_active_status.py diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 000000000..8ab9008ad --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +# Utility functions and constants for agent tools \ No newline at end of file diff --git a/app/utils/auth_utils.py b/app/utils/auth_utils.py new file mode 100644 index 000000000..62de82055 --- /dev/null +++ b/app/utils/auth_utils.py @@ -0,0 +1,231 @@ +import sentry +from fastapi import HTTPException, Request +from typing import Optional +import jwt +from jwt.exceptions import PyJWTError +from utils.logger import structlog + +# This function extracts the user ID from Supabase JWT +async def get_current_user_id_from_jwt(request: Request) -> str: + """ + Extract and verify the user ID from the JWT in the Authorization header. + + This function is used as a dependency in FastAPI routes to ensure the user + is authenticated and to provide the user ID for authorization checks. + + Args: + request: The FastAPI request object + + Returns: + str: The user ID extracted from the JWT + + Raises: + HTTPException: If no valid token is found or if the token is invalid + """ + auth_header = request.headers.get('Authorization') + + if not auth_header or not auth_header.startswith('Bearer '): + raise HTTPException( + status_code=401, + detail="No valid authentication credentials found", + headers={"WWW-Authenticate": "Bearer"} + ) + + token = auth_header.split(' ')[1] + + try: + # For Supabase JWT, we just need to decode and extract the user ID + # The actual validation is handled by Supabase's RLS + payload = jwt.decode(token, options={"verify_signature": False}) + + # Supabase stores the user ID in the 'sub' claim + user_id = payload.get('sub') + + if not user_id: + raise HTTPException( + status_code=401, + detail="Invalid token payload", + headers={"WWW-Authenticate": "Bearer"} + ) + + sentry.sentry.set_user({ "id": user_id }) + structlog.contextvars.bind_contextvars( + user_id=user_id + ) + return user_id + + except PyJWTError: + raise HTTPException( + status_code=401, + detail="Invalid token", + headers={"WWW-Authenticate": "Bearer"} + ) + +async def get_account_id_from_thread(client, thread_id: str) -> str: + """ + Extract and verify the account ID from the thread. + + Args: + client: The Supabase client + thread_id: The ID of the thread + + Returns: + str: The account ID associated with the thread + + Raises: + HTTPException: If the thread is not found or if there's an error + """ + try: + response = await client.table('threads').select('account_id').eq('thread_id', thread_id).execute() + + if not response.data or len(response.data) == 0: + raise HTTPException( + status_code=404, + detail="Thread not found" + ) + + account_id = response.data[0].get('account_id') + + if not account_id: + raise HTTPException( + status_code=500, + detail="Thread has no associated account" + ) + + return account_id + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Error retrieving thread information: {str(e)}" + ) + +async def get_user_id_from_stream_auth( + request: Request, + token: Optional[str] = None +) -> str: + """ + Extract and verify the user ID from either the Authorization header or query parameter token. + This function is specifically designed for streaming endpoints that need to support both + header-based and query parameter-based authentication (for EventSource compatibility). + + Args: + request: The FastAPI request object + token: Optional token from query parameters + + Returns: + str: The user ID extracted from the JWT + + Raises: + HTTPException: If no valid token is found or if the token is invalid + """ + # Try to get user_id from token in query param (for EventSource which can't set headers) + if token: + try: + # For Supabase JWT, we just need to decode and extract the user ID + payload = jwt.decode(token, options={"verify_signature": False}) + user_id = payload.get('sub') + if user_id: + sentry.sentry.set_user({ "id": user_id }) + structlog.contextvars.bind_contextvars( + user_id=user_id + ) + return user_id + except Exception: + pass + + # If no valid token in query param, try to get it from the Authorization header + auth_header = request.headers.get('Authorization') + if auth_header and auth_header.startswith('Bearer '): + try: + # Extract token from header + header_token = auth_header.split(' ')[1] + payload = jwt.decode(header_token, options={"verify_signature": False}) + user_id = payload.get('sub') + if user_id: + return user_id + except Exception: + pass + + # If we still don't have a user_id, return authentication error + raise HTTPException( + status_code=401, + detail="No valid authentication credentials found", + headers={"WWW-Authenticate": "Bearer"} + ) + +async def verify_thread_access(client, thread_id: str, user_id: str): + """ + Verify that a user has access to a specific thread based on account membership. + + Args: + client: The Supabase client + thread_id: The thread ID to check access for + user_id: The user ID to check permissions for + + Returns: + bool: True if the user has access + + Raises: + HTTPException: If the user doesn't have access to the thread + """ + # Query the thread to get account information + thread_result = await client.table('threads').select('*,project_id').eq('thread_id', thread_id).execute() + + if not thread_result.data or len(thread_result.data) == 0: + raise HTTPException(status_code=404, detail="Thread not found") + + thread_data = thread_result.data[0] + + # Check if project is public + project_id = thread_data.get('project_id') + if project_id: + project_result = await client.table('projects').select('is_public').eq('project_id', project_id).execute() + if project_result.data and len(project_result.data) > 0: + if project_result.data[0].get('is_public'): + return True + + account_id = thread_data.get('account_id') + # When using service role, we need to manually check account membership instead of using current_user_account_role + if account_id: + account_user_result = await client.schema('basejump').from_('account_user').select('account_role').eq('user_id', user_id).eq('account_id', account_id).execute() + if account_user_result.data and len(account_user_result.data) > 0: + return True + raise HTTPException(status_code=403, detail="Not authorized to access this thread") + +async def get_optional_user_id(request: Request) -> Optional[str]: + """ + Extract the user ID from the JWT in the Authorization header if present, + but don't require authentication. Returns None if no valid token is found. + + This function is used for endpoints that support both authenticated and + unauthenticated access (like public projects). + + Args: + request: The FastAPI request object + + Returns: + Optional[str]: The user ID extracted from the JWT, or None if no valid token + """ + auth_header = request.headers.get('Authorization') + + if not auth_header or not auth_header.startswith('Bearer '): + return None + + token = auth_header.split(' ')[1] + + try: + # For Supabase JWT, we just need to decode and extract the user ID + payload = jwt.decode(token, options={"verify_signature": False}) + + # Supabase stores the user ID in the 'sub' claim + user_id = payload.get('sub') + if user_id: + sentry.sentry.set_user({ "id": user_id }) + structlog.contextvars.bind_contextvars( + user_id=user_id + ) + + return user_id + except PyJWTError: + return None diff --git a/app/utils/config.py b/app/utils/config.py new file mode 100644 index 000000000..ab2853727 --- /dev/null +++ b/app/utils/config.py @@ -0,0 +1,253 @@ +""" +Configuration management. + +This module provides a centralized way to access configuration settings and +environment variables across the application. It supports different environment +modes (development, staging, production) and provides validation for required +values. + +Usage: + from utils.config import config + + # Access configuration values + api_key = config.OPENAI_API_KEY + env_mode = config.ENV_MODE +""" + +import os +from enum import Enum +from typing import Dict, Any, Optional, get_type_hints, Union +from dotenv import load_dotenv +import logging + +logger = logging.getLogger(__name__) + +class EnvMode(Enum): + """Environment mode enumeration.""" + LOCAL = "local" + STAGING = "staging" + PRODUCTION = "production" + +class Configuration: + """ + Centralized configuration for AgentPress backend. + + This class loads environment variables and provides type checking and validation. + Default values can be specified for optional configuration items. + """ + + # Environment mode + ENV_MODE: EnvMode = EnvMode.LOCAL + + # Subscription tier IDs - Production + STRIPE_FREE_TIER_ID_PROD: str = 'price_1RILb4G6l1KZGqIrK4QLrx9i' + STRIPE_TIER_2_20_ID_PROD: str = 'price_1RILb4G6l1KZGqIrhomjgDnO' + STRIPE_TIER_6_50_ID_PROD: str = 'price_1RILb4G6l1KZGqIr5q0sybWn' + STRIPE_TIER_12_100_ID_PROD: str = 'price_1RILb4G6l1KZGqIr5Y20ZLHm' + STRIPE_TIER_25_200_ID_PROD: str = 'price_1RILb4G6l1KZGqIrGAD8rNjb' + STRIPE_TIER_50_400_ID_PROD: str = 'price_1RILb4G6l1KZGqIruNBUMTF1' + STRIPE_TIER_125_800_ID_PROD: str = 'price_1RILb3G6l1KZGqIrbJA766tN' + STRIPE_TIER_200_1000_ID_PROD: str = 'price_1RILb3G6l1KZGqIrmauYPOiN' + + # Subscription tier IDs - Staging + STRIPE_FREE_TIER_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrw14abxeL' + STRIPE_TIER_2_20_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrCRu0E4Gi' + STRIPE_TIER_6_50_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrvjlz5p5V' + STRIPE_TIER_12_100_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrT6UfgblC' + STRIPE_TIER_25_200_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrOVLKlOMj' + STRIPE_TIER_50_400_ID_STAGING: str = 'price_1RIKNgG6l1KZGqIrvsat5PW7' + STRIPE_TIER_125_800_ID_STAGING: str = 'price_1RIKNrG6l1KZGqIrjKT0yGvI' + STRIPE_TIER_200_1000_ID_STAGING: str = 'price_1RIKQ2G6l1KZGqIrum9n8SI7' + + # Computed subscription tier IDs based on environment + @property + def STRIPE_FREE_TIER_ID(self) -> str: + if self.ENV_MODE == EnvMode.STAGING: + return self.STRIPE_FREE_TIER_ID_STAGING + return self.STRIPE_FREE_TIER_ID_PROD + + @property + def STRIPE_TIER_2_20_ID(self) -> str: + if self.ENV_MODE == EnvMode.STAGING: + return self.STRIPE_TIER_2_20_ID_STAGING + return self.STRIPE_TIER_2_20_ID_PROD + + @property + def STRIPE_TIER_6_50_ID(self) -> str: + if self.ENV_MODE == EnvMode.STAGING: + return self.STRIPE_TIER_6_50_ID_STAGING + return self.STRIPE_TIER_6_50_ID_PROD + + @property + def STRIPE_TIER_12_100_ID(self) -> str: + if self.ENV_MODE == EnvMode.STAGING: + return self.STRIPE_TIER_12_100_ID_STAGING + return self.STRIPE_TIER_12_100_ID_PROD + + @property + def STRIPE_TIER_25_200_ID(self) -> str: + if self.ENV_MODE == EnvMode.STAGING: + return self.STRIPE_TIER_25_200_ID_STAGING + return self.STRIPE_TIER_25_200_ID_PROD + + @property + def STRIPE_TIER_50_400_ID(self) -> str: + if self.ENV_MODE == EnvMode.STAGING: + return self.STRIPE_TIER_50_400_ID_STAGING + return self.STRIPE_TIER_50_400_ID_PROD + + @property + def STRIPE_TIER_125_800_ID(self) -> str: + if self.ENV_MODE == EnvMode.STAGING: + return self.STRIPE_TIER_125_800_ID_STAGING + return self.STRIPE_TIER_125_800_ID_PROD + + @property + def STRIPE_TIER_200_1000_ID(self) -> str: + if self.ENV_MODE == EnvMode.STAGING: + return self.STRIPE_TIER_200_1000_ID_STAGING + return self.STRIPE_TIER_200_1000_ID_PROD + + # LLM API keys + ANTHROPIC_API_KEY: Optional[str] = None + OPENAI_API_KEY: Optional[str] = None + GROQ_API_KEY: Optional[str] = None + OPENROUTER_API_KEY: Optional[str] = None + OPENROUTER_API_BASE: Optional[str] = "https://openrouter.ai/api/v1" + OR_SITE_URL: Optional[str] = "https://kortix.ai" + OR_APP_NAME: Optional[str] = "Kortix AI" + + # AWS Bedrock credentials + AWS_ACCESS_KEY_ID: Optional[str] = None + AWS_SECRET_ACCESS_KEY: Optional[str] = None + AWS_REGION_NAME: Optional[str] = None + + # Model configuration + MODEL_TO_USE: Optional[str] = "anthropic/claude-sonnet-4-20250514" + + # Supabase configuration + SUPABASE_URL: str + SUPABASE_ANON_KEY: str + SUPABASE_SERVICE_ROLE_KEY: str + + # Redis configuration + REDIS_HOST: str + REDIS_PORT: int = 6379 + REDIS_PASSWORD: Optional[str] = None + REDIS_SSL: bool = True + + # Daytona sandbox configuration + DAYTONA_API_KEY: str + DAYTONA_SERVER_URL: str + DAYTONA_TARGET: str + + # Search and other API keys + TAVILY_API_KEY: str + RAPID_API_KEY: str + CLOUDFLARE_API_TOKEN: Optional[str] = None + FIRECRAWL_API_KEY: str + FIRECRAWL_URL: Optional[str] = "https://api.firecrawl.dev" + + # Stripe configuration + STRIPE_SECRET_KEY: Optional[str] = None + STRIPE_WEBHOOK_SECRET: Optional[str] = None + STRIPE_DEFAULT_PLAN_ID: Optional[str] = None + STRIPE_DEFAULT_TRIAL_DAYS: int = 14 + + # Stripe Product IDs + STRIPE_PRODUCT_ID_PROD: str = 'prod_SCl7AQ2C8kK1CD' + STRIPE_PRODUCT_ID_STAGING: str = 'prod_SCgIj3G7yPOAWY' + + # Sandbox configuration + SANDBOX_IMAGE_NAME = "kortix/suna:0.1.3" + SANDBOX_ENTRYPOINT = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf" + + # LangFuse configuration + LANGFUSE_PUBLIC_KEY: Optional[str] = None + LANGFUSE_SECRET_KEY: Optional[str] = None + LANGFUSE_HOST: str = "https://cloud.langfuse.com" + + @property + def STRIPE_PRODUCT_ID(self) -> str: + if self.ENV_MODE == EnvMode.STAGING: + return self.STRIPE_PRODUCT_ID_STAGING + return self.STRIPE_PRODUCT_ID_PROD + + def __init__(self): + """Initialize configuration by loading from environment variables.""" + # Load environment variables from .env file if it exists + load_dotenv() + + # Set environment mode first + env_mode_str = os.getenv("ENV_MODE", EnvMode.LOCAL.value) + try: + self.ENV_MODE = EnvMode(env_mode_str.lower()) + except ValueError: + logger.warning(f"Invalid ENV_MODE: {env_mode_str}, defaulting to LOCAL") + self.ENV_MODE = EnvMode.LOCAL + + logger.info(f"Environment mode: {self.ENV_MODE.value}") + + # Load configuration from environment variables + self._load_from_env() + + # Perform validation + self._validate() + + def _load_from_env(self): + """Load configuration values from environment variables.""" + for key, expected_type in get_type_hints(self.__class__).items(): + env_val = os.getenv(key) + + if env_val is not None: + # Convert environment variable to the expected type + if expected_type == bool: + # Handle boolean conversion + setattr(self, key, env_val.lower() in ('true', 't', 'yes', 'y', '1')) + elif expected_type == int: + # Handle integer conversion + try: + setattr(self, key, int(env_val)) + except ValueError: + logger.warning(f"Invalid value for {key}: {env_val}, using default") + elif expected_type == EnvMode: + # Already handled for ENV_MODE + pass + else: + # String or other type + setattr(self, key, env_val) + + def _validate(self): + """Validate configuration based on type hints.""" + # Get all configuration fields and their type hints + type_hints = get_type_hints(self.__class__) + + # Find missing required fields + missing_fields = [] + for field, field_type in type_hints.items(): + # Check if the field is Optional + is_optional = hasattr(field_type, "__origin__") and field_type.__origin__ is Union and type(None) in field_type.__args__ + + # If not optional and value is None, add to missing fields + if not is_optional and getattr(self, field) is None: + missing_fields.append(field) + + if missing_fields: + error_msg = f"Missing required configuration fields: {', '.join(missing_fields)}" + logger.error(error_msg) + raise ValueError(error_msg) + + def get(self, key: str, default: Any = None) -> Any: + """Get a configuration value with an optional default.""" + return getattr(self, key, default) + + def as_dict(self) -> Dict[str, Any]: + """Return configuration as a dictionary.""" + return { + key: getattr(self, key) + for key in get_type_hints(self.__class__).keys() + if not key.startswith('_') + } + +# Create a singleton instance +config = Configuration() \ No newline at end of file diff --git a/app/utils/constants.py b/app/utils/constants.py new file mode 100644 index 000000000..e24578bda --- /dev/null +++ b/app/utils/constants.py @@ -0,0 +1,145 @@ +MODEL_ACCESS_TIERS = { + "free": [ + "openrouter/deepseek/deepseek-chat", + "openrouter/qwen/qwen3-235b-a22b", + "openrouter/google/gemini-2.5-flash-preview-05-20", + ], + "tier_2_20": [ + "openrouter/deepseek/deepseek-chat", + # "xai/grok-3-mini-fast-beta", + "openai/gpt-4o", + # "openai/gpt-4-turbo", + # "xai/grok-3-fast-latest", + "openrouter/google/gemini-2.5-flash-preview-05-20", # Added + # "openai/gpt-4", + "anthropic/claude-3-7-sonnet-latest", + "anthropic/claude-sonnet-4-20250514", + # "openai/gpt-4.1-2025-04-14", + # "openrouter/deepseek/deepseek-r1", + "openrouter/qwen/qwen3-235b-a22b", + ], + "tier_6_50": [ + "openrouter/deepseek/deepseek-chat", + # "xai/grok-3-mini-fast-beta", + "openai/gpt-4o", + # "openai/gpt-4-turbo", + # "xai/grok-3-fast-latest", + "openrouter/google/gemini-2.5-flash-preview-05-20", # Added + # "openai/gpt-4", + "anthropic/claude-3-7-sonnet-latest", + "anthropic/claude-sonnet-4-20250514", + # "openai/gpt-4.1-2025-04-14", + # "openrouter/deepseek/deepseek-r1", + "openrouter/qwen/qwen3-235b-a22b", + ], + "tier_12_100": [ + "openrouter/deepseek/deepseek-chat", + # "xai/grok-3-mini-fast-beta", + "openai/gpt-4o", + # "openai/gpt-4-turbo", + # "xai/grok-3-fast-latest", + "openrouter/google/gemini-2.5-flash-preview-05-20", # Added + # "openai/gpt-4", + "anthropic/claude-3-7-sonnet-latest", + "anthropic/claude-sonnet-4-20250514", + # "openai/gpt-4.1-2025-04-14", + # "openrouter/deepseek/deepseek-r1", + "openrouter/qwen/qwen3-235b-a22b", + ], + "tier_25_200": [ + "openrouter/deepseek/deepseek-chat", + # "xai/grok-3-mini-fast-beta", + "openai/gpt-4o", + # "openai/gpt-4-turbo", + # "xai/grok-3-fast-latest", + "openrouter/google/gemini-2.5-flash-preview-05-20", # Added + # "openai/gpt-4", + "anthropic/claude-3-7-sonnet-latest", + "anthropic/claude-sonnet-4-20250514", + # "openai/gpt-4.1-2025-04-14", + # "openrouter/deepseek/deepseek-r1", + "openrouter/qwen/qwen3-235b-a22b", + ], + "tier_50_400": [ + "openrouter/deepseek/deepseek-chat", + # "xai/grok-3-mini-fast-beta", + "openai/gpt-4o", + # "openai/gpt-4-turbo", + # "xai/grok-3-fast-latest", + "openrouter/google/gemini-2.5-flash-preview-05-20", # Added + # "openai/gpt-4", + "anthropic/claude-3-7-sonnet-latest", + "anthropic/claude-sonnet-4-20250514", + # "openai/gpt-4.1-2025-04-14", + # "openrouter/deepseek/deepseek-r1", + "openrouter/qwen/qwen3-235b-a22b", + ], + "tier_125_800": [ + "openrouter/deepseek/deepseek-chat", + # "xai/grok-3-mini-fast-beta", + "openai/gpt-4o", + # "openai/gpt-4-turbo", + # "xai/grok-3-fast-latest", + "openrouter/google/gemini-2.5-flash-preview-05-20", # Added + # "openai/gpt-4", + "anthropic/claude-3-7-sonnet-latest", + "anthropic/claude-sonnet-4-20250514", + # "openai/gpt-4.1-2025-04-14", + # "openrouter/deepseek/deepseek-r1", + "openrouter/qwen/qwen3-235b-a22b", + ], + "tier_200_1000": [ + "openrouter/deepseek/deepseek-chat", + # "xai/grok-3-mini-fast-beta", + "openai/gpt-4o", + # "openai/gpt-4-turbo", + # "xai/grok-3-fast-latest", + "openrouter/google/gemini-2.5-flash-preview-05-20", # Added + # "openai/gpt-4", + "anthropic/claude-3-7-sonnet-latest", + "anthropic/claude-sonnet-4-20250514", + # "openai/gpt-4.1-2025-04-14", + # "openrouter/deepseek/deepseek-r1", + "openrouter/qwen/qwen3-235b-a22b", + ], +} +MODEL_NAME_ALIASES = { + # Short names to full names + "sonnet-3.7": "anthropic/claude-3-7-sonnet-latest", + "sonnet-3.5": "anthropic/claude-3-5-sonnet-latest", + "haiku-3.5": "anthropic/claude-3-5-haiku-latest", + "claude-sonnet-4": "anthropic/claude-sonnet-4-20250514", + # "gpt-4.1": "openai/gpt-4.1-2025-04-14", # Commented out in constants.py + "gpt-4o": "openai/gpt-4o", + "gpt-4.1": "openai/gpt-4.1", + "gpt-4.1-mini": "gpt-4.1-mini", + # "gpt-4-turbo": "openai/gpt-4-turbo", # Commented out in constants.py + # "gpt-4": "openai/gpt-4", # Commented out in constants.py + # "gemini-flash-2.5": "openrouter/google/gemini-2.5-flash-preview", # Commented out in constants.py + # "grok-3": "xai/grok-3-fast-latest", # Commented out in constants.py + "deepseek": "openrouter/deepseek/deepseek-chat", + # "deepseek-r1": "openrouter/deepseek/deepseek-r1", + # "grok-3-mini": "xai/grok-3-mini-fast-beta", # Commented out in constants.py + "qwen3": "openrouter/qwen/qwen3-235b-a22b", # Commented out in constants.py + "gemini-flash-2.5": "openrouter/google/gemini-2.5-flash-preview-05-20", + "gemini-2.5-flash:thinking":"openrouter/google/gemini-2.5-flash-preview-05-20:thinking", + + # "google/gemini-2.5-flash-preview":"openrouter/google/gemini-2.5-flash-preview", + # "google/gemini-2.5-flash-preview:thinking":"openrouter/google/gemini-2.5-flash-preview:thinking", + "google/gemini-2.5-pro-preview":"openrouter/google/gemini-2.5-pro-preview", + "deepseek/deepseek-chat-v3-0324":"openrouter/deepseek/deepseek-chat-v3-0324", + + # Also include full names as keys to ensure they map to themselves + # "anthropic/claude-3-7-sonnet-latest": "anthropic/claude-3-7-sonnet-latest", + # "openai/gpt-4.1-2025-04-14": "openai/gpt-4.1-2025-04-14", # Commented out in constants.py + # "openai/gpt-4o": "openai/gpt-4o", + # "openai/gpt-4-turbo": "openai/gpt-4-turbo", # Commented out in constants.py + # "openai/gpt-4": "openai/gpt-4", # Commented out in constants.py + # "openrouter/google/gemini-2.5-flash-preview": "openrouter/google/gemini-2.5-flash-preview", # Commented out in constants.py + # "xai/grok-3-fast-latest": "xai/grok-3-fast-latest", # Commented out in constants.py + # "deepseek/deepseek-chat": "openrouter/deepseek/deepseek-chat", + # "deepseek/deepseek-r1": "openrouter/deepseek/deepseek-r1", + + # "qwen/qwen3-235b-a22b": "openrouter/qwen/qwen3-235b-a22b", + # "xai/grok-3-mini-fast-beta": "xai/grok-3-mini-fast-beta", # Commented out in constants.py +} \ No newline at end of file diff --git a/app/utils/files_utils.py b/app/utils/files_utils.py new file mode 100644 index 000000000..508b1bb9f --- /dev/null +++ b/app/utils/files_utils.py @@ -0,0 +1,91 @@ + +import os + +# Files to exclude from operations +EXCLUDED_FILES = { + ".DS_Store", + ".gitignore", + "package-lock.json", + "postcss.config.js", + "postcss.config.mjs", + "jsconfig.json", + "components.json", + "tsconfig.tsbuildinfo", + "tsconfig.json", +} + +# Directories to exclude from operations +EXCLUDED_DIRS = { + "node_modules", + ".next", + "dist", + "build", + ".git" +} + +# File extensions to exclude from operations +EXCLUDED_EXT = { + ".ico", + ".svg", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".tiff", + ".webp", + ".db", + ".sql" +} + +def should_exclude_file(rel_path: str) -> bool: + """Check if a file should be excluded based on path, name, or extension + + Args: + rel_path: Relative path of the file to check + + Returns: + True if the file should be excluded, False otherwise + """ + # Check filename + filename = os.path.basename(rel_path) + if filename in EXCLUDED_FILES: + return True + + # Check directory + dir_path = os.path.dirname(rel_path) + if any(excluded in dir_path for excluded in EXCLUDED_DIRS): + return True + + # Check extension + _, ext = os.path.splitext(filename) + if ext.lower() in EXCLUDED_EXT: + return True + + return False + +def clean_path(path: str, workspace_path: str = "/workspace") -> str: + """Clean and normalize a path to be relative to the workspace + + Args: + path: The path to clean + workspace_path: The base workspace path to remove (default: "/workspace") + + Returns: + The cleaned path, relative to the workspace + """ + # Remove any leading slash + path = path.lstrip('/') + + # Remove workspace prefix if present + if path.startswith(workspace_path.lstrip('/')): + path = path[len(workspace_path.lstrip('/')):] + + # Remove workspace/ prefix if present + if path.startswith('workspace/'): + path = path[9:] + + # Remove any remaining leading slash + path = path.lstrip('/') + + return path \ No newline at end of file diff --git a/app/utils/logger.py b/app/utils/logger.py new file mode 100644 index 000000000..6b36fa41f --- /dev/null +++ b/app/utils/logger.py @@ -0,0 +1,28 @@ +import structlog, logging, os + +ENV_MODE = os.getenv("ENV_MODE", "LOCAL") + +renderer = [structlog.processors.JSONRenderer()] +if ENV_MODE.lower() == "local".lower(): + renderer = [structlog.dev.ConsoleRenderer()] + +structlog.configure( + processors=[ + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.dict_tracebacks, + structlog.processors.CallsiteParameterAdder( + { + structlog.processors.CallsiteParameter.FILENAME, + structlog.processors.CallsiteParameter.FUNC_NAME, + structlog.processors.CallsiteParameter.LINENO, + } + ), + structlog.processors.TimeStamper(fmt="iso"), + structlog.contextvars.merge_contextvars, + *renderer, + ], + cache_logger_on_first_use=True, +) + +logger: structlog.stdlib.BoundLogger = structlog.get_logger(level=logging.DEBUG) diff --git a/app/utils/retry.py b/app/utils/retry.py new file mode 100644 index 000000000..992c04543 --- /dev/null +++ b/app/utils/retry.py @@ -0,0 +1,58 @@ +import asyncio +from typing import TypeVar, Callable, Awaitable, Optional + +T = TypeVar("T") + + +async def retry( + fn: Callable[[], Awaitable[T]], + max_attempts: int = 3, + delay_seconds: int = 1, +) -> T: + """ + Retry an async function with exponential backoff. + + Args: + fn: The async function to retry + max_attempts: Maximum number of attempts + delay_seconds: Delay between attempts in seconds + + Returns: + The result of the function call + + Raises: + The last exception if all attempts fail + + Example: + ```python + async def fetch_data(): + # Some operation that might fail + return await api_call() + + try: + result = await retry(fetch_data, max_attempts=3, delay_seconds=2) + print(f"Success: {result}") + except Exception as e: + print(f"Failed after all retries: {e}") + ``` + """ + if max_attempts <= 0: + raise ValueError("max_attempts must be greater than zero") + + last_error: Optional[Exception] = None + + for attempt in range(1, max_attempts + 1): + try: + return await fn() + except Exception as error: + last_error = error + + if attempt == max_attempts: + break + + await asyncio.sleep(delay_seconds) + + if last_error: + raise last_error + + raise RuntimeError("Unexpected: last_error is None") diff --git a/app/utils/s3_upload_utils.py b/app/utils/s3_upload_utils.py new file mode 100644 index 000000000..65722640a --- /dev/null +++ b/app/utils/s3_upload_utils.py @@ -0,0 +1,51 @@ +""" +Utility functions for handling image operations. +""" + +import base64 +import uuid +from datetime import datetime +from utils.logger import logger +from services.supabase import DBConnection + +async def upload_base64_image(base64_data: str, bucket_name: str = "browser-screenshots") -> str: + """Upload a base64 encoded image to Supabase storage and return the URL. + + Args: + base64_data (str): Base64 encoded image data (with or without data URL prefix) + bucket_name (str): Name of the storage bucket to upload to + + Returns: + str: Public URL of the uploaded image + """ + try: + # Remove data URL prefix if present + if base64_data.startswith('data:'): + base64_data = base64_data.split(',')[1] + + # Decode base64 data + image_data = base64.b64decode(base64_data) + + # Generate unique filename + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + unique_id = str(uuid.uuid4())[:8] + filename = f"image_{timestamp}_{unique_id}.png" + + # Upload to Supabase storage + db = DBConnection() + client = await db.client + storage_response = await client.storage.from_(bucket_name).upload( + filename, + image_data, + {"content-type": "image/png"} + ) + + # Get public URL + public_url = await client.storage.from_(bucket_name).get_public_url(filename) + + logger.debug(f"Successfully uploaded image to {public_url}") + return public_url + + except Exception as e: + logger.error(f"Error uploading base64 image: {e}") + raise RuntimeError(f"Failed to upload image: {str(e)}") \ No newline at end of file diff --git a/app/utils/scripts/archive_inactive_sandboxes.py b/app/utils/scripts/archive_inactive_sandboxes.py new file mode 100644 index 000000000..1a56dbeba --- /dev/null +++ b/app/utils/scripts/archive_inactive_sandboxes.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python +""" +Script to archive sandboxes for projects whose account_id is not associated with an active billing customer. + +Usage: + python archive_inactive_sandboxes.py + +This script: +1. Gets all active account_ids from basejump.billing_customers (active=TRUE) +2. Gets all projects from the projects table +3. Archives sandboxes for any project whose account_id is not in the active billing customers list + +Make sure your environment variables are properly set: +- SUPABASE_URL +- SUPABASE_SERVICE_ROLE_KEY +- DAYTONA_SERVER_URL +""" + +import asyncio +import sys +import os +import argparse +from typing import List, Dict, Any, Set +from dotenv import load_dotenv + +# Load script-specific environment variables +load_dotenv(".env") + +from services.supabase import DBConnection +from sandbox.sandbox import daytona +from utils.logger import logger + +# Global DB connection to reuse +db_connection = None + + +async def get_active_billing_customer_account_ids() -> Set[str]: + """ + Query all account_ids from the basejump.billing_customers table where active=TRUE. + + Returns: + Set of account_ids that have an active billing customer record + """ + global db_connection + if db_connection is None: + db_connection = DBConnection() + + client = await db_connection.client + + # Print the Supabase URL being used + print(f"Using Supabase URL: {os.getenv('SUPABASE_URL')}") + + # Query all account_ids from billing_customers where active=true + result = await client.schema('basejump').from_('billing_customers').select('account_id, active').execute() + + # Print the query result + print(f"Found {len(result.data)} billing customers in database") + print(result.data) + + if not result.data: + logger.info("No billing customers found in database") + return set() + + # Extract account_ids for active customers and return as a set for fast lookups + active_account_ids = {customer.get('account_id') for customer in result.data + if customer.get('account_id') and customer.get('active') is True} + + print(f"Found {len(active_account_ids)} active billing customers") + return active_account_ids + + +async def get_all_projects() -> List[Dict[str, Any]]: + """ + Query all projects with sandbox information. + + Returns: + List of projects with their sandbox information + """ + global db_connection + if db_connection is None: + db_connection = DBConnection() + + client = await db_connection.client + + # Initialize variables for pagination + all_projects = [] + page_size = 1000 + current_page = 0 + has_more = True + + logger.info("Starting to fetch all projects (paginated)") + + # Paginate through all projects + while has_more: + # Query projects with pagination + start_range = current_page * page_size + end_range = start_range + page_size - 1 + + logger.info(f"Fetching projects page {current_page+1} (range: {start_range}-{end_range})") + + result = await client.table('projects').select( + 'project_id', + 'name', + 'account_id', + 'sandbox' + ).range(start_range, end_range).execute() + + if not result.data: + has_more = False + else: + all_projects.extend(result.data) + current_page += 1 + + # Progress update + logger.info(f"Loaded {len(all_projects)} projects so far") + print(f"Loaded {len(all_projects)} projects so far...") + + # Check if we've reached the end + if len(result.data) < page_size: + has_more = False + + # Print the query result + total_projects = len(all_projects) + print(f"Found {total_projects} projects in database") + logger.info(f"Total projects found in database: {total_projects}") + + if not all_projects: + logger.info("No projects found in database") + return [] + + # Filter projects that have sandbox information + projects_with_sandboxes = [ + project for project in all_projects + if project.get('sandbox') and project['sandbox'].get('id') + ] + + logger.info(f"Found {len(projects_with_sandboxes)} projects with sandboxes") + return projects_with_sandboxes + + +async def archive_sandbox(project: Dict[str, Any], dry_run: bool) -> bool: + """ + Archive a single sandbox. + + Args: + project: Project information containing sandbox to archive + dry_run: If True, only simulate archiving + + Returns: + True if successful, False otherwise + """ + sandbox_id = project['sandbox'].get('id') + project_name = project.get('name', 'Unknown') + project_id = project.get('project_id', 'Unknown') + + try: + logger.info(f"Checking sandbox {sandbox_id} for project '{project_name}' (ID: {project_id})") + + if dry_run: + logger.info(f"DRY RUN: Would archive sandbox {sandbox_id}") + print(f"Would archive sandbox {sandbox_id} for project '{project_name}'") + return True + + # Get the sandbox + sandbox = daytona.get(sandbox_id) + + # Check sandbox state - it must be stopped before archiving + sandbox_info = sandbox.info() + + # Log the current state + logger.info(f"Sandbox {sandbox_id} is in '{sandbox_info.state}' state") + + # Only archive if the sandbox is in the stopped state + if sandbox_info.state == "stopped": + logger.info(f"Archiving sandbox {sandbox_id} as it is in stopped state") + sandbox.archive() + logger.info(f"Successfully archived sandbox {sandbox_id}") + return True + else: + logger.info(f"Skipping sandbox {sandbox_id} as it is not in stopped state (current: {sandbox_info.state})") + return True + + except Exception as e: + import traceback + error_type = type(e).__name__ + stack_trace = traceback.format_exc() + + # Log detailed error information + logger.error(f"Error processing sandbox {sandbox_id}: {str(e)}") + logger.error(f"Error type: {error_type}") + logger.error(f"Stack trace:\n{stack_trace}") + + # If the exception has a response attribute (like in HTTP errors), log it + if hasattr(e, 'response'): + try: + response_data = e.response.json() if hasattr(e.response, 'json') else str(e.response) + logger.error(f"Response data: {response_data}") + except Exception: + logger.error(f"Could not parse response data from error") + + print(f"Failed to process sandbox {sandbox_id}: {error_type} - {str(e)}") + return False + + +async def process_sandboxes(inactive_projects: List[Dict[str, Any]], dry_run: bool) -> tuple[int, int]: + """ + Process all sandboxes sequentially. + + Args: + inactive_projects: List of projects without active billing + dry_run: Whether to actually archive sandboxes or just simulate + + Returns: + Tuple of (processed_count, failed_count) + """ + processed_count = 0 + failed_count = 0 + + if dry_run: + logger.info(f"DRY RUN: Would archive {len(inactive_projects)} sandboxes") + else: + logger.info(f"Archiving {len(inactive_projects)} sandboxes") + + print(f"Processing {len(inactive_projects)} sandboxes...") + + # Process each sandbox sequentially + for i, project in enumerate(inactive_projects): + success = await archive_sandbox(project, dry_run) + + if success: + processed_count += 1 + else: + failed_count += 1 + + # Print progress periodically + if (i + 1) % 20 == 0 or (i + 1) == len(inactive_projects): + progress = (i + 1) / len(inactive_projects) * 100 + print(f"Progress: {i + 1}/{len(inactive_projects)} sandboxes processed ({progress:.1f}%)") + print(f" - Processed: {processed_count}, Failed: {failed_count}") + + return processed_count, failed_count + + +async def main(): + """Main function to run the script.""" + # Parse command line arguments + parser = argparse.ArgumentParser(description='Archive sandboxes for projects without active billing') + parser.add_argument('--dry-run', action='store_true', help='Show what would be archived without actually archiving') + args = parser.parse_args() + + logger.info("Starting sandbox cleanup for projects without active billing") + if args.dry_run: + logger.info("DRY RUN MODE - No sandboxes will be archived") + + # Print environment info + print(f"Environment Mode: {os.getenv('ENV_MODE', 'Not set')}") + print(f"Daytona Server: {os.getenv('DAYTONA_SERVER_URL', 'Not set')}") + + try: + # Initialize global DB connection + global db_connection + db_connection = DBConnection() + + # Get all account_ids that have an active billing customer + active_billing_customer_account_ids = await get_active_billing_customer_account_ids() + + # Get all projects with sandboxes + all_projects = await get_all_projects() + + if not all_projects: + logger.info("No projects with sandboxes to process") + return + + # Filter projects whose account_id is not in the active billing customers list + inactive_projects = [ + project for project in all_projects + if project.get('account_id') not in active_billing_customer_account_ids + ] + + # Print summary of what will be processed + active_projects_count = len(all_projects) - len(inactive_projects) + print("\n===== SANDBOX CLEANUP SUMMARY =====") + print(f"Total projects found: {len(all_projects)}") + print(f"Projects with active billing accounts: {active_projects_count}") + print(f"Projects without active billing accounts: {len(inactive_projects)}") + print(f"Sandboxes that will be archived: {len(inactive_projects)}") + print("===================================") + + logger.info(f"Found {len(inactive_projects)} projects without an active billing customer account") + + if not inactive_projects: + logger.info("No projects to archive sandboxes for") + return + + # Ask for confirmation before proceeding + if not args.dry_run: + print("\n⚠️ WARNING: You are about to archive sandboxes for inactive accounts ⚠️") + print("This action cannot be undone!") + confirmation = input("\nAre you sure you want to proceed with archiving? (TRUE/FALSE): ").strip().upper() + + if confirmation != "TRUE": + print("Archiving cancelled. Exiting script.") + logger.info("Archiving cancelled by user") + return + + print("\nProceeding with sandbox archiving...\n") + logger.info("User confirmed sandbox archiving") + + # List all projects to be processed + for i, project in enumerate(inactive_projects[:5]): # Just show first 5 for brevity + account_id = project.get('account_id', 'Unknown') + project_name = project.get('name', 'Unknown') + project_id = project.get('project_id', 'Unknown') + sandbox_id = project['sandbox'].get('id') + + print(f"{i+1}. Project: {project_name}") + print(f" Project ID: {project_id}") + print(f" Account ID: {account_id}") + print(f" Sandbox ID: {sandbox_id}") + + if len(inactive_projects) > 5: + print(f" ... and {len(inactive_projects) - 5} more projects") + + # Process all sandboxes + processed_count, failed_count = await process_sandboxes(inactive_projects, args.dry_run) + + # Print final summary + print("\nSandbox Cleanup Summary:") + print(f"Total projects without active billing: {len(inactive_projects)}") + print(f"Total sandboxes processed: {len(inactive_projects)}") + + if args.dry_run: + print(f"DRY RUN: No sandboxes were actually archived") + else: + print(f"Successfully processed: {processed_count}") + print(f"Failed to process: {failed_count}") + + logger.info("Sandbox cleanup completed") + + except Exception as e: + logger.error(f"Error during sandbox cleanup: {str(e)}") + sys.exit(1) + finally: + # Clean up database connection + if db_connection: + await DBConnection.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/app/utils/scripts/archive_old_sandboxes.py b/app/utils/scripts/archive_old_sandboxes.py new file mode 100644 index 000000000..ae020d89a --- /dev/null +++ b/app/utils/scripts/archive_old_sandboxes.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python +""" +Script to archive sandboxes for projects that are older than 1 day. + +Usage: + python archive_old_sandboxes.py [--days N] [--dry-run] + +This script: +1. Gets all projects from the projects table +2. Filters projects created more than N days ago (default: 1 day) +3. Archives the sandboxes for those projects + +Make sure your environment variables are properly set: +- SUPABASE_URL +- SUPABASE_SERVICE_ROLE_KEY +- DAYTONA_SERVER_URL +""" + +# TODO: SAVE THE LATEST SANDBOX STATE SOMEWHERE OR LIKE MASS CHECK THE STATE BEFORE STARTING TO ARCHIVE - AS ITS GOING TO GO OVER A BUNCH THAT ARE ALREADY ARCHIVED – MAYBE BEST TO GET ALL FROM DAYTONA AND THEN RUN THE ARCHIVE ONLY ON THE ONES THAT MEET THE CRITERIA (STOPPED STATE) + +import asyncio +import sys +import os +import argparse +from typing import List, Dict, Any +from datetime import datetime, timedelta +from dotenv import load_dotenv + +# Load script-specific environment variables +load_dotenv(".env") + +from services.supabase import DBConnection +from sandbox.sandbox import daytona +from utils.logger import logger + +# Global DB connection to reuse +db_connection = None + + +async def get_old_projects(days_threshold: int = 1) -> List[Dict[str, Any]]: + """ + Query all projects created more than N days ago. + + Args: + days_threshold: Number of days threshold (default: 1) + + Returns: + List of projects with their sandbox information + """ + global db_connection + if db_connection is None: + db_connection = DBConnection() + + client = await db_connection.client + + # Print the Supabase URL being used + print(f"Using Supabase URL: {os.getenv('SUPABASE_URL')}") + + # Calculate the date threshold + threshold_date = (datetime.now() - timedelta(days=days_threshold)).isoformat() + + # Initialize variables for pagination + all_projects = [] + page_size = 1000 + current_page = 0 + has_more = True + + logger.info(f"Starting to fetch projects older than {days_threshold} day(s)") + print(f"Looking for projects created before: {threshold_date}") + + # Paginate through all projects + while has_more: + # Query projects with pagination + start_range = current_page * page_size + end_range = start_range + page_size - 1 + + logger.info(f"Fetching projects page {current_page+1} (range: {start_range}-{end_range})") + + try: + result = await client.table('projects').select( + 'project_id', + 'name', + 'created_at', + 'account_id', + 'sandbox' + ).order('created_at', desc=True).range(start_range, end_range).execute() + + # Debug info - print raw response + print(f"Response data length: {len(result.data)}") + + if not result.data: + print("No more data returned from query, ending pagination") + has_more = False + else: + # Print a sample project to see the actual data structure + if current_page == 0 and result.data: + print(f"Sample project data: {result.data[0]}") + + all_projects.extend(result.data) + current_page += 1 + + # Progress update + logger.info(f"Loaded {len(all_projects)} projects so far") + print(f"Loaded {len(all_projects)} projects so far...") + + # Check if we've reached the end - if we got fewer results than the page size + if len(result.data) < page_size: + print(f"Got {len(result.data)} records which is less than page size {page_size}, ending pagination") + has_more = False + else: + print(f"Full page returned ({len(result.data)} records), continuing to next page") + + except Exception as e: + logger.error(f"Error during pagination: {str(e)}") + print(f"Error during pagination: {str(e)}") + has_more = False # Stop on error + + # Print the query result summary + total_projects = len(all_projects) + print(f"Found {total_projects} total projects in database") + logger.info(f"Total projects found in database: {total_projects}") + + if not all_projects: + logger.info("No projects found in database") + return [] + + # Filter projects that are older than the threshold and have sandbox information + old_projects_with_sandboxes = [ + project for project in all_projects + if project.get('created_at') and project.get('created_at') < threshold_date + and project.get('sandbox') and project['sandbox'].get('id') + ] + + logger.info(f"Found {len(old_projects_with_sandboxes)} old projects with sandboxes") + + # Print a few sample old projects for debugging + if old_projects_with_sandboxes: + print("\nSample of old projects with sandboxes:") + for i, project in enumerate(old_projects_with_sandboxes[:3]): + print(f" {i+1}. {project.get('name')} (Created: {project.get('created_at')})") + print(f" Sandbox ID: {project['sandbox'].get('id')}") + if i >= 2: + break + + return old_projects_with_sandboxes + + +async def archive_sandbox(project: Dict[str, Any], dry_run: bool) -> bool: + """ + Archive a single sandbox. + + Args: + project: Project information containing sandbox to archive + dry_run: If True, only simulate archiving + + Returns: + True if successful, False otherwise + """ + sandbox_id = project['sandbox'].get('id') + project_name = project.get('name', 'Unknown') + project_id = project.get('project_id', 'Unknown') + created_at = project.get('created_at', 'Unknown') + + try: + logger.info(f"Checking sandbox {sandbox_id} for project '{project_name}' (ID: {project_id}, Created: {created_at})") + + if dry_run: + logger.info(f"DRY RUN: Would archive sandbox {sandbox_id}") + print(f"Would archive sandbox {sandbox_id} for project '{project_name}' (Created: {created_at})") + return True + + # Get the sandbox + sandbox = daytona.get(sandbox_id) + + # Check sandbox state - it must be stopped before archiving + sandbox_info = sandbox.info() + + # Log the current state + logger.info(f"Sandbox {sandbox_id} is in '{sandbox_info.state}' state") + + # Only archive if the sandbox is in the stopped state + if sandbox_info.state == "stopped": + logger.info(f"Archiving sandbox {sandbox_id} as it is in stopped state") + sandbox.archive() + logger.info(f"Successfully archived sandbox {sandbox_id}") + return True + else: + logger.info(f"Skipping sandbox {sandbox_id} as it is not in stopped state (current: {sandbox_info.state})") + return True + + except Exception as e: + import traceback + error_type = type(e).__name__ + stack_trace = traceback.format_exc() + + # Log detailed error information + logger.error(f"Error processing sandbox {sandbox_id}: {str(e)}") + logger.error(f"Error type: {error_type}") + logger.error(f"Stack trace:\n{stack_trace}") + + # If the exception has a response attribute (like in HTTP errors), log it + if hasattr(e, 'response'): + try: + response_data = e.response.json() if hasattr(e.response, 'json') else str(e.response) + logger.error(f"Response data: {response_data}") + except Exception: + logger.error(f"Could not parse response data from error") + + print(f"Failed to process sandbox {sandbox_id}: {error_type} - {str(e)}") + return False + + +async def process_sandboxes(old_projects: List[Dict[str, Any]], dry_run: bool) -> tuple[int, int]: + """ + Process all sandboxes sequentially. + + Args: + old_projects: List of projects older than the threshold + dry_run: Whether to actually archive sandboxes or just simulate + + Returns: + Tuple of (processed_count, failed_count) + """ + processed_count = 0 + failed_count = 0 + + if dry_run: + logger.info(f"DRY RUN: Would archive {len(old_projects)} sandboxes") + else: + logger.info(f"Archiving {len(old_projects)} sandboxes") + + print(f"Processing {len(old_projects)} sandboxes...") + + # Process each sandbox sequentially + for i, project in enumerate(old_projects): + success = await archive_sandbox(project, dry_run) + + if success: + processed_count += 1 + else: + failed_count += 1 + + # Print progress periodically + if (i + 1) % 20 == 0 or (i + 1) == len(old_projects): + progress = (i + 1) / len(old_projects) * 100 + print(f"Progress: {i + 1}/{len(old_projects)} sandboxes processed ({progress:.1f}%)") + print(f" - Processed: {processed_count}, Failed: {failed_count}") + + return processed_count, failed_count + + +async def main(): + """Main function to run the script.""" + # Parse command line arguments + parser = argparse.ArgumentParser(description='Archive sandboxes for projects older than N days') + parser.add_argument('--days', type=int, default=1, help='Age threshold in days (default: 1)') + parser.add_argument('--dry-run', action='store_true', help='Show what would be archived without actually archiving') + args = parser.parse_args() + + logger.info(f"Starting sandbox cleanup for projects older than {args.days} day(s)") + if args.dry_run: + logger.info("DRY RUN MODE - No sandboxes will be archived") + + # Print environment info + print(f"Environment Mode: {os.getenv('ENV_MODE', 'Not set')}") + print(f"Daytona Server: {os.getenv('DAYTONA_SERVER_URL', 'Not set')}") + + try: + # Initialize global DB connection + global db_connection + db_connection = DBConnection() + + # Get all projects older than the threshold + old_projects = await get_old_projects(args.days) + + if not old_projects: + logger.info(f"No projects older than {args.days} day(s) with sandboxes to process") + print(f"No projects older than {args.days} day(s) with sandboxes to archive.") + return + + # Print summary of what will be processed + print("\n===== SANDBOX CLEANUP SUMMARY =====") + print(f"Projects older than {args.days} day(s): {len(old_projects)}") + print(f"Sandboxes that will be archived: {len(old_projects)}") + print("===================================") + + logger.info(f"Found {len(old_projects)} projects older than {args.days} day(s)") + + # Ask for confirmation before proceeding + if not args.dry_run: + print("\n⚠️ WARNING: You are about to archive sandboxes for old projects ⚠️") + print("This action cannot be undone!") + confirmation = input("\nAre you sure you want to proceed with archiving? (TRUE/FALSE): ").strip().upper() + + if confirmation != "TRUE": + print("Archiving cancelled. Exiting script.") + logger.info("Archiving cancelled by user") + return + + print("\nProceeding with sandbox archiving...\n") + logger.info("User confirmed sandbox archiving") + + # List a sample of projects to be processed + for i, project in enumerate(old_projects[:5]): # Just show first 5 for brevity + created_at = project.get('created_at', 'Unknown') + project_name = project.get('name', 'Unknown') + project_id = project.get('project_id', 'Unknown') + sandbox_id = project['sandbox'].get('id') + + print(f"{i+1}. Project: {project_name}") + print(f" Project ID: {project_id}") + print(f" Created At: {created_at}") + print(f" Sandbox ID: {sandbox_id}") + + if len(old_projects) > 5: + print(f" ... and {len(old_projects) - 5} more projects") + + # Process all sandboxes + processed_count, failed_count = await process_sandboxes(old_projects, args.dry_run) + + # Print final summary + print("\nSandbox Cleanup Summary:") + print(f"Total projects older than {args.days} day(s): {len(old_projects)}") + print(f"Total sandboxes processed: {len(old_projects)}") + + if args.dry_run: + print(f"DRY RUN: No sandboxes were actually archived") + else: + print(f"Successfully processed: {processed_count}") + print(f"Failed to process: {failed_count}") + + logger.info("Sandbox cleanup completed") + + except Exception as e: + logger.error(f"Error during sandbox cleanup: {str(e)}") + sys.exit(1) + finally: + # Clean up database connection + if db_connection: + await DBConnection.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/app/utils/scripts/copy_project.py b/app/utils/scripts/copy_project.py new file mode 100644 index 000000000..e35cdf0d1 --- /dev/null +++ b/app/utils/scripts/copy_project.py @@ -0,0 +1,388 @@ +import asyncio +import argparse +from dotenv import load_dotenv + +load_dotenv(".env") + +from services.supabase import DBConnection +from daytona_sdk import Sandbox +from sandbox.sandbox import daytona, create_sandbox, delete_sandbox +from utils.logger import logger + +db_connection = None +db = None + + +async def get_db(): + global db_connection, db + if db_connection is None or db is None: + db_connection = DBConnection() + db = await db_connection.client + return db + + +async def get_project(project_id: str): + db = await get_db() + project = ( + await db.schema("public") + .from_("projects") + .select("*") + .eq("project_id", project_id) + .maybe_single() + .execute() + ) + return project.data + + +async def get_threads(project_id: str): + db = await get_db() + threads = ( + await db.schema("public") + .from_("threads") + .select("*") + .eq("project_id", project_id) + .execute() + ) + return threads.data + + +async def copy_thread(thread_id: str, account_id: str, project_id: str): + db = await get_db() + thread = ( + await db.schema("public") + .from_("threads") + .select("*") + .eq("thread_id", thread_id) + .maybe_single() + .execute() + ) + + if not thread.data: + raise Exception(f"Thread {thread_id} not found") + + thread_data = thread.data + new_thread = ( + await db.schema("public") + .from_("threads") + .insert( + { + "account_id": account_id, + "project_id": project_id, + "is_public": thread_data["is_public"], + "agent_id": thread_data["agent_id"], + "metadata": thread_data["metadata"] or {}, + } + ) + .execute() + ) + return new_thread.data[0] + + +async def copy_project(project_id: str, to_user_id: str, sandbox_data: dict): + db = await get_db() + project = await get_project(project_id) + to_user = await get_user(to_user_id) + + if not project: + raise Exception(f"Project {project_id} not found") + if not to_user: + raise Exception(f"User {to_user_id} not found") + + result = ( + await db.schema("public") + .from_("projects") + .insert( + { + "name": project["name"], + "description": project["description"], + "account_id": to_user["id"], + "is_public": project["is_public"], + "sandbox": sandbox_data, + } + ) + .execute() + ) + return result.data[0] + + +async def copy_agent_runs(thread_id: str, new_thread_id: str): + db = await get_db() + agent_runs = ( + await db.schema("public") + .from_("agent_runs") + .select("*") + .eq("thread_id", thread_id) + .execute() + ) + + async def copy_single_agent_run(agent_run, new_thread_id, db): + new_agent_run = ( + await db.schema("public") + .from_("agent_runs") + .insert( + { + "thread_id": new_thread_id, + "status": agent_run["status"], + "started_at": agent_run["started_at"], + "completed_at": agent_run["completed_at"], + "responses": agent_run["responses"], + "error": agent_run["error"], + } + ) + .execute() + ) + return new_agent_run.data[0] + + tasks = [ + copy_single_agent_run(agent_run, new_thread_id, db) + for agent_run in agent_runs.data + ] + new_agent_runs = await asyncio.gather(*tasks) + return new_agent_runs + + +async def copy_messages(thread_id: str, new_thread_id: str): + db = await get_db() + messages_data = [] + offset = 0 + batch_size = 1000 + + while True: + batch = ( + await db.schema("public") + .from_("messages") + .select("*") + .eq("thread_id", thread_id) + .range(offset, offset + batch_size - 1) + .execute() + ) + + if not batch.data: + break + + messages_data.extend(batch.data) + + if len(batch.data) < batch_size: + break + + offset += batch_size + + async def copy_single_message(message, new_thread_id, db): + new_message = ( + await db.schema("public") + .from_("messages") + .insert( + { + "thread_id": new_thread_id, + "type": message["type"], + "is_llm_message": message["is_llm_message"], + "content": message["content"], + "metadata": message["metadata"], + "created_at": message["created_at"], + "updated_at": message["updated_at"], + } + ) + .execute() + ) + return new_message.data[0] + + tasks = [] + for message in messages_data: + tasks.append(copy_single_message(message, new_thread_id, db)) + + # Process tasks in batches to avoid overwhelming the database + batch_size = 100 + new_messages = [] + for i in range(0, len(tasks), batch_size): + batch_tasks = tasks[i : i + batch_size] + batch_results = await asyncio.gather(*batch_tasks) + new_messages.extend(batch_results) + # Add delay between batches + if i + batch_size < len(tasks): + await asyncio.sleep(1) + + return new_messages + + +async def get_user(user_id: str): + db = await get_db() + user = await db.auth.admin.get_user_by_id(user_id) + return user.user.model_dump() + + +async def copy_sandbox(sandbox_id: str, password: str, project_id: str) -> Sandbox: + sandbox = daytona.find_one(sandbox_id=sandbox_id) + if not sandbox: + raise Exception(f"Sandbox {sandbox_id} not found") + + # TODO: Currently there's no way to create a copy of a sandbox, so we will create a new one + new_sandbox = create_sandbox(password, project_id) + return new_sandbox + + +async def main(): + """Main function to run the script.""" + # Parse command line arguments + parser = argparse.ArgumentParser(description="Create copy of a project") + parser.add_argument( + "--project-id", type=str, help="Project ID to copy", required=True + ) + parser.add_argument( + "--new-user-id", + type=str, + default=None, + help="[OPTIONAL] User ID to copy the project to", + required=False, + ) + args = parser.parse_args() + + # Initialize variables for cleanup + new_sandbox = None + new_project = None + new_threads = [] + new_agent_runs = [] + new_messages = [] + + try: + project = await get_project(args.project_id) + if not project: + raise Exception(f"Project {args.project_id} not found") + + to_user_id = args.new_user_id or project["account_id"] + to_user = await get_user(to_user_id) + + logger.info( + f"Project: {project['project_id']} ({project['name']}) -> User: {to_user['id']} ({to_user['email']})" + ) + + new_sandbox = await copy_sandbox( + project["sandbox"]["id"], project["sandbox"]["pass"], args.project_id + ) + if new_sandbox: + vnc_link = new_sandbox.get_preview_link(6080) + website_link = new_sandbox.get_preview_link(8080) + vnc_url = ( + vnc_link.url + if hasattr(vnc_link, "url") + else str(vnc_link).split("url='")[1].split("'")[0] + ) + website_url = ( + website_link.url + if hasattr(website_link, "url") + else str(website_link).split("url='")[1].split("'")[0] + ) + token = None + if hasattr(vnc_link, "token"): + token = vnc_link.token + elif "token='" in str(vnc_link): + token = str(vnc_link).split("token='")[1].split("'")[0] + else: + raise Exception("Failed to create new sandbox") + + sandbox_data = { + "id": new_sandbox.id, + "pass": project["sandbox"]["pass"], + "token": token, + "vnc_preview": vnc_url, + "sandbox_url": website_url, + } + logger.info(f"New sandbox: {new_sandbox.id}") + + new_project = await copy_project( + project["project_id"], to_user["id"], sandbox_data + ) + logger.info(f"New project: {new_project['project_id']} ({new_project['name']})") + + threads = await get_threads(project["project_id"]) + if threads: + for thread in threads: + new_thread = await copy_thread( + thread["thread_id"], to_user["id"], new_project["project_id"] + ) + new_threads.append(new_thread) + logger.info(f"New threads: {len(new_threads)}") + + for i in range(len(new_threads)): + runs = await copy_agent_runs( + threads[i]["thread_id"], new_threads[i]["thread_id"] + ) + new_agent_runs.extend(runs) + logger.info(f"New agent runs: {len(new_agent_runs)}") + + for i in range(len(new_threads)): + messages = await copy_messages( + threads[i]["thread_id"], new_threads[i]["thread_id"] + ) + new_messages.extend(messages) + logger.info(f"New messages: {len(new_messages)}") + else: + logger.info("No threads found for this project") + + except Exception as e: + db = await get_db() + # Clean up any resources that were created before the error + if new_sandbox: + try: + logger.info(f"Cleaning up sandbox: {new_sandbox.id}") + await delete_sandbox(new_sandbox.id) + except Exception as cleanup_error: + logger.error( + f"Error cleaning up sandbox {new_sandbox.id}: {cleanup_error}" + ) + + if new_messages: + for message in new_messages: + try: + logger.info(f"Cleaning up message: {message['message_id']}") + await db.table("messages").delete().eq( + "message_id", message["message_id"] + ).execute() + except Exception as cleanup_error: + logger.error( + f"Error cleaning up message {message['message_id']}: {cleanup_error}" + ) + + if new_agent_runs: + for agent_run in new_agent_runs: + try: + logger.info(f"Cleaning up agent run: {agent_run['id']}") + await db.table("agent_runs").delete().eq( + "id", agent_run["id"] + ).execute() + except Exception as cleanup_error: + logger.error( + f"Error cleaning up agent run {agent_run['id']}: {cleanup_error}" + ) + + if new_threads: + for thread in new_threads: + try: + logger.info(f"Cleaning up thread: {thread['thread_id']}") + await db.table("threads").delete().eq( + "thread_id", thread["thread_id"] + ).execute() + except Exception as cleanup_error: + logger.error( + f"Error cleaning up thread {thread['thread_id']}: {cleanup_error}" + ) + + if new_project: + try: + logger.info(f"Cleaning up project: {new_project['project_id']}") + await db.table("projects").delete().eq( + "project_id", new_project["project_id"] + ).execute() + except Exception as cleanup_error: + logger.error( + f"Error cleaning up project {new_project['project_id']}: {cleanup_error}" + ) + + await DBConnection.disconnect() + raise e + + finally: + await DBConnection.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/utils/scripts/delete_user_sandboxes.py b/app/utils/scripts/delete_user_sandboxes.py new file mode 100644 index 000000000..fe1254ef4 --- /dev/null +++ b/app/utils/scripts/delete_user_sandboxes.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python +""" +Script to query and delete sandboxes for a given account ID. + +Usage: + python delete_user_sandboxes.py +""" + +import asyncio +import sys +import os +from typing import List, Dict, Any +from dotenv import load_dotenv + +# Load script-specific environment variables +load_dotenv(".env") + +from services.supabase import DBConnection +from sandbox.sandbox import daytona +from utils.logger import logger + + +async def get_user_sandboxes(account_id: str) -> List[Dict[str, Any]]: + """ + Query all projects and their sandboxes associated with a specific account ID. + + Args: + account_id: The account ID to query + + Returns: + List of projects with sandbox information + """ + db = DBConnection() + client = await db.client + + # Print the Supabase URL being used + print(f"Using Supabase URL: {os.getenv('SUPABASE_URL')}") + + # Query projects by account_id + result = await client.table('projects').select( + 'project_id', + 'name', + 'sandbox' + ).eq('account_id', account_id).execute() + + # Print the query result for debugging + print(f"Query result: {result}") + + if not result.data: + logger.info(f"No projects found for account ID: {account_id}") + return [] + + # Filter projects with sandbox information + projects_with_sandboxes = [ + project for project in result.data + if project.get('sandbox') and project['sandbox'].get('id') + ] + + logger.info(f"Found {len(projects_with_sandboxes)} projects with sandboxes for account ID: {account_id}") + return projects_with_sandboxes + + +async def delete_sandboxes(projects: List[Dict[str, Any]]) -> None: + """ + Delete all sandboxes from the provided list of projects. + + Args: + projects: List of projects with sandbox information + """ + if not projects: + logger.info("No sandboxes to delete") + return + + for project in projects: + sandbox_id = project['sandbox'].get('id') + project_name = project.get('name', 'Unknown') + project_id = project.get('project_id', 'Unknown') + + if not sandbox_id: + continue + + try: + logger.info(f"Deleting sandbox {sandbox_id} for project '{project_name}' (ID: {project_id})") + + # Get the sandbox and delete it + sandbox = daytona.get(sandbox_id) + daytona.delete(sandbox) + + logger.info(f"Successfully deleted sandbox {sandbox_id}") + except Exception as e: + logger.error(f"Error deleting sandbox {sandbox_id}: {str(e)}") + + +async def main(): + """Main function to run the script.""" + if len(sys.argv) != 2: + print(f"Usage: python {sys.argv[0]} ") + sys.exit(1) + + account_id = sys.argv[1] + logger.info(f"Starting sandbox cleanup for account ID: {account_id}") + + # Print environment info + print(f"Environment Mode: {os.getenv('ENV_MODE', 'Not set')}") + print(f"Daytona Server: {os.getenv('DAYTONA_SERVER_URL', 'Not set')}") + + try: + # Query projects with sandboxes + projects = await get_user_sandboxes(account_id) + + # Print sandbox information + for i, project in enumerate(projects): + sandbox_id = project['sandbox'].get('id', 'N/A') + print(f"{i+1}. Project: {project.get('name', 'Unknown')}") + print(f" Project ID: {project.get('project_id', 'Unknown')}") + print(f" Sandbox ID: {sandbox_id}") + + # Confirm deletion + if projects: + confirm = input(f"\nDelete {len(projects)} sandboxes? (y/n): ") + if confirm.lower() == 'y': + await delete_sandboxes(projects) + logger.info("Sandbox cleanup completed") + else: + logger.info("Sandbox deletion cancelled") + else: + logger.info("No sandboxes found for deletion") + + except Exception as e: + logger.error(f"Error during sandbox cleanup: {str(e)}") + sys.exit(1) + finally: + # Clean up database connection + await DBConnection.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/utils/scripts/export_import_project.py b/app/utils/scripts/export_import_project.py new file mode 100644 index 000000000..b5632ff20 --- /dev/null +++ b/app/utils/scripts/export_import_project.py @@ -0,0 +1,400 @@ +import asyncio +import argparse +import json +import os +from datetime import datetime +from dotenv import load_dotenv + +load_dotenv(".env") + +from services.supabase import DBConnection +from daytona_sdk import Sandbox +from sandbox.sandbox import daytona, create_sandbox, delete_sandbox +from utils.logger import logger + +db_connection = None +db = None + + +async def get_db(): + global db_connection, db + if db_connection is None or db is None: + db_connection = DBConnection() + db = await db_connection.client + return db + + +async def get_project(project_id: str): + db = await get_db() + project = ( + await db.schema("public") + .from_("projects") + .select("*") + .eq("project_id", project_id) + .maybe_single() + .execute() + ) + return project.data + + +async def get_threads(project_id: str): + db = await get_db() + threads = ( + await db.schema("public") + .from_("threads") + .select("*") + .eq("project_id", project_id) + .execute() + ) + return threads.data + + +async def get_agent_runs(thread_id: str): + db = await get_db() + agent_runs = ( + await db.schema("public") + .from_("agent_runs") + .select("*") + .eq("thread_id", thread_id) + .execute() + ) + return agent_runs.data + + +async def get_messages(thread_id: str): + db = await get_db() + messages_data = [] + offset = 0 + batch_size = 1000 + + while True: + batch = ( + await db.schema("public") + .from_("messages") + .select("*") + .eq("thread_id", thread_id) + .range(offset, offset + batch_size - 1) + .execute() + ) + + if not batch.data: + break + + messages_data.extend(batch.data) + + if len(batch.data) < batch_size: + break + + offset += batch_size + + return messages_data + + +async def get_user(user_id: str): + db = await get_db() + user = await db.auth.admin.get_user_by_id(user_id) + return user.user.model_dump() + + +async def export_project_to_file(project_id: str, output_file: str): + """Export all project data to a JSON file.""" + try: + logger.info(f"Starting export of project {project_id}") + + # Get project data + project = await get_project(project_id) + if not project: + raise Exception(f"Project {project_id} not found") + + logger.info(f"Exporting project: {project['name']}") + + # Get threads + threads = await get_threads(project_id) + logger.info(f"Found {len(threads)} threads") + + # Get agent runs and messages for each thread + threads_data = [] + for thread in threads: + thread_data = dict(thread) + + # Get agent runs for this thread + agent_runs = await get_agent_runs(thread["thread_id"]) + thread_data["agent_runs"] = agent_runs + + # Get messages for this thread + messages = await get_messages(thread["thread_id"]) + thread_data["messages"] = messages + + threads_data.append(thread_data) + logger.info(f"Thread {thread['thread_id']}: {len(agent_runs)} runs, {len(messages)} messages") + + # Prepare export data + export_data = { + "export_metadata": { + "export_date": datetime.now().isoformat(), + "project_id": project_id, + "project_name": project["name"] + }, + "project": project, + "threads": threads_data + } + + # Write to file + os.makedirs(os.path.dirname(output_file), exist_ok=True) + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(export_data, f, indent=2, ensure_ascii=False, default=str) + + logger.info(f"Project exported successfully to {output_file}") + logger.info(f"Export summary: 1 project, {len(threads_data)} threads") + + return export_data + + except Exception as e: + logger.error(f"Error exporting project: {e}") + raise e + finally: + await DBConnection.disconnect() + + +async def import_project_from_file(input_file: str, to_user_id: str = None, create_new_sandbox: bool = True): + """Import project data from a JSON file and create a new project.""" + new_sandbox = None + new_project = None + new_threads = [] + new_agent_runs = [] + new_messages = [] + + try: + logger.info(f"Starting import from {input_file}") + + # Read data from file + with open(input_file, 'r', encoding='utf-8') as f: + import_data = json.load(f) + + project_data = import_data["project"] + threads_data = import_data["threads"] + + logger.info(f"Importing project: {project_data['name']}") + logger.info(f"Found {len(threads_data)} threads to import") + + # Determine target user + to_user_id = to_user_id or project_data["account_id"] + to_user = await get_user(to_user_id) + + logger.info(f"Target user: {to_user['id']} ({to_user['email']})") + + # Create new sandbox if requested + if create_new_sandbox: + logger.info("Creating new sandbox...") + new_sandbox = create_sandbox(project_data["sandbox"]["pass"], project_data["project_id"]) + + if new_sandbox: + vnc_link = new_sandbox.get_preview_link(6080) + website_link = new_sandbox.get_preview_link(8080) + vnc_url = ( + vnc_link.url + if hasattr(vnc_link, "url") + else str(vnc_link).split("url='")[1].split("'")[0] + ) + website_url = ( + website_link.url + if hasattr(website_link, "url") + else str(website_link).split("url='")[1].split("'")[0] + ) + token = None + if hasattr(vnc_link, "token"): + token = vnc_link.token + elif "token='" in str(vnc_link): + token = str(vnc_link).split("token='")[1].split("'")[0] + + sandbox_data = { + "id": new_sandbox.id, + "pass": project_data["sandbox"]["pass"], + "token": token, + "vnc_preview": vnc_url, + "sandbox_url": website_url, + } + logger.info(f"New sandbox created: {new_sandbox.id}") + else: + raise Exception("Failed to create new sandbox") + else: + # Use existing sandbox data + sandbox_data = project_data["sandbox"] + logger.info("Using existing sandbox data") + + # Create new project + db = await get_db() + result = ( + await db.schema("public") + .from_("projects") + .insert( + { + "name": project_data["name"], + "description": project_data["description"], + "account_id": to_user["id"], + "is_public": project_data["is_public"], + "sandbox": sandbox_data, + } + ) + .execute() + ) + new_project = result.data[0] + logger.info(f"New project created: {new_project['project_id']} ({new_project['name']})") + + # Import threads + for thread_data in threads_data: + # Create new thread + new_thread = ( + await db.schema("public") + .from_("threads") + .insert( + { + "account_id": to_user["id"], + "project_id": new_project["project_id"], + "is_public": thread_data["is_public"], + "agent_id": thread_data["agent_id"], + "metadata": thread_data["metadata"] or {}, + } + ) + .execute() + ) + new_thread = new_thread.data[0] + new_threads.append(new_thread) + + # Create agent runs for this thread + for agent_run_data in thread_data.get("agent_runs", []): + new_agent_run = ( + await db.schema("public") + .from_("agent_runs") + .insert( + { + "thread_id": new_thread["thread_id"], + "status": agent_run_data["status"], + "started_at": agent_run_data["started_at"], + "completed_at": agent_run_data["completed_at"], + "responses": agent_run_data["responses"], + "error": agent_run_data["error"], + } + ) + .execute() + ) + new_agent_runs.append(new_agent_run.data[0]) + + # Create messages for this thread in batches + messages = thread_data.get("messages", []) + batch_size = 100 + for i in range(0, len(messages), batch_size): + batch_messages = messages[i:i + batch_size] + message_inserts = [] + + for message_data in batch_messages: + message_inserts.append({ + "thread_id": new_thread["thread_id"], + "type": message_data["type"], + "is_llm_message": message_data["is_llm_message"], + "content": message_data["content"], + "metadata": message_data["metadata"], + "created_at": message_data["created_at"], + "updated_at": message_data["updated_at"], + }) + + if message_inserts: + batch_result = ( + await db.schema("public") + .from_("messages") + .insert(message_inserts) + .execute() + ) + new_messages.extend(batch_result.data) + + # Add delay between batches + if i + batch_size < len(messages): + await asyncio.sleep(0.5) + + logger.info(f"Thread imported: {len(thread_data.get('agent_runs', []))} runs, {len(messages)} messages") + + logger.info(f"Import completed successfully!") + logger.info(f"Summary: 1 project, {len(new_threads)} threads, {len(new_agent_runs)} agent runs, {len(new_messages)} messages") + + return { + "project": new_project, + "threads": new_threads, + "agent_runs": new_agent_runs, + "messages": new_messages + } + + except Exception as e: + logger.error(f"Error importing project: {e}") + + # Clean up any resources that were created before the error + db = await get_db() + + if new_sandbox: + try: + logger.info(f"Cleaning up sandbox: {new_sandbox.id}") + await delete_sandbox(new_sandbox.id) + except Exception as cleanup_error: + logger.error(f"Error cleaning up sandbox {new_sandbox.id}: {cleanup_error}") + + if new_messages: + for message in new_messages: + try: + await db.table("messages").delete().eq("message_id", message["message_id"]).execute() + except Exception as cleanup_error: + logger.error(f"Error cleaning up message {message['message_id']}: {cleanup_error}") + + if new_agent_runs: + for agent_run in new_agent_runs: + try: + await db.table("agent_runs").delete().eq("id", agent_run["id"]).execute() + except Exception as cleanup_error: + logger.error(f"Error cleaning up agent run {agent_run['id']}: {cleanup_error}") + + if new_threads: + for thread in new_threads: + try: + await db.table("threads").delete().eq("thread_id", thread["thread_id"]).execute() + except Exception as cleanup_error: + logger.error(f"Error cleaning up thread {thread['thread_id']}: {cleanup_error}") + + if new_project: + try: + await db.table("projects").delete().eq("project_id", new_project["project_id"]).execute() + except Exception as cleanup_error: + logger.error(f"Error cleaning up project {new_project['project_id']}: {cleanup_error}") + + await DBConnection.disconnect() + raise e + + finally: + await DBConnection.disconnect() + + +async def main(): + """Main function to run the script.""" + parser = argparse.ArgumentParser(description="Export/Import project data") + parser.add_argument("action", choices=["export", "import"], help="Action to perform") + parser.add_argument("--project-id", type=str, help="Project ID to export (required for export)") + parser.add_argument("--file", type=str, help="File path for export/import", required=True) + parser.add_argument("--user-id", type=str, help="User ID to import project to (optional for import)") + parser.add_argument("--no-sandbox", action="store_true", help="Don't create new sandbox during import") + + args = parser.parse_args() + + try: + if args.action == "export": + if not args.project_id: + raise Exception("--project-id is required for export") + await export_project_to_file(args.project_id, args.file) + + elif args.action == "import": + create_new_sandbox = not args.no_sandbox + await import_project_from_file(args.file, args.user_id, create_new_sandbox) + + except Exception as e: + logger.error(f"Script failed: {e}") + raise e + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/app/utils/scripts/generate_share_links.py b/app/utils/scripts/generate_share_links.py new file mode 100644 index 000000000..6607eeb7d --- /dev/null +++ b/app/utils/scripts/generate_share_links.py @@ -0,0 +1,166 @@ +import asyncio +import sys +import os +from typing import List, Dict, Any +from datetime import datetime +import random +from dotenv import load_dotenv + +load_dotenv(".env") + +from services.supabase import DBConnection +from utils.logger import logger + +db_connection = None + + +async def get_random_thread_ids(n: int) -> List[str]: + global db_connection + if db_connection is None: + db_connection = DBConnection() + + client = await db_connection.client + + print(f"Using Supabase URL: {os.getenv('SUPABASE_URL')}") + + all_thread_ids = [] + page_size = 1000 + current_page = 0 + has_more = True + + print("Fetching all thread IDs from database (paginated)...") + + while has_more: + start_range = current_page * page_size + end_range = start_range + page_size - 1 + + print(f"Fetching page {current_page + 1} (rows {start_range}-{end_range})...") + + try: + result = await client.table('threads').select('thread_id').range(start_range, end_range).execute() + + if not result.data: + has_more = False + else: + page_thread_ids = [thread['thread_id'] for thread in result.data] + all_thread_ids.extend(page_thread_ids) + + print(f"Loaded {len(page_thread_ids)} thread IDs (total so far: {len(all_thread_ids)})") + if len(result.data) < page_size: + has_more = False + else: + current_page += 1 + + except Exception as e: + logger.error(f"Error during pagination: {str(e)}") + has_more = False + + print(f"Found {len(all_thread_ids)} total thread IDs in database") + + if not all_thread_ids: + logger.info("No threads found in database") + return [] + + if len(all_thread_ids) <= n: + logger.warning(f"Requested {n} threads but only {len(all_thread_ids)} available. Returning all.") + selected_thread_ids = all_thread_ids + else: + selected_thread_ids = random.sample(all_thread_ids, n) + + logger.info(f"Retrieved {len(selected_thread_ids)} random thread IDs") + return selected_thread_ids + + +async def generate_share_links(n: int) -> List[str]: + try: + thread_ids = await get_random_thread_ids(n) + + if not thread_ids: + logger.warning("No thread IDs found, returning empty list") + return [] + + share_links = [f"suna.so/share/{thread_id}" for thread_id in thread_ids] + + logger.info(f"Generated {len(share_links)} share links") + return share_links + + except Exception as e: + logger.error(f"Error generating share links: {str(e)}") + raise + + +def save_links_to_file(share_links: List[str], filename: str = None) -> str: + """Save share links to a text file and return the filename.""" + if filename is None: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"share_links_{timestamp}.txt" + + try: + with open(filename, 'w', encoding='utf-8') as f: + f.write(f"Share Links Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + f.write("=" * 60 + "\n\n") + + for i, link in enumerate(share_links, 1): + f.write(f"{i}. {link}\n") + + f.write(f"\nTotal: {len(share_links)} share links generated\n") + + logger.info(f"Share links saved to {filename}") + return filename + + except Exception as e: + logger.error(f"Error saving links to file: {str(e)}") + raise + + +async def main(): + logger.info("Starting share link generation process") + + try: + global db_connection + db_connection = DBConnection() + + try: + n = int(input("Enter the number of share links to generate: ")) + if n <= 0: + print("Number must be positive") + return + except ValueError: + print("Please enter a valid number") + return + + custom_filename = input("Enter filename (press Enter for auto-generated): ").strip() + if not custom_filename: + custom_filename = None + elif not custom_filename.endswith('.txt'): + custom_filename += '.txt' + + print(f"\nGenerating {n} random share links...") + + share_links = await generate_share_links(n) + + if not share_links: + print("No share links were generated") + return + + print(f"\nGenerated {len(share_links)} share links:") + print("-" * 50) + for i, link in enumerate(share_links, 1): + print(f"{i}. {link}") + + saved_filename = save_links_to_file(share_links, custom_filename) + + print(f"\nTotal: {len(share_links)} share links generated") + print(f"Links saved to: {saved_filename}") + logger.info("Share link generation completed") + + except Exception as e: + logger.error(f"Error during share link generation: {str(e)}") + sys.exit(1) + finally: + if db_connection: + await DBConnection.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/app/utils/scripts/get_monthly_usage.py b/app/utils/scripts/get_monthly_usage.py new file mode 100644 index 000000000..4fdb1e019 --- /dev/null +++ b/app/utils/scripts/get_monthly_usage.py @@ -0,0 +1,248 @@ +""" +Monthly Usage Script + +This script calculates the monthly usage (in agent run minutes) for a specific user during a specific month. + +Usage: + python backend/utils/scripts/get_monthly_usage.py --user-id --year --month [--verbose] + +Arguments: + --user-id The user ID to get usage for (required) + --year The year (e.g., 2024) (required) + --month The month (1-12) (required) + --verbose Enable verbose logging (optional) + +Examples: + # Get usage for December 2024 + python backend/utils/scripts/get_monthly_usage.py --user-id "user123" --year 2024 --month 12 + + # Get usage with verbose logging + python backend/utils/scripts/get_monthly_usage.py --user-id "user123" --year 2024 --month 11 --verbose + +Output: + The script will output: + - User information (email and ID) + - Month and year + - Total usage in minutes and hours + - Average usage per day (if any usage exists) + + Example output: + === Monthly Usage Report === + User: user@example.com (user123) + Month: December 2024 + Total Usage: 150.45 minutes + Total Usage: 2.51 hours + Average per day: 5.02 minutes + +Features: + - Validates agent runs to exclude invalid durations (>2 hours) + - Handles incomplete agent runs appropriately + - Provides detailed logging for debugging + - Calculates usage only for the specified month + - Shows average daily usage + +Notes: + - The script requires access to the Supabase database + - Make sure the .env file is properly configured + - The script uses the same logic as the billing system for consistency +""" + +import asyncio +import argparse +from datetime import datetime, timezone +from dotenv import load_dotenv + +load_dotenv(".env") + +from services.supabase import DBConnection +from utils.logger import logger + +db_connection = None +db = None + + +async def get_db(): + global db_connection, db + if db_connection is None or db is None: + db_connection = DBConnection() + db = await db_connection.client + return db + + +async def get_user(user_id: str): + """Get user information by user ID.""" + db = await get_db() + user = await db.auth.admin.get_user_by_id(user_id) + return user.user.model_dump() + + +async def calculate_monthly_usage(client, user_id: str, year: int, month: int): + """Calculate total agent run minutes for a specific month for a user.""" + # Get start and end of specified month in UTC + start_of_month = datetime(year, month, 1, tzinfo=timezone.utc) + + # Calculate start of next month for end boundary + if month == 12: + end_of_month = datetime(year + 1, 1, 1, tzinfo=timezone.utc) + else: + end_of_month = datetime(year, month + 1, 1, tzinfo=timezone.utc) + + # First get all threads for this user + threads_result = ( + await client.table("threads") + .select("thread_id") + .eq("account_id", user_id) + .execute() + ) + + if not threads_result.data: + return 0.0, [] + + thread_ids = [t["thread_id"] for t in threads_result.data] + logger.info(f"Found {len(thread_ids)} threads for user {user_id}") + + # Then get all agent runs for these threads in specified month + runs_result = ( + await client.table("agent_runs") + .select("id, started_at, completed_at, thread_id") + .in_("thread_id", thread_ids) + .gte("started_at", start_of_month.isoformat()) + .lt("started_at", end_of_month.isoformat()) + .execute() + ) + + if not runs_result.data: + return 0.0, [] + + logger.info(f"Found {len(runs_result.data)} agent runs in {year}-{month:02d}") + + # Calculate total minutes and collect run details + total_seconds = 0 + valid_runs = 0 + run_details = [] + + for run in runs_result.data: + start_time = datetime.fromisoformat( + run["started_at"].replace("Z", "+00:00") + ).timestamp() + + if run["completed_at"]: + end_time = datetime.fromisoformat( + run["completed_at"].replace("Z", "+00:00") + ).timestamp() + # Skip runs that seem invalid (more than 2 hours) + if start_time < end_time - 7200: + logger.warning(f"Skipping run with duration > 2 hours: {run}") + continue + status = "completed" + else: + # For incomplete runs, use end of month as boundary if run started in that month + end_time = min( + end_of_month.timestamp(), datetime.now(timezone.utc).timestamp() + ) + # Skip runs that started more than 1 hour ago and are still incomplete + if start_time < datetime.now(timezone.utc).timestamp() - 3600: + logger.warning(f"Skipping incomplete run started > 1 hour ago: {run}") + continue + status = "incomplete" + + duration = end_time - start_time + total_seconds += duration + valid_runs += 1 + + # Store run details + run_details.append( + { + "id": run["id"], + "thread_id": run["thread_id"], + "started_at": run["started_at"], + "completed_at": run["completed_at"], + "duration_minutes": duration / 60, + "status": status, + } + ) + + logger.debug(f"Run duration: {duration/60:.2f} minutes") + + logger.info( + f"Processed {valid_runs} valid runs out of {len(runs_result.data)} total runs" + ) + + # Sort runs by duration (longest first) + run_details.sort(key=lambda x: x["duration_minutes"], reverse=True) + + return total_seconds / 60, run_details # Convert to minutes + + +async def main(): + """Main function to run the script.""" + # Parse command line arguments + parser = argparse.ArgumentParser( + description="Get monthly usage for a specific user and month" + ) + parser.add_argument( + "--user-id", type=str, help="User ID to get usage for", required=True + ) + parser.add_argument("--year", type=int, help="Year (e.g., 2024)", required=True) + parser.add_argument("--month", type=int, help="Month (1-12)", required=True) + parser.add_argument( + "--verbose", "-v", action="store_true", help="Enable verbose logging" + ) + + args = parser.parse_args() + + # Validate month + if args.month < 1 or args.month > 12: + raise ValueError("Month must be between 1 and 12") + + try: + # Get user information + try: + user = await get_user(args.user_id) + logger.info(f"User: {user['id']} ({user['email']})") + except Exception as e: + logger.warning(f"Could not fetch user details: {e}") + user = {"id": args.user_id, "email": "unknown"} + + # Get database connection + db = await get_db() + + # Calculate monthly usage + usage_minutes, run_details = await calculate_monthly_usage( + db, args.user_id, args.year, args.month + ) + + # Display results + month_name = datetime(args.year, args.month, 1).strftime("%B") + print(f"\n=== Monthly Usage Report ===") + print(f"User: {user['email']} ({user['id']})") + print(f"Month: {month_name} {args.year}") + print(f"Total Usage: {usage_minutes:.2f} minutes") + print(f"Total Usage: {usage_minutes/60:.2f} hours") + + if usage_minutes > 0: + print(f"Average per day: {usage_minutes/30:.2f} minutes") + + # Display top 10 runs + if run_details: + print(f"\n=== Top Longest Runs ===") + for i, run in enumerate(run_details, 1): + started_at = datetime.fromisoformat( + run["started_at"].replace("Z", "+00:00") + ) + print( + f"{i:2d}. {run['duration_minutes']:6.2f} min | {started_at.strftime('%Y-%m-%d %H:%M')} | {run['status']:10} | Thread: {run['thread_id']} | Run: {run['id']}" + ) + else: + print("\nNo runs found for this period.") + + except Exception as e: + logger.error(f"Error: {e}") + raise e + + finally: + await DBConnection.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/utils/scripts/set_all_customers_active.py b/app/utils/scripts/set_all_customers_active.py new file mode 100644 index 000000000..a64cf75cc --- /dev/null +++ b/app/utils/scripts/set_all_customers_active.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python +""" +Script to set all Stripe customers in the database to active status. + +Usage: + python update_customer_status.py + +This script: +1. Queries all customer IDs from basejump.billing_customers +2. Sets all customers' active field to True in the database + +Make sure your environment variables are properly set: +- SUPABASE_URL +- SUPABASE_SERVICE_ROLE_KEY +""" + +import asyncio +import sys +import os +from typing import List, Dict, Any +from dotenv import load_dotenv + +# Load script-specific environment variables +load_dotenv(".env") + +from services.supabase import DBConnection +from utils.logger import logger + +# Semaphore to limit concurrent database connections +DB_CONNECTION_LIMIT = 20 +db_semaphore = asyncio.Semaphore(DB_CONNECTION_LIMIT) + +# Global DB connection to reuse +db_connection = None + + +async def get_all_customers() -> List[Dict[str, Any]]: + """ + Query all customers from the database. + + Returns: + List of customers with their ID and account_id + """ + global db_connection + if db_connection is None: + db_connection = DBConnection() + + client = await db_connection.client + + # Print the Supabase URL being used + print(f"Using Supabase URL: {os.getenv('SUPABASE_URL')}") + + # Query all customers from billing_customers + result = await client.schema('basejump').from_('billing_customers').select( + 'id', + 'account_id', + 'active' + ).execute() + + # Print the query result + print(f"Found {len(result.data)} customers in database") + print(result.data) + + if not result.data: + logger.info("No customers found in database") + return [] + + return result.data + + +async def update_all_customers_to_active() -> Dict[str, int]: + """ + Update all customers to active status in the database. + + Returns: + Dict with count of updated customers + """ + try: + global db_connection + if db_connection is None: + db_connection = DBConnection() + + client = await db_connection.client + + # Update all customers to active + result = await client.schema('basejump').from_('billing_customers').update( + {'active': True} + ).filter('id', 'neq', None).execute() + + updated_count = len(result.data) if hasattr(result, 'data') else 0 + logger.info(f"Updated {updated_count} customers to active status") + print(f"Updated {updated_count} customers to active status") + print("Result:", result) + + return {'updated': updated_count} + except Exception as e: + logger.error(f"Error updating customers in database: {str(e)}") + return {'updated': 0, 'error': str(e)} + + +async def main(): + """Main function to run the script.""" + logger.info("Starting customer status update process") + + try: + # Initialize global DB connection + global db_connection + db_connection = DBConnection() + + # Get all customers from the database + customers = await get_all_customers() + + if not customers: + logger.info("No customers to process") + return + + # Ask for confirmation before proceeding + confirm = input(f"\nSet all {len(customers)} customers to active? (y/n): ") + if confirm.lower() != 'y': + logger.info("Operation cancelled by user") + return + + # Update all customers to active + results = await update_all_customers_to_active() + + # Print summary + print("\nCustomer Status Update Summary:") + print(f"Total customers set to active: {results.get('updated', 0)}") + + logger.info("Customer status update completed") + + except Exception as e: + logger.error(f"Error during customer status update: {str(e)}") + sys.exit(1) + finally: + # Clean up database connection + if db_connection: + await DBConnection.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/app/utils/scripts/update_customer_active_status.py b/app/utils/scripts/update_customer_active_status.py new file mode 100644 index 000000000..ced3ad2e2 --- /dev/null +++ b/app/utils/scripts/update_customer_active_status.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python +""" +Script to check Stripe subscriptions for all customers and update their active status. + +Usage: + python update_customer_active_status.py + +This script: +1. Queries all customers from basejump.billing_customers +2. Checks subscription status directly on Stripe using customer_id +3. Updates customer active status in database + +Make sure your environment variables are properly set: +- SUPABASE_URL +- SUPABASE_SERVICE_ROLE_KEY +- STRIPE_SECRET_KEY +""" + +import asyncio +import sys +import os +import time +from typing import List, Dict, Any, Tuple +from dotenv import load_dotenv +import stripe + +# Load script-specific environment variables +load_dotenv(".env") + +# Import relative modules +from services.supabase import DBConnection +from utils.logger import logger +from utils.config import config + +# Initialize Stripe with the API key +stripe.api_key = config.STRIPE_SECRET_KEY + +# Batch size settings +BATCH_SIZE = 100 # Process customers in batches +MAX_CONCURRENCY = 20 # Maximum concurrent Stripe API calls + +# Global DB connection to reuse +db_connection = None + +async def get_all_customers() -> List[Dict[str, Any]]: + """ + Query all customers from the database. + + Returns: + List of customers with their ID (customer_id is used for Stripe) + """ + global db_connection + if db_connection is None: + db_connection = DBConnection() + + client = await db_connection.client + + # Print the Supabase URL being used + print(f"Using Supabase URL: {os.getenv('SUPABASE_URL')}") + + # Query all customers from billing_customers + result = await client.schema('basejump').from_('billing_customers').select( + 'id', + 'active' + ).execute() + + # Print the query result + print(f"Found {len(result.data)} customers in database") + + if not result.data: + logger.info("No customers found in database") + return [] + + return result.data + +async def check_stripe_subscription(customer_id: str) -> bool: + """ + Check if a customer has an active subscription directly on Stripe. + + Args: + customer_id: Customer ID (billing_customers.id) which is the Stripe customer ID + + Returns: + True if customer has at least one active subscription, False otherwise + """ + if not customer_id: + print(f"⚠️ Empty customer_id") + return False + + try: + # Print what we're checking for debugging + print(f"Checking Stripe subscriptions for customer: {customer_id}") + + # List all subscriptions for this customer directly on Stripe + subscriptions = stripe.Subscription.list( + customer=customer_id, + status='active', # Only get active subscriptions + limit=1 # We only need to know if there's at least one + ) + + # Print the raw data for debugging + print(f"Stripe returned data: {subscriptions.data}") + + # If there's at least one active subscription, the customer is active + has_active_subscription = len(subscriptions.data) > 0 + + if has_active_subscription: + print(f"✅ Customer {customer_id} has ACTIVE subscription") + else: + print(f"❌ Customer {customer_id} has NO active subscription") + + return has_active_subscription + + except Exception as e: + logger.error(f"Error checking Stripe subscription for customer {customer_id}: {str(e)}") + print(f"⚠️ Error checking subscription for {customer_id}: {str(e)}") + return False + +async def process_customer_batch(batch: List[Dict[str, Any]], batch_number: int, total_batches: int) -> Dict[str, bool]: + """ + Process a batch of customers by checking their Stripe subscriptions concurrently. + + Args: + batch: List of customer records in this batch + batch_number: Current batch number (for logging) + total_batches: Total number of batches (for logging) + + Returns: + Dictionary mapping customer IDs to subscription status (True/False) + """ + start_time = time.time() + batch_size = len(batch) + print(f"Processing batch {batch_number}/{total_batches} ({batch_size} customers)...") + + # Create a semaphore to limit concurrency within the batch to avoid rate limiting + semaphore = asyncio.Semaphore(MAX_CONCURRENCY) + + async def check_single_customer(customer: Dict[str, Any]) -> Tuple[str, bool]: + async with semaphore: # Limit concurrent API calls + customer_id = customer['id'] + + # Check directly on Stripe - customer_id IS the Stripe customer ID + is_active = await check_stripe_subscription(customer_id) + return customer_id, is_active + + # Create tasks for all customers in this batch + tasks = [check_single_customer(customer) for customer in batch] + + # Run all tasks in this batch concurrently + results = await asyncio.gather(*tasks) + + # Convert results to dictionary + subscription_status = {customer_id: status for customer_id, status in results} + + end_time = time.time() + + # Count active/inactive in this batch + active_count = sum(1 for status in subscription_status.values() if status) + inactive_count = batch_size - active_count + + print(f"Batch {batch_number} completed in {end_time - start_time:.2f} seconds") + print(f"Results (batch {batch_number}): {active_count} active, {inactive_count} inactive subscriptions") + + return subscription_status + +async def update_customer_batch(subscription_status: Dict[str, bool]) -> Dict[str, int]: + """ + Update a batch of customers in the database. + + Args: + subscription_status: Dictionary mapping customer IDs to active status + + Returns: + Dictionary with statistics about the update + """ + start_time = time.time() + + global db_connection + if db_connection is None: + db_connection = DBConnection() + + client = await db_connection.client + + # Separate customers into active and inactive groups + active_customers = [cid for cid, status in subscription_status.items() if status] + inactive_customers = [cid for cid, status in subscription_status.items() if not status] + + total_count = len(active_customers) + len(inactive_customers) + + # Update statistics + stats = { + 'total': total_count, + 'active_updated': 0, + 'inactive_updated': 0, + 'errors': 0 + } + + # Update active customers in a single operation + if active_customers: + try: + print(f"Updating {len(active_customers)} customers to ACTIVE status") + await client.schema('basejump').from_('billing_customers').update( + {'active': True} + ).in_('id', active_customers).execute() + + stats['active_updated'] = len(active_customers) + logger.info(f"Updated {len(active_customers)} customers to ACTIVE status") + except Exception as e: + logger.error(f"Error updating active customers: {str(e)}") + stats['errors'] += 1 + + # Update inactive customers in a single operation + if inactive_customers: + try: + print(f"Updating {len(inactive_customers)} customers to INACTIVE status") + await client.schema('basejump').from_('billing_customers').update( + {'active': False} + ).in_('id', inactive_customers).execute() + + stats['inactive_updated'] = len(inactive_customers) + logger.info(f"Updated {len(inactive_customers)} customers to INACTIVE status") + except Exception as e: + logger.error(f"Error updating inactive customers: {str(e)}") + stats['errors'] += 1 + + end_time = time.time() + print(f"Database updates completed in {end_time - start_time:.2f} seconds") + + return stats + +async def main(): + """Main function to run the script.""" + total_start_time = time.time() + logger.info("Starting customer active status update process") + + try: + # Check Stripe API key + print(f"Stripe API key configured: {'Yes' if config.STRIPE_SECRET_KEY else 'No'}") + if not config.STRIPE_SECRET_KEY: + print("ERROR: Stripe API key not configured. Please set STRIPE_SECRET_KEY in your environment.") + return + + # Initialize global DB connection + global db_connection + db_connection = DBConnection() + + # Get all customers from the database + all_customers = await get_all_customers() + + if not all_customers: + logger.info("No customers to process") + return + + # Print a small sample of the customer data + print("\nCustomer data sample (customer_id = Stripe customer ID):") + for i, customer in enumerate(all_customers[:5]): # Show first 5 only + print(f" {i+1}. ID: {customer['id']}, Active: {customer.get('active')}") + if len(all_customers) > 5: + print(f" ... and {len(all_customers) - 5} more") + + # Split customers into batches + batches = [all_customers[i:i + BATCH_SIZE] for i in range(0, len(all_customers), BATCH_SIZE)] + total_batches = len(batches) + + # Ask for confirmation before proceeding + confirm = input(f"\nProcess {len(all_customers)} customers in {total_batches} batches of {BATCH_SIZE}? (y/n): ") + if confirm.lower() != 'y': + logger.info("Operation cancelled by user") + return + + # Overall statistics + all_stats = { + 'total': 0, + 'active_updated': 0, + 'inactive_updated': 0, + 'errors': 0 + } + + # Process each batch + for i, batch in enumerate(batches): + batch_number = i + 1 + + # STEP 1: Process this batch of customers + subscription_status = await process_customer_batch(batch, batch_number, total_batches) + + # STEP 2: Update this batch in the database + batch_stats = await update_customer_batch(subscription_status) + + # Accumulate statistics + all_stats['total'] += batch_stats['total'] + all_stats['active_updated'] += batch_stats['active_updated'] + all_stats['inactive_updated'] += batch_stats['inactive_updated'] + all_stats['errors'] += batch_stats['errors'] + + # Show batch completion + print(f"Completed batch {batch_number}/{total_batches}") + + # Brief pause between batches to avoid Stripe rate limiting + if batch_number < total_batches: + await asyncio.sleep(1) # 1 second pause between batches + + # Print summary + total_end_time = time.time() + total_time = total_end_time - total_start_time + + print("\nCustomer Status Update Summary:") + print(f"Total customers processed: {all_stats['total']}") + print(f"Customers set to active: {all_stats['active_updated']}") + print(f"Customers set to inactive: {all_stats['inactive_updated']}") + if all_stats['errors'] > 0: + print(f"Update errors: {all_stats['errors']}") + print(f"Total processing time: {total_time:.2f} seconds") + + logger.info(f"Customer active status update completed in {total_time:.2f} seconds") + + except Exception as e: + logger.error(f"Error during customer status update: {str(e)}") + sys.exit(1) + finally: + # Clean up database connection + if db_connection: + await DBConnection.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file From a6a45d7e0297fd09e918e0034a759674d009cbe6 Mon Sep 17 00:00:00 2001 From: white-rm <704748696@qq.com> Date: Mon, 30 Jun 2025 00:49:18 +0800 Subject: [PATCH 03/33] add sandox utils --- app/daytona/utils/__init__.py | 0 app/daytona/utils/context_manager.py | 298 ++++ app/daytona/utils/response_processor.py | 1890 +++++++++++++++++++++++ app/daytona/utils/thread_manager.py | 787 ++++++++++ app/daytona/utils/tool.py | 240 +++ app/daytona/utils/tool_registry.py | 152 ++ app/daytona/utils/xml_tool_parser.py | 302 ++++ app/tool/sb_browser_tool.py | 10 +- 8 files changed, 3674 insertions(+), 5 deletions(-) create mode 100644 app/daytona/utils/__init__.py create mode 100644 app/daytona/utils/context_manager.py create mode 100644 app/daytona/utils/response_processor.py create mode 100644 app/daytona/utils/thread_manager.py create mode 100644 app/daytona/utils/tool.py create mode 100644 app/daytona/utils/tool_registry.py create mode 100644 app/daytona/utils/xml_tool_parser.py diff --git a/app/daytona/utils/__init__.py b/app/daytona/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/daytona/utils/context_manager.py b/app/daytona/utils/context_manager.py new file mode 100644 index 000000000..11405f40b --- /dev/null +++ b/app/daytona/utils/context_manager.py @@ -0,0 +1,298 @@ +""" +Context Management for AgentPress Threads. + +This module handles token counting and thread summarization to prevent +reaching the context window limitations of LLM models. +""" + +import json +from typing import List, Dict, Any, Optional + +from litellm import token_counter, completion_cost +from services.supabase import DBConnection +from services.llm import make_llm_api_call +from utils.logger import logger + +# Constants for token management +DEFAULT_TOKEN_THRESHOLD = 120000 # 80k tokens threshold for summarization +SUMMARY_TARGET_TOKENS = 10000 # Target ~10k tokens for the summary message +RESERVE_TOKENS = 5000 # Reserve tokens for new messages + +class ContextManager: + """Manages thread context including token counting and summarization.""" + + def __init__(self, token_threshold: int = DEFAULT_TOKEN_THRESHOLD): + """Initialize the ContextManager. + + Args: + token_threshold: Token count threshold to trigger summarization + """ + self.db = DBConnection() + self.token_threshold = token_threshold + + async def get_thread_token_count(self, thread_id: str) -> int: + """Get the current token count for a thread using LiteLLM. + + Args: + thread_id: ID of the thread to analyze + + Returns: + The total token count for relevant messages in the thread + """ + logger.debug(f"Getting token count for thread {thread_id}") + + try: + # Get messages for the thread + messages = await self.get_messages_for_summarization(thread_id) + + if not messages: + logger.debug(f"No messages found for thread {thread_id}") + return 0 + + # Use litellm's token_counter for accurate model-specific counting + # This is much more accurate than the SQL-based estimation + token_count = token_counter(model="gpt-4", messages=messages) + + logger.info(f"Thread {thread_id} has {token_count} tokens (calculated with litellm)") + return token_count + + except Exception as e: + logger.error(f"Error getting token count: {str(e)}") + return 0 + + async def get_messages_for_summarization(self, thread_id: str) -> List[Dict[str, Any]]: + """Get all LLM messages from the thread that need to be summarized. + + This gets messages after the most recent summary or all messages if + no summary exists. Unlike get_llm_messages, this includes ALL messages + since the last summary, even if we're generating a new summary. + + Args: + thread_id: ID of the thread to get messages from + + Returns: + List of message objects to summarize + """ + logger.debug(f"Getting messages for summarization for thread {thread_id}") + client = await self.db.client + + try: + # Find the most recent summary message + summary_result = await client.table('messages').select('created_at') \ + .eq('thread_id', thread_id) \ + .eq('type', 'summary') \ + .eq('is_llm_message', True) \ + .order('created_at', desc=True) \ + .limit(1) \ + .execute() + + # Get messages after the most recent summary or all messages if no summary + if summary_result.data and len(summary_result.data) > 0: + last_summary_time = summary_result.data[0]['created_at'] + logger.debug(f"Found last summary at {last_summary_time}") + + # Get all messages after the summary, but NOT including the summary itself + messages_result = await client.table('messages').select('*') \ + .eq('thread_id', thread_id) \ + .eq('is_llm_message', True) \ + .gt('created_at', last_summary_time) \ + .order('created_at') \ + .execute() + else: + logger.debug("No previous summary found, getting all messages") + # Get all messages + messages_result = await client.table('messages').select('*') \ + .eq('thread_id', thread_id) \ + .eq('is_llm_message', True) \ + .order('created_at') \ + .execute() + + # Parse the message content if needed + messages = [] + for msg in messages_result.data: + # Skip existing summary messages - we don't want to summarize summaries + if msg.get('type') == 'summary': + logger.debug(f"Skipping summary message from {msg.get('created_at')}") + continue + + # Parse content if it's a string + content = msg['content'] + if isinstance(content, str): + try: + content = json.loads(content) + except json.JSONDecodeError: + pass # Keep as string if not valid JSON + + # Ensure we have the proper format for the LLM + if 'role' not in content and 'type' in msg: + # Convert message type to role if needed + role = msg['type'] + if role == 'assistant' or role == 'user' or role == 'system' or role == 'tool': + content = {'role': role, 'content': content} + + messages.append(content) + + logger.info(f"Got {len(messages)} messages to summarize for thread {thread_id}") + return messages + + except Exception as e: + logger.error(f"Error getting messages for summarization: {str(e)}", exc_info=True) + return [] + + async def create_summary( + self, + thread_id: str, + messages: List[Dict[str, Any]], + model: str = "gpt-4o-mini" + ) -> Optional[Dict[str, Any]]: + """Generate a summary of conversation messages. + + Args: + thread_id: ID of the thread to summarize + messages: Messages to summarize + model: LLM model to use for summarization + + Returns: + Summary message object or None if summarization failed + """ + if not messages: + logger.warning("No messages to summarize") + return None + + logger.info(f"Creating summary for thread {thread_id} with {len(messages)} messages") + + # Create system message with summarization instructions + system_message = { + "role": "system", + "content": f"""You are a specialized summarization assistant. Your task is to create a concise but comprehensive summary of the conversation history. + +The summary should: +1. Preserve all key information including decisions, conclusions, and important context +2. Include any tools that were used and their results +3. Maintain chronological order of events +4. Be presented as a narrated list of key points with section headers +5. Include only factual information from the conversation (no new information) +6. Be concise but detailed enough that the conversation can continue with this summary as context + +VERY IMPORTANT: This summary will replace older parts of the conversation in the LLM's context window, so ensure it contains ALL key information and LATEST STATE OF THE CONVERSATION - SO WE WILL KNOW HOW TO PICK UP WHERE WE LEFT OFF. + + +THE CONVERSATION HISTORY TO SUMMARIZE IS AS FOLLOWS: +=============================================================== +==================== CONVERSATION HISTORY ==================== +{messages} +==================== END OF CONVERSATION HISTORY ==================== +=============================================================== +""" + } + + try: + # Call LLM to generate summary + response = await make_llm_api_call( + model_name=model, + messages=[system_message, {"role": "user", "content": "PLEASE PROVIDE THE SUMMARY NOW."}], + temperature=0, + max_tokens=SUMMARY_TARGET_TOKENS, + stream=False + ) + + if response and hasattr(response, 'choices') and response.choices: + summary_content = response.choices[0].message.content + + # Track token usage + try: + token_count = token_counter(model=model, messages=[{"role": "user", "content": summary_content}]) + cost = completion_cost(model=model, prompt="", completion=summary_content) + logger.info(f"Summary generated with {token_count} tokens at cost ${cost:.6f}") + except Exception as e: + logger.error(f"Error calculating token usage: {str(e)}") + + # Format the summary message with clear beginning and end markers + formatted_summary = f""" +======== CONVERSATION HISTORY SUMMARY ======== + +{summary_content} + +======== END OF SUMMARY ======== + +The above is a summary of the conversation history. The conversation continues below. +""" + + # Format the summary message + summary_message = { + "role": "user", + "content": formatted_summary + } + + return summary_message + else: + logger.error("Failed to generate summary: Invalid response") + return None + + except Exception as e: + logger.error(f"Error creating summary: {str(e)}", exc_info=True) + return None + + async def check_and_summarize_if_needed( + self, + thread_id: str, + add_message_callback, + model: str = "gpt-4o-mini", + force: bool = False + ) -> bool: + """Check if thread needs summarization and summarize if so. + + Args: + thread_id: ID of the thread to check + add_message_callback: Callback to add the summary message to the thread + model: LLM model to use for summarization + force: Whether to force summarization regardless of token count + + Returns: + True if summarization was performed, False otherwise + """ + try: + # Get token count using LiteLLM (accurate model-specific counting) + token_count = await self.get_thread_token_count(thread_id) + + # If token count is below threshold and not forcing, no summarization needed + if token_count < self.token_threshold and not force: + logger.debug(f"Thread {thread_id} has {token_count} tokens, below threshold {self.token_threshold}") + return False + + # Log reason for summarization + if force: + logger.info(f"Forced summarization of thread {thread_id} with {token_count} tokens") + else: + logger.info(f"Thread {thread_id} exceeds token threshold ({token_count} >= {self.token_threshold}), summarizing...") + + # Get messages to summarize + messages = await self.get_messages_for_summarization(thread_id) + + # If there are too few messages, don't summarize + if len(messages) < 3: + logger.info(f"Thread {thread_id} has too few messages ({len(messages)}) to summarize") + return False + + # Create summary + summary = await self.create_summary(thread_id, messages, model) + + if summary: + # Add summary message to thread + await add_message_callback( + thread_id=thread_id, + type="summary", + content=summary, + is_llm_message=True, + metadata={"token_count": token_count} + ) + + logger.info(f"Successfully added summary to thread {thread_id}") + return True + else: + logger.error(f"Failed to create summary for thread {thread_id}") + return False + + except Exception as e: + logger.error(f"Error in check_and_summarize_if_needed: {str(e)}", exc_info=True) + return False \ No newline at end of file diff --git a/app/daytona/utils/response_processor.py b/app/daytona/utils/response_processor.py new file mode 100644 index 000000000..68cd6bdc1 --- /dev/null +++ b/app/daytona/utils/response_processor.py @@ -0,0 +1,1890 @@ +""" +Response processing module for AgentPress. + +This module handles the processing of LLM responses, including: +- Streaming and non-streaming response handling +- XML and native tool call detection and parsing +- Tool execution orchestration +- Message formatting and persistence +""" + +import json +import re +import uuid +import asyncio +from datetime import datetime, timezone +from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple, Union, Callable, Literal +from dataclasses import dataclass +from utils.logger import logger +from agentpress.tool import ToolResult +from agentpress.tool_registry import ToolRegistry +from agentpress.xml_tool_parser import XMLToolParser +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"] + +# Type alias for tool execution strategy +ToolExecutionStrategy = Literal["sequential", "parallel"] + +@dataclass +class ToolExecutionContext: + """Context for a tool execution including call details, result, and display info.""" + tool_call: Dict[str, Any] + tool_index: int + result: Optional[ToolResult] = None + function_name: Optional[str] = None + xml_tag_name: Optional[str] = None + error: Optional[Exception] = None + assistant_message_id: Optional[str] = None + parsing_details: Optional[Dict[str, Any]] = None + +@dataclass +class ProcessorConfig: + """ + Configuration for response processing and tool execution. + + This class controls how the LLM's responses are processed, including how tool calls + are detected, executed, and their results handled. + + Attributes: + xml_tool_calling: Enable XML-based tool call detection (...) + native_tool_calling: Enable OpenAI-style function calling format + execute_tools: Whether to automatically execute detected tool calls + execute_on_stream: For streaming, execute tools as they appear vs. at the end + tool_execution_strategy: How to execute multiple tools ("sequential" or "parallel") + xml_adding_strategy: How to add XML tool results to the conversation + max_xml_tool_calls: Maximum number of XML tool calls to process (0 = no limit) + """ + + xml_tool_calling: bool = True + native_tool_calling: bool = False + + execute_tools: bool = True + execute_on_stream: bool = False + tool_execution_strategy: ToolExecutionStrategy = "sequential" + xml_adding_strategy: XmlAddingStrategy = "assistant_message" + max_xml_tool_calls: int = 0 # 0 means no limit + + def __post_init__(self): + """Validate configuration after initialization.""" + if self.xml_tool_calling is False and self.native_tool_calling is False and self.execute_tools: + raise ValueError("At least one tool calling format (XML or native) must be enabled if execute_tools is True") + + if self.xml_adding_strategy not in ["user_message", "assistant_message", "inline_edit"]: + raise ValueError("xml_adding_strategy must be 'user_message', 'assistant_message', or 'inline_edit'") + + if self.max_xml_tool_calls < 0: + raise ValueError("max_xml_tool_calls must be a non-negative integer (0 = no limit)") + +class ResponseProcessor: + """Processes LLM responses, extracting and executing tool calls.""" + + def __init__(self, tool_registry: ToolRegistry, add_message_callback: Callable, trace: Optional[StatefulTraceClient] = None, is_agent_builder: bool = False, target_agent_id: Optional[str] = None, agent_config: Optional[dict] = None): + """Initialize the ResponseProcessor. + + Args: + tool_registry: Registry of available tools + add_message_callback: Callback function to add messages to the thread. + MUST return the full saved message object (dict) or None. + agent_config: Optional agent configuration with version information + """ + self.tool_registry = tool_registry + self.add_message = add_message_callback + self.trace = trace + if not self.trace: + self.trace = langfuse.trace(name="anonymous:response_processor") + # Initialize the XML parser with backwards compatibility + self.xml_parser = XMLToolParser(strict_mode=False) + self.is_agent_builder = is_agent_builder + self.target_agent_id = target_agent_id + self.agent_config = agent_config + + async def _yield_message(self, message_obj: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Helper to yield a message with proper formatting. + + Ensures that content and metadata are JSON strings for client compatibility. + """ + if message_obj: + return format_for_yield(message_obj) + + async def _add_message_with_agent_info( + self, + thread_id: str, + type: str, + content: Union[Dict[str, Any], List[Any], str], + is_llm_message: bool = False, + metadata: Optional[Dict[str, Any]] = None + ): + """Helper to add a message with agent version information if available.""" + agent_id = None + agent_version_id = None + + if self.agent_config: + agent_id = self.agent_config.get('agent_id') + agent_version_id = self.agent_config.get('current_version_id') + + return await self.add_message( + thread_id=thread_id, + type=type, + content=content, + is_llm_message=is_llm_message, + metadata=metadata, + agent_id=agent_id, + agent_version_id=agent_version_id + ) + + async def process_streaming_response( + self, + llm_response: AsyncGenerator, + thread_id: str, + prompt_messages: List[Dict[str, Any]], + llm_model: str, + config: ProcessorConfig = ProcessorConfig(), + ) -> AsyncGenerator[Dict[str, Any], None]: + """Process a streaming LLM response, handling tool calls and execution. + + Args: + llm_response: Streaming response from the LLM + thread_id: ID of the conversation thread + prompt_messages: List of messages sent to the LLM (the prompt) + llm_model: The name of the LLM model used + config: Configuration for parsing and execution + + Yields: + Complete message objects matching the DB schema, except for content chunks. + """ + accumulated_content = "" + tool_calls_buffer = {} + current_xml_content = "" + xml_chunks_buffer = [] + pending_tool_executions = [] + yielded_tool_indices = set() # Stores indices of tools whose *status* has been yielded + tool_index = 0 + xml_tool_call_count = 0 + finish_reason = None + last_assistant_message_object = None # Store the final saved assistant message object + 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}") + + thread_run_id = str(uuid.uuid4()) + + try: + # --- Save and Yield Start Events --- + start_content = {"status_type": "thread_run_start", "thread_run_id": thread_run_id} + start_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=start_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if start_msg_obj: yield format_for_yield(start_msg_obj) + + assist_start_content = {"status_type": "assistant_response_start"} + assist_start_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=assist_start_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if assist_start_msg_obj: yield format_for_yield(assist_start_msg_obj) + # --- End Start Events --- + + __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}") + + if hasattr(chunk, 'choices') and chunk.choices: + delta = chunk.choices[0].delta if hasattr(chunk.choices[0], 'delta') else None + + # Check for and log Anthropic thinking content + if delta and hasattr(delta, 'reasoning_content') and delta.reasoning_content: + if not has_printed_thinking_prefix: + # print("[THINKING]: ", end='', flush=True) + has_printed_thinking_prefix = True + # print(delta.reasoning_content, end='', flush=True) + # Append reasoning to main content to be saved in the final message + accumulated_content += delta.reasoning_content + + # Process content chunk + if delta and hasattr(delta, 'content') and delta.content: + chunk_content = delta.content + # print(chunk_content, end='', flush=True) + accumulated_content += chunk_content + current_xml_content += chunk_content + + if not (config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls): + # Yield ONLY content chunk (don't save) + now_chunk = datetime.now(timezone.utc).isoformat() + yield { + "sequence": __sequence, + "message_id": None, "thread_id": thread_id, "type": "assistant", + "is_llm_message": True, + "content": to_json_string({"role": "assistant", "content": chunk_content}), + "metadata": to_json_string({"stream_status": "chunk", "thread_run_id": thread_run_id}), + "created_at": now_chunk, "updated_at": now_chunk + } + __sequence += 1 + else: + logger.info("XML tool call limit reached - not yielding more content chunks") + self.trace.event(name="xml_tool_call_limit_reached", level="DEFAULT", status_message=(f"XML tool call limit reached - not yielding more content chunks")) + + # --- Process XML Tool Calls (if enabled and limit not reached) --- + if config.xml_tool_calling and not (config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls): + xml_chunks = self._extract_xml_chunks(current_xml_content) + for xml_chunk in xml_chunks: + current_xml_content = current_xml_content.replace(xml_chunk, "", 1) + xml_chunks_buffer.append(xml_chunk) + result = self._parse_xml_tool_call(xml_chunk) + if result: + tool_call, parsing_details = result + xml_tool_call_count += 1 + current_assistant_id = last_assistant_message_object['message_id'] if last_assistant_message_object else None + context = self._create_tool_context( + tool_call, tool_index, current_assistant_id, parsing_details + ) + + if config.execute_tools and config.execute_on_stream: + # Save and Yield tool_started status + started_msg_obj = await self._yield_and_save_tool_started(context, thread_id, thread_run_id) + if started_msg_obj: yield format_for_yield(started_msg_obj) + yielded_tool_indices.add(tool_index) # Mark status as yielded + + execution_task = asyncio.create_task(self._execute_tool(tool_call)) + pending_tool_executions.append({ + "task": execution_task, "tool_call": tool_call, + "tool_index": tool_index, "context": context + }) + tool_index += 1 + + if config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls: + logger.debug(f"Reached XML tool call limit ({config.max_xml_tool_calls})") + finish_reason = "xml_tool_limit_reached" + break # Stop processing more XML chunks in this delta + + # --- Process Native Tool Call Chunks --- + if config.native_tool_calling and delta and hasattr(delta, 'tool_calls') and delta.tool_calls: + for tool_call_chunk in delta.tool_calls: + # Yield Native Tool Call Chunk (transient status, not saved) + # ... (safe extraction logic for tool_call_data_chunk) ... + tool_call_data_chunk = {} # Placeholder for extracted data + if hasattr(tool_call_chunk, 'model_dump'): tool_call_data_chunk = tool_call_chunk.model_dump() + else: # Manual extraction... + if hasattr(tool_call_chunk, 'id'): tool_call_data_chunk['id'] = tool_call_chunk.id + if hasattr(tool_call_chunk, 'index'): tool_call_data_chunk['index'] = tool_call_chunk.index + if hasattr(tool_call_chunk, 'type'): tool_call_data_chunk['type'] = tool_call_chunk.type + if hasattr(tool_call_chunk, 'function'): + tool_call_data_chunk['function'] = {} + if hasattr(tool_call_chunk.function, 'name'): tool_call_data_chunk['function']['name'] = tool_call_chunk.function.name + if hasattr(tool_call_chunk.function, 'arguments'): tool_call_data_chunk['function']['arguments'] = tool_call_chunk.function.arguments if isinstance(tool_call_chunk.function.arguments, str) else to_json_string(tool_call_chunk.function.arguments) + + + now_tool_chunk = datetime.now(timezone.utc).isoformat() + yield { + "message_id": None, "thread_id": thread_id, "type": "status", "is_llm_message": True, + "content": to_json_string({"role": "assistant", "status_type": "tool_call_chunk", "tool_call_chunk": tool_call_data_chunk}), + "metadata": to_json_string({"thread_run_id": thread_run_id}), + "created_at": now_tool_chunk, "updated_at": now_tool_chunk + } + + # --- Buffer and Execute Complete Native Tool Calls --- + if not hasattr(tool_call_chunk, 'function'): continue + idx = tool_call_chunk.index if hasattr(tool_call_chunk, 'index') else 0 + # ... (buffer update logic remains same) ... + # ... (check complete logic remains same) ... + has_complete_tool_call = False # Placeholder + if (tool_calls_buffer.get(idx) and + tool_calls_buffer[idx]['id'] and + tool_calls_buffer[idx]['function']['name'] and + tool_calls_buffer[idx]['function']['arguments']): + try: + safe_json_parse(tool_calls_buffer[idx]['function']['arguments']) + has_complete_tool_call = True + except json.JSONDecodeError: pass + + + if has_complete_tool_call and config.execute_tools and config.execute_on_stream: + current_tool = tool_calls_buffer[idx] + tool_call_data = { + "function_name": current_tool['function']['name'], + "arguments": safe_json_parse(current_tool['function']['arguments']), + "id": current_tool['id'] + } + current_assistant_id = last_assistant_message_object['message_id'] if last_assistant_message_object else None + context = self._create_tool_context( + tool_call_data, tool_index, current_assistant_id + ) + + # Save and Yield tool_started status + started_msg_obj = await self._yield_and_save_tool_started(context, thread_id, thread_run_id) + if started_msg_obj: yield format_for_yield(started_msg_obj) + yielded_tool_indices.add(tool_index) # Mark status as yielded + + execution_task = asyncio.create_task(self._execute_tool(tool_call_data)) + pending_tool_executions.append({ + "task": execution_task, "tool_call": tool_call_data, + "tool_index": tool_index, "context": context + }) + tool_index += 1 + + if finish_reason == "xml_tool_limit_reached": + logger.info("Stopping stream processing after loop due to XML tool call limit") + self.trace.event(name="stopping_stream_processing_after_loop_due_to_xml_tool_call_limit", level="DEFAULT", status_message=(f"Stopping stream processing after loop due to XML tool call limit")) + break + + # 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) + if pending_tool_executions: + logger.info(f"Waiting for {len(pending_tool_executions)} pending streamed tool executions") + self.trace.event(name="waiting_for_pending_streamed_tool_executions", level="DEFAULT", status_message=(f"Waiting for {len(pending_tool_executions)} pending streamed tool executions")) + # ... (asyncio.wait logic) ... + pending_tasks = [execution["task"] for execution in pending_tool_executions] + done, _ = await asyncio.wait(pending_tasks) + + for execution in pending_tool_executions: + tool_idx = execution.get("tool_index", -1) + context = execution["context"] + tool_name = context.function_name + + # Check if status was already yielded during stream run + if tool_idx in yielded_tool_indices: + logger.debug(f"Status for tool index {tool_idx} already yielded.") + # Still need to process the result for the buffer + try: + if execution["task"].done(): + result = execution["task"].result() + context.result = result + tool_results_buffer.append((execution["tool_call"], result, tool_idx, context)) + + if tool_name in ['ask', 'complete']: + logger.info(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.") + self.trace.event(name="terminating_tool_completed_during_streaming", level="DEFAULT", status_message=(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.")) + agent_should_terminate = True + + else: # Should not happen with asyncio.wait + logger.warning(f"Task for tool index {tool_idx} not done after wait.") + self.trace.event(name="task_for_tool_index_not_done_after_wait", level="WARNING", status_message=(f"Task for tool index {tool_idx} not done after wait.")) + except Exception as e: + logger.error(f"Error getting result for pending tool execution {tool_idx}: {str(e)}") + self.trace.event(name="error_getting_result_for_pending_tool_execution", level="ERROR", status_message=(f"Error getting result for pending tool execution {tool_idx}: {str(e)}")) + context.error = e + # Save and Yield tool error status message (even if started was yielded) + error_msg_obj = await self._yield_and_save_tool_error(context, thread_id, thread_run_id) + if error_msg_obj: yield format_for_yield(error_msg_obj) + continue # Skip further status yielding for this tool index + + # If status wasn't yielded before (shouldn't happen with current logic), yield it now + try: + if execution["task"].done(): + result = execution["task"].result() + context.result = result + tool_results_buffer.append((execution["tool_call"], result, tool_idx, context)) + + # Check if this is a terminating tool + if tool_name in ['ask', 'complete']: + logger.info(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.") + self.trace.event(name="terminating_tool_completed_during_streaming", level="DEFAULT", status_message=(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.")) + agent_should_terminate = True + + # Save and Yield tool completed/failed status + completed_msg_obj = await self._yield_and_save_tool_completed( + context, None, thread_id, thread_run_id + ) + if completed_msg_obj: yield format_for_yield(completed_msg_obj) + yielded_tool_indices.add(tool_idx) + except Exception as e: + logger.error(f"Error getting result/yielding status for pending tool execution {tool_idx}: {str(e)}") + self.trace.event(name="error_getting_result_yielding_status_for_pending_tool_execution", level="ERROR", status_message=(f"Error getting result/yielding status for pending tool execution {tool_idx}: {str(e)}")) + context.error = e + # Save and Yield tool error status + error_msg_obj = await self._yield_and_save_tool_error(context, thread_id, thread_run_id) + if error_msg_obj: yield format_for_yield(error_msg_obj) + yielded_tool_indices.add(tool_idx) + + + # Save and yield finish status if limit was reached + if finish_reason == "xml_tool_limit_reached": + finish_content = {"status_type": "finish", "finish_reason": "xml_tool_limit_reached"} + finish_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=finish_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if finish_msg_obj: yield format_for_yield(finish_msg_obj) + logger.info(f"Stream finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls") + self.trace.event(name="stream_finished_with_reason_xml_tool_limit_reached_after_xml_tool_calls", level="DEFAULT", status_message=(f"Stream finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls")) + + # --- SAVE and YIELD Final Assistant Message --- + if accumulated_content: + # ... (Truncate accumulated_content logic) ... + if config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls and xml_chunks_buffer: + last_xml_chunk = xml_chunks_buffer[-1] + last_chunk_end_pos = accumulated_content.find(last_xml_chunk) + len(last_xml_chunk) + if last_chunk_end_pos > 0: + accumulated_content = accumulated_content[:last_chunk_end_pos] + + # ... (Extract complete_native_tool_calls logic) ... + # 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']: + try: + args = safe_json_parse(tc_buf['function']['arguments']) + complete_native_tool_calls.append({ + "id": tc_buf['id'], "type": "function", + "function": {"name": tc_buf['function']['name'],"arguments": args} + }) + except json.JSONDecodeError: continue + + message_data = { # Dict to be saved in 'content' + "role": "assistant", "content": accumulated_content, + "tool_calls": complete_native_tool_calls or None + } + + last_assistant_message_object = await self._add_message_with_agent_info( + thread_id=thread_id, type="assistant", content=message_data, + is_llm_message=True, metadata={"thread_run_id": thread_run_id} + ) + + if last_assistant_message_object: + # Yield the complete saved object, adding stream_status metadata just for yield + yield_metadata = ensure_dict(last_assistant_message_object.get('metadata'), {}) + yield_metadata['stream_status'] = 'complete' + # Format the message for yielding + yield_message = last_assistant_message_object.copy() + yield_message['metadata'] = yield_metadata + yield format_for_yield(yield_message) + else: + logger.error(f"Failed to save final assistant message for thread {thread_id}") + self.trace.event(name="failed_to_save_final_assistant_message_for_thread", level="ERROR", status_message=(f"Failed to save final assistant message for thread {thread_id}")) + # Save and yield an error status + err_content = {"role": "system", "status_type": "error", "message": "Failed to save final assistant message"} + err_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=err_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if err_msg_obj: yield format_for_yield(err_msg_obj) + + # --- Process All Tool Results Now --- + if config.execute_tools: + final_tool_calls_to_process = [] + # ... (Gather final_tool_calls_to_process from native and XML buffers) ... + # Gather native tool calls from buffer + if config.native_tool_calling and complete_native_tool_calls: + for tc in complete_native_tool_calls: + final_tool_calls_to_process.append({ + "function_name": tc["function"]["name"], + "arguments": tc["function"]["arguments"], # Already parsed object + "id": tc["id"] + }) + # Gather XML tool calls from buffer (up to limit) + parsed_xml_data = [] + if config.xml_tool_calling: + # Reparse remaining content just in case (should be empty if processed correctly) + xml_chunks = self._extract_xml_chunks(current_xml_content) + xml_chunks_buffer.extend(xml_chunks) + # Process only chunks not already handled in the stream loop + remaining_limit = config.max_xml_tool_calls - xml_tool_call_count if config.max_xml_tool_calls > 0 else len(xml_chunks_buffer) + xml_chunks_to_process = xml_chunks_buffer[:remaining_limit] # Ensure limit is respected + + for chunk in xml_chunks_to_process: + parsed_result = self._parse_xml_tool_call(chunk) + if parsed_result: + tool_call, parsing_details = parsed_result + # Avoid adding if already processed during streaming + if not any(exec['tool_call'] == tool_call for exec in pending_tool_executions): + final_tool_calls_to_process.append(tool_call) + parsed_xml_data.append({'tool_call': tool_call, 'parsing_details': parsing_details}) + + + all_tool_data_map = {} # tool_index -> {'tool_call': ..., 'parsing_details': ...} + # Add native tool data + native_tool_index = 0 + if config.native_tool_calling and complete_native_tool_calls: + for tc in complete_native_tool_calls: + # Find the corresponding entry in final_tool_calls_to_process if needed + # For now, assume order matches if only native used + exec_tool_call = { + "function_name": tc["function"]["name"], + "arguments": tc["function"]["arguments"], + "id": tc["id"] + } + all_tool_data_map[native_tool_index] = {"tool_call": exec_tool_call, "parsing_details": None} + native_tool_index += 1 + + # Add XML tool data + xml_tool_index_start = native_tool_index + for idx, item in enumerate(parsed_xml_data): + all_tool_data_map[xml_tool_index_start + idx] = item + + + tool_results_map = {} # tool_index -> (tool_call, result, context) + + # Populate from buffer if executed on stream + if config.execute_on_stream and tool_results_buffer: + logger.info(f"Processing {len(tool_results_buffer)} buffered tool results") + self.trace.event(name="processing_buffered_tool_results", level="DEFAULT", status_message=(f"Processing {len(tool_results_buffer)} buffered tool results")) + for tool_call, result, tool_idx, context in tool_results_buffer: + if last_assistant_message_object: context.assistant_message_id = last_assistant_message_object['message_id'] + tool_results_map[tool_idx] = (tool_call, result, context) + + # Or execute now if not streamed + elif final_tool_calls_to_process and not config.execute_on_stream: + logger.info(f"Executing {len(final_tool_calls_to_process)} tools ({config.tool_execution_strategy}) after stream") + self.trace.event(name="executing_tools_after_stream", level="DEFAULT", status_message=(f"Executing {len(final_tool_calls_to_process)} tools ({config.tool_execution_strategy}) after stream")) + results_list = await self._execute_tools(final_tool_calls_to_process, config.tool_execution_strategy) + current_tool_idx = 0 + for tc, res in results_list: + # Map back using all_tool_data_map which has correct indices + if current_tool_idx in all_tool_data_map: + tool_data = all_tool_data_map[current_tool_idx] + context = self._create_tool_context( + tc, current_tool_idx, + last_assistant_message_object['message_id'] if last_assistant_message_object else None, + tool_data.get('parsing_details') + ) + context.result = res + tool_results_map[current_tool_idx] = (tc, res, context) + else: + logger.warning(f"Could not map result for tool index {current_tool_idx}") + self.trace.event(name="could_not_map_result_for_tool_index", level="WARNING", status_message=(f"Could not map result for tool index {current_tool_idx}")) + current_tool_idx += 1 + + # Save and Yield each result message + if tool_results_map: + logger.info(f"Saving and yielding {len(tool_results_map)} final tool result messages") + self.trace.event(name="saving_and_yielding_final_tool_result_messages", level="DEFAULT", status_message=(f"Saving and yielding {len(tool_results_map)} final tool result messages")) + for tool_idx in sorted(tool_results_map.keys()): + tool_call, result, context = tool_results_map[tool_idx] + context.result = result + if not context.assistant_message_id and last_assistant_message_object: + context.assistant_message_id = last_assistant_message_object['message_id'] + + # Yield start status ONLY IF executing non-streamed (already yielded if streamed) + if not config.execute_on_stream and tool_idx not in yielded_tool_indices: + started_msg_obj = await self._yield_and_save_tool_started(context, thread_id, thread_run_id) + if started_msg_obj: yield format_for_yield(started_msg_obj) + yielded_tool_indices.add(tool_idx) # Mark status yielded + + # Save the tool result message to DB + saved_tool_result_object = await self._add_tool_result( # Returns full object or None + thread_id, tool_call, result, config.xml_adding_strategy, + context.assistant_message_id, context.parsing_details + ) + + # Yield completed/failed status (linked to saved result ID if available) + completed_msg_obj = await self._yield_and_save_tool_completed( + context, + saved_tool_result_object['message_id'] if saved_tool_result_object else None, + thread_id, thread_run_id + ) + if completed_msg_obj: yield format_for_yield(completed_msg_obj) + # Don't add to yielded_tool_indices here, completion status is separate yield + + # Yield the saved tool result object + if saved_tool_result_object: + tool_result_message_objects[tool_idx] = saved_tool_result_object + yield format_for_yield(saved_tool_result_object) + else: + logger.error(f"Failed to save tool result for index {tool_idx}, not yielding result message.") + 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? + + # --- Final Finish Status --- + if finish_reason and finish_reason != "xml_tool_limit_reached": + finish_content = {"status_type": "finish", "finish_reason": finish_reason} + finish_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=finish_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if finish_msg_obj: yield format_for_yield(finish_msg_obj) + + # Check if agent should terminate after processing pending tools + if agent_should_terminate: + logger.info("Agent termination requested after executing ask/complete tool. Stopping further processing.") + self.trace.event(name="agent_termination_requested", level="DEFAULT", status_message="Agent termination requested after executing ask/complete tool. Stopping further processing.") + + # Set finish reason to indicate termination + finish_reason = "agent_terminated" + + # Save and yield termination status + finish_content = {"status_type": "finish", "finish_reason": "agent_terminated"} + finish_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=finish_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + 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)}")) + # Save and yield error status message + err_content = {"role": "system", "status_type": "error", "message": str(e)} + err_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=err_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} + ) + if err_msg_obj: yield format_for_yield(err_msg_obj) # Yield the saved error message + + # Re-raise the same exception (not a new one) to ensure proper error propagation + logger.critical(f"Re-raising error to stop further processing: {str(e)}") + self.trace.event(name="re_raising_error_to_stop_further_processing", level="ERROR", status_message=(f"Re-raising error to stop further processing: {str(e)}")) + raise # Use bare 'raise' to preserve the original exception with its traceback + + finally: + # Save and Yield the final thread_run_end status + try: + end_content = {"status_type": "thread_run_end"} + end_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=end_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} + ) + if end_msg_obj: yield format_for_yield(end_msg_obj) + except Exception as final_e: + logger.error(f"Error in finally block: {str(final_e)}", exc_info=True) + self.trace.event(name="error_in_finally_block", level="ERROR", status_message=(f"Error in finally block: {str(final_e)}")) + + async def process_non_streaming_response( + self, + llm_response: Any, + thread_id: str, + prompt_messages: List[Dict[str, Any]], + llm_model: str, + config: ProcessorConfig = ProcessorConfig(), + ) -> AsyncGenerator[Dict[str, Any], None]: + """Process a non-streaming LLM response, handling tool calls and execution. + + Args: + llm_response: Response from the LLM + thread_id: ID of the conversation thread + prompt_messages: List of messages sent to the LLM (the prompt) + llm_model: The name of the LLM model used + config: Configuration for parsing and execution + + Yields: + Complete message objects matching the DB schema. + """ + content = "" + thread_run_id = str(uuid.uuid4()) + all_tool_data = [] # Stores {'tool_call': ..., 'parsing_details': ...} + tool_index = 0 + assistant_message_object = None + tool_result_message_objects = {} + finish_reason = None + native_tool_calls_for_message = [] + + try: + # Save and Yield thread_run_start status message + start_content = {"status_type": "thread_run_start", "thread_run_id": thread_run_id} + start_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=start_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if start_msg_obj: yield format_for_yield(start_msg_obj) + + # Extract finish_reason, content, tool calls + if hasattr(llm_response, 'choices') and llm_response.choices: + if hasattr(llm_response.choices[0], 'finish_reason'): + finish_reason = llm_response.choices[0].finish_reason + logger.info(f"Non-streaming finish_reason: {finish_reason}") + self.trace.event(name="non_streaming_finish_reason", level="DEFAULT", status_message=(f"Non-streaming finish_reason: {finish_reason}")) + response_message = llm_response.choices[0].message if hasattr(llm_response.choices[0], 'message') else None + if response_message: + if hasattr(response_message, 'content') and response_message.content: + content = response_message.content + if config.xml_tool_calling: + parsed_xml_data = self._parse_xml_tool_calls(content) + if config.max_xml_tool_calls > 0 and len(parsed_xml_data) > config.max_xml_tool_calls: + # Truncate content and tool data if limit exceeded + # ... (Truncation logic similar to streaming) ... + if parsed_xml_data: + xml_chunks = self._extract_xml_chunks(content)[:config.max_xml_tool_calls] + if xml_chunks: + last_chunk = xml_chunks[-1] + last_chunk_pos = content.find(last_chunk) + if last_chunk_pos >= 0: content = content[:last_chunk_pos + len(last_chunk)] + parsed_xml_data = parsed_xml_data[:config.max_xml_tool_calls] + finish_reason = "xml_tool_limit_reached" + all_tool_data.extend(parsed_xml_data) + + if config.native_tool_calling and hasattr(response_message, 'tool_calls') and response_message.tool_calls: + for tool_call in response_message.tool_calls: + if hasattr(tool_call, 'function'): + exec_tool_call = { + "function_name": tool_call.function.name, + "arguments": safe_json_parse(tool_call.function.arguments) if isinstance(tool_call.function.arguments, str) else tool_call.function.arguments, + "id": tool_call.id if hasattr(tool_call, 'id') else str(uuid.uuid4()) + } + all_tool_data.append({"tool_call": exec_tool_call, "parsing_details": None}) + native_tool_calls_for_message.append({ + "id": exec_tool_call["id"], "type": "function", + "function": { + "name": tool_call.function.name, + "arguments": tool_call.function.arguments if isinstance(tool_call.function.arguments, str) else to_json_string(tool_call.function.arguments) + } + }) + + + # --- SAVE and YIELD Final Assistant Message --- + message_data = {"role": "assistant", "content": content, "tool_calls": native_tool_calls_for_message or None} + assistant_message_object = await self._add_message_with_agent_info( + thread_id=thread_id, type="assistant", content=message_data, + is_llm_message=True, metadata={"thread_run_id": thread_run_id} + ) + if assistant_message_object: + yield assistant_message_object + else: + logger.error(f"Failed to save non-streaming assistant message for thread {thread_id}") + self.trace.event(name="failed_to_save_non_streaming_assistant_message_for_thread", level="ERROR", status_message=(f"Failed to save non-streaming assistant message for thread {thread_id}")) + err_content = {"role": "system", "status_type": "error", "message": "Failed to save assistant message"} + err_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=err_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if err_msg_obj: yield format_for_yield(err_msg_obj) + + # --- 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}") + self.trace.event(name="executing_tools_with_strategy", level="DEFAULT", status_message=(f"Executing {len(tool_calls_to_execute)} tools with strategy: {config.tool_execution_strategy}")) + tool_results = await self._execute_tools(tool_calls_to_execute, config.tool_execution_strategy) + + for i, (returned_tool_call, result) in enumerate(tool_results): + original_data = all_tool_data[i] + tool_call_from_data = original_data['tool_call'] + parsing_details = original_data['parsing_details'] + current_assistant_id = assistant_message_object['message_id'] if assistant_message_object else None + + context = self._create_tool_context( + tool_call_from_data, tool_index, current_assistant_id, parsing_details + ) + context.result = result + + # Save and Yield start status + started_msg_obj = await self._yield_and_save_tool_started(context, thread_id, thread_run_id) + if started_msg_obj: yield format_for_yield(started_msg_obj) + + # Save tool result + saved_tool_result_object = await self._add_tool_result( + thread_id, tool_call_from_data, result, config.xml_adding_strategy, + current_assistant_id, parsing_details + ) + + # Save and Yield completed/failed status + completed_msg_obj = await self._yield_and_save_tool_completed( + context, + saved_tool_result_object['message_id'] if saved_tool_result_object else None, + thread_id, thread_run_id + ) + if completed_msg_obj: yield format_for_yield(completed_msg_obj) + + # Yield the saved tool result object + if saved_tool_result_object: + tool_result_message_objects[tool_index] = saved_tool_result_object + yield format_for_yield(saved_tool_result_object) + else: + logger.error(f"Failed to save tool result for index {tool_index}") + self.trace.event(name="failed_to_save_tool_result_for_index", level="ERROR", status_message=(f"Failed to save tool result for index {tool_index}")) + + tool_index += 1 + + # --- Save and Yield Final Status --- + if finish_reason: + finish_content = {"status_type": "finish", "finish_reason": finish_reason} + finish_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=finish_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + 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)}")) + # Save and yield error status + err_content = {"role": "system", "status_type": "error", "message": str(e)} + err_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=err_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} + ) + if err_msg_obj: yield format_for_yield(err_msg_obj) + + # Re-raise the same exception (not a new one) to ensure proper error propagation + logger.critical(f"Re-raising error to stop further processing: {str(e)}") + self.trace.event(name="re_raising_error_to_stop_further_processing", level="CRITICAL", status_message=(f"Re-raising error to stop further processing: {str(e)}")) + raise # Use bare 'raise' to preserve the original exception with its traceback + + finally: + # Save and Yield the final thread_run_end status + end_content = {"status_type": "thread_run_end"} + end_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=end_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} + ) + if end_msg_obj: yield format_for_yield(end_msg_obj) + + # XML parsing methods + def _extract_tag_content(self, xml_chunk: str, tag_name: str) -> Tuple[Optional[str], Optional[str]]: + """Extract content between opening and closing tags, handling nested tags.""" + start_tag = f'<{tag_name}' + end_tag = f'' + + try: + # Find start tag position + start_pos = xml_chunk.find(start_tag) + if start_pos == -1: + return None, xml_chunk + + # Find end of opening tag + tag_end = xml_chunk.find('>', start_pos) + if tag_end == -1: + return None, xml_chunk + + # Find matching closing tag + content_start = tag_end + 1 + nesting_level = 1 + pos = content_start + + while nesting_level > 0 and pos < len(xml_chunk): + next_start = xml_chunk.find(start_tag, pos) + next_end = xml_chunk.find(end_tag, pos) + + if next_end == -1: + return None, xml_chunk + + if next_start != -1 and next_start < next_end: + nesting_level += 1 + pos = next_start + len(start_tag) + else: + nesting_level -= 1 + if nesting_level == 0: + content = xml_chunk[content_start:next_end] + remaining = xml_chunk[next_end + len(end_tag):] + return content, remaining + else: + pos = next_end + len(end_tag) + + return None, xml_chunk + + except Exception as e: + logger.error(f"Error extracting tag content: {e}") + self.trace.event(name="error_extracting_tag_content", level="ERROR", status_message=(f"Error extracting tag content: {e}")) + return None, xml_chunk + + def _extract_attribute(self, opening_tag: str, attr_name: str) -> Optional[str]: + """Extract attribute value from opening tag.""" + try: + # Handle both single and double quotes with raw strings + patterns = [ + fr'{attr_name}="([^"]*)"', # Double quotes + fr"{attr_name}='([^']*)'", # Single quotes + fr'{attr_name}=([^\s/>;]+)' # No quotes - fixed escape sequence + ] + + for pattern in patterns: + match = re.search(pattern, opening_tag) + if match: + value = match.group(1) + # Unescape common XML entities + value = value.replace('"', '"').replace(''', "'") + value = value.replace('<', '<').replace('>', '>') + value = value.replace('&', '&') + return value + + return None + + except Exception as e: + logger.error(f"Error extracting attribute: {e}") + self.trace.event(name="error_extracting_attribute", level="ERROR", status_message=(f"Error extracting attribute: {e}")) + return None + + def _extract_xml_chunks(self, content: str) -> List[str]: + """Extract complete XML chunks using start and end pattern matching.""" + chunks = [] + pos = 0 + + try: + # First, look for new format blocks + start_pattern = '' + end_pattern = '' + + while pos < len(content): + # Find the next function_calls block + start_pos = content.find(start_pattern, pos) + if start_pos == -1: + break + + # Find the matching end tag + end_pos = content.find(end_pattern, start_pos) + if end_pos == -1: + break + + # Extract the complete block including tags + chunk_end = end_pos + len(end_pattern) + chunk = content[start_pos:chunk_end] + chunks.append(chunk) + + # Move position past this chunk + pos = chunk_end + + # If no new format found, fall back to old format for backwards compatibility + if not chunks: + pos = 0 + while pos < len(content): + # Find the next tool tag + next_tag_start = -1 + current_tag = None + + # Find the earliest occurrence of any registered tag + for tag_name in self.tool_registry.xml_tools.keys(): + start_pattern = f'<{tag_name}' + tag_pos = content.find(start_pattern, pos) + + if tag_pos != -1 and (next_tag_start == -1 or tag_pos < next_tag_start): + next_tag_start = tag_pos + current_tag = tag_name + + if next_tag_start == -1 or not current_tag: + break + + # Find the matching end tag + end_pattern = f'' + tag_stack = [] + chunk_start = next_tag_start + current_pos = next_tag_start + + while current_pos < len(content): + # Look for next start or end tag of the same type + next_start = content.find(f'<{current_tag}', current_pos + 1) + next_end = content.find(end_pattern, current_pos) + + if next_end == -1: # No closing tag found + break + + if next_start != -1 and next_start < next_end: + # Found nested start tag + tag_stack.append(next_start) + current_pos = next_start + 1 + else: + # Found end tag + if not tag_stack: # This is our matching end tag + chunk_end = next_end + len(end_pattern) + chunk = content[chunk_start:chunk_end] + chunks.append(chunk) + pos = chunk_end + break + else: + # Pop nested tag + tag_stack.pop() + current_pos = next_end + 1 + + if current_pos >= len(content): # Reached end without finding closing tag + break + + pos = max(pos + 1, current_pos) + + except Exception as e: + logger.error(f"Error extracting XML chunks: {e}") + logger.error(f"Content was: {content}") + self.trace.event(name="error_extracting_xml_chunks", level="ERROR", status_message=(f"Error extracting XML chunks: {e}"), metadata={"content": content}) + + return chunks + + def _parse_xml_tool_call(self, xml_chunk: str) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]: + """Parse XML chunk into tool call format and return parsing details. + + Returns: + Tuple of (tool_call, parsing_details) or None if parsing fails. + - tool_call: Dict with 'function_name', 'xml_tag_name', 'arguments' + - parsing_details: Dict with 'attributes', 'elements', 'text_content', 'root_content' + """ + try: + # Check if this is the new format (contains ) + if '' in xml_chunk and ']+)', xml_chunk) + if not tag_match: + logger.error(f"No tag found in XML chunk: {xml_chunk}") + self.trace.event(name="no_tag_found_in_xml_chunk", level="ERROR", status_message=(f"No tag found in XML chunk: {xml_chunk}")) + return None + + # This is the XML tag as it appears in the text (e.g., "create-file") + xml_tag_name = tag_match.group(1) + logger.info(f"Found XML tag: {xml_tag_name}") + self.trace.event(name="found_xml_tag", level="DEFAULT", status_message=(f"Found XML tag: {xml_tag_name}")) + + # Get tool info and schema from registry + tool_info = self.tool_registry.get_xml_tool(xml_tag_name) + if not tool_info or not tool_info['schema'].xml_schema: + logger.error(f"No tool or schema found for tag: {xml_tag_name}") + self.trace.event(name="no_tool_or_schema_found_for_tag", level="ERROR", status_message=(f"No tool or schema found for tag: {xml_tag_name}")) + return None + + # This is the actual function name to call (e.g., "create_file") + function_name = tool_info['method'] + + schema = tool_info['schema'].xml_schema + params = {} + remaining_chunk = xml_chunk + + # --- Store detailed parsing info --- + parsing_details = { + "attributes": {}, + "elements": {}, + "text_content": None, + "root_content": None, + "raw_chunk": xml_chunk # Store the original chunk for reference + } + # --- + + # Process each mapping + for mapping in schema.mappings: + try: + if mapping.node_type == "attribute": + # Extract attribute from opening tag + opening_tag = remaining_chunk.split('>', 1)[0] + value = self._extract_attribute(opening_tag, mapping.param_name) + if value is not None: + params[mapping.param_name] = value + parsing_details["attributes"][mapping.param_name] = value # Store raw attribute + # logger.info(f"Found attribute {mapping.param_name}: {value}") + + elif mapping.node_type == "element": + # Extract element content + content, remaining_chunk = self._extract_tag_content(remaining_chunk, mapping.path) + if content is not None: + params[mapping.param_name] = content.strip() + parsing_details["elements"][mapping.param_name] = content.strip() # Store raw element content + # logger.info(f"Found element {mapping.param_name}: {content.strip()}") + + elif mapping.node_type == "text": + # Extract text content + content, _ = self._extract_tag_content(remaining_chunk, xml_tag_name) + if content is not None: + params[mapping.param_name] = content.strip() + parsing_details["text_content"] = content.strip() # Store raw text content + # logger.info(f"Found text content for {mapping.param_name}: {content.strip()}") + + elif mapping.node_type == "content": + # Extract root content + content, _ = self._extract_tag_content(remaining_chunk, xml_tag_name) + if content is not None: + params[mapping.param_name] = content.strip() + parsing_details["root_content"] = content.strip() # Store raw root content + # logger.info(f"Found root content for {mapping.param_name}") + + except Exception as e: + logger.error(f"Error processing mapping {mapping}: {e}") + self.trace.event(name="error_processing_mapping", level="ERROR", status_message=(f"Error processing mapping {mapping}: {e}")) + continue + + # Create tool call with clear separation between function_name and xml_tag_name + tool_call = { + "function_name": function_name, # The actual method to call (e.g., create_file) + "xml_tag_name": xml_tag_name, # The original XML tag (e.g., create-file) + "arguments": params # The extracted parameters + } + + # logger.debug(f"Parsed old format tool call: {tool_call["function_name"]}") + return tool_call, parsing_details # Return both dicts + + except Exception as e: + logger.error(f"Error parsing XML chunk: {e}") + logger.error(f"XML chunk was: {xml_chunk}") + self.trace.event(name="error_parsing_xml_chunk", level="ERROR", status_message=(f"Error parsing XML chunk: {e}"), metadata={"xml_chunk": xml_chunk}) + return None + + def _parse_xml_tool_calls(self, content: str) -> List[Dict[str, Any]]: + """Parse XML tool calls from content string. + + Returns: + List of dictionaries, each containing {'tool_call': ..., 'parsing_details': ...} + """ + parsed_data = [] + + try: + xml_chunks = self._extract_xml_chunks(content) + + for xml_chunk in xml_chunks: + result = self._parse_xml_tool_call(xml_chunk) + if result: + tool_call, parsing_details = result + parsed_data.append({ + "tool_call": tool_call, + "parsing_details": parsing_details + }) + + except Exception as e: + logger.error(f"Error parsing XML tool calls: {e}", exc_info=True) + self.trace.event(name="error_parsing_xml_tool_calls", level="ERROR", status_message=(f"Error parsing XML tool calls: {e}"), metadata={"content": content}) + + return parsed_data + + # Tool execution methods + async def _execute_tool(self, tool_call: Dict[str, Any]) -> ToolResult: + """Execute a single tool call and return the result.""" + span = self.trace.span(name=f"execute_tool.{tool_call['function_name']}", input=tool_call["arguments"]) + try: + function_name = tool_call["function_name"] + arguments = tool_call["arguments"] + + logger.info(f"Executing tool: {function_name} with arguments: {arguments}") + self.trace.event(name="executing_tool", level="DEFAULT", status_message=(f"Executing tool: {function_name} with arguments: {arguments}")) + + if isinstance(arguments, str): + try: + arguments = safe_json_parse(arguments) + except json.JSONDecodeError: + arguments = {"text": arguments} + + # Get available functions from tool registry + available_functions = self.tool_registry.get_available_functions() + + # Look up the function by name + tool_fn = available_functions.get(function_name) + if not tool_fn: + logger.error(f"Tool function '{function_name}' not found in registry") + span.end(status_message="tool_not_found", level="ERROR") + return ToolResult(success=False, output=f"Tool function '{function_name}' not found") + + logger.debug(f"Found tool function for '{function_name}', executing...") + result = await tool_fn(**arguments) + logger.info(f"Tool execution complete: {function_name} -> {result}") + span.end(status_message="tool_executed", output=result) + return result + except Exception as e: + logger.error(f"Error executing tool {tool_call['function_name']}: {str(e)}", exc_info=True) + span.end(status_message="tool_execution_error", output=f"Error executing tool: {str(e)}", level="ERROR") + return ToolResult(success=False, output=f"Error executing tool: {str(e)}") + + async def _execute_tools( + self, + tool_calls: List[Dict[str, Any]], + execution_strategy: ToolExecutionStrategy = "sequential" + ) -> List[Tuple[Dict[str, Any], ToolResult]]: + """Execute tool calls with the specified strategy. + + This is the main entry point for tool execution. It dispatches to the appropriate + execution method based on the provided strategy. + + Args: + tool_calls: List of tool calls to execute + execution_strategy: Strategy for executing tools: + - "sequential": Execute tools one after another, waiting for each to complete + - "parallel": Execute all tools simultaneously for better performance + + Returns: + List of tuples containing the original tool call and its result + """ + logger.info(f"Executing {len(tool_calls)} tools with strategy: {execution_strategy}") + self.trace.event(name="executing_tools_with_strategy", level="DEFAULT", status_message=(f"Executing {len(tool_calls)} tools with strategy: {execution_strategy}")) + + if execution_strategy == "sequential": + return await self._execute_tools_sequentially(tool_calls) + elif execution_strategy == "parallel": + return await self._execute_tools_in_parallel(tool_calls) + else: + logger.warning(f"Unknown execution strategy: {execution_strategy}, falling back to sequential") + return await self._execute_tools_sequentially(tool_calls) + + async def _execute_tools_sequentially(self, tool_calls: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], ToolResult]]: + """Execute tool calls sequentially and return results. + + This method executes tool calls one after another, waiting for each tool to complete + before starting the next one. This is useful when tools have dependencies on each other. + + Args: + tool_calls: List of tool calls to execute + + Returns: + List of tuples containing the original tool call and its result + """ + if not tool_calls: + return [] + + try: + tool_names = [t.get('function_name', 'unknown') for t in tool_calls] + logger.info(f"Executing {len(tool_calls)} tools sequentially: {tool_names}") + self.trace.event(name="executing_tools_sequentially", level="DEFAULT", status_message=(f"Executing {len(tool_calls)} tools sequentially: {tool_names}")) + + results = [] + for index, tool_call in enumerate(tool_calls): + tool_name = tool_call.get('function_name', 'unknown') + logger.debug(f"Executing tool {index+1}/{len(tool_calls)}: {tool_name}") + + try: + result = await self._execute_tool(tool_call) + results.append((tool_call, result)) + logger.debug(f"Completed tool {tool_name} with success={result.success}") + + # Check if this is a terminating tool (ask or complete) + if tool_name in ['ask', 'complete']: + logger.info(f"Terminating tool '{tool_name}' executed. Stopping further tool execution.") + self.trace.event(name="terminating_tool_executed", level="DEFAULT", status_message=(f"Terminating tool '{tool_name}' executed. Stopping further tool execution.")) + break # Stop executing remaining tools + + except Exception as e: + logger.error(f"Error executing tool {tool_name}: {str(e)}") + self.trace.event(name="error_executing_tool", level="ERROR", status_message=(f"Error executing tool {tool_name}: {str(e)}")) + error_result = ToolResult(success=False, output=f"Error executing tool: {str(e)}") + results.append((tool_call, error_result)) + + logger.info(f"Sequential execution completed for {len(results)} tools (out of {len(tool_calls)} total)") + self.trace.event(name="sequential_execution_completed", level="DEFAULT", status_message=(f"Sequential execution completed for {len(results)} tools (out of {len(tool_calls)} total)")) + return results + + except Exception as e: + logger.error(f"Error in sequential tool execution: {str(e)}", exc_info=True) + # Return partial results plus error results for remaining tools + completed_tool_names = [r[0].get('function_name', 'unknown') for r in results] if 'results' in locals() else [] + remaining_tools = [t for t in tool_calls if t.get('function_name', 'unknown') not in completed_tool_names] + + # Add error results for remaining tools + error_results = [(tool, ToolResult(success=False, output=f"Execution error: {str(e)}")) + for tool in remaining_tools] + + return (results if 'results' in locals() else []) + error_results + + async def _execute_tools_in_parallel(self, tool_calls: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], ToolResult]]: + """Execute tool calls in parallel and return results. + + This method executes all tool calls simultaneously using asyncio.gather, which + can significantly improve performance when executing multiple independent tools. + + Args: + tool_calls: List of tool calls to execute + + Returns: + List of tuples containing the original tool call and its result + """ + if not tool_calls: + return [] + + try: + tool_names = [t.get('function_name', 'unknown') for t in tool_calls] + logger.info(f"Executing {len(tool_calls)} tools in parallel: {tool_names}") + self.trace.event(name="executing_tools_in_parallel", level="DEFAULT", status_message=(f"Executing {len(tool_calls)} tools in parallel: {tool_names}")) + + # Create tasks for all tool calls + tasks = [self._execute_tool(tool_call) for tool_call in tool_calls] + + # Execute all tasks concurrently with error handling + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Process results and handle any exceptions + processed_results = [] + for i, (tool_call, result) in enumerate(zip(tool_calls, results)): + if isinstance(result, Exception): + logger.error(f"Error executing tool {tool_call.get('function_name', 'unknown')}: {str(result)}") + self.trace.event(name="error_executing_tool", level="ERROR", status_message=(f"Error executing tool {tool_call.get('function_name', 'unknown')}: {str(result)}")) + # Create error result + error_result = ToolResult(success=False, output=f"Error executing tool: {str(result)}") + processed_results.append((tool_call, error_result)) + else: + processed_results.append((tool_call, result)) + + logger.info(f"Parallel execution completed for {len(tool_calls)} tools") + self.trace.event(name="parallel_execution_completed", level="DEFAULT", status_message=(f"Parallel execution completed for {len(tool_calls)} tools")) + return processed_results + + except Exception as e: + logger.error(f"Error in parallel tool execution: {str(e)}", exc_info=True) + self.trace.event(name="error_in_parallel_tool_execution", level="ERROR", status_message=(f"Error in parallel tool execution: {str(e)}")) + # Return error results for all tools if the gather itself fails + return [(tool_call, ToolResult(success=False, output=f"Execution error: {str(e)}")) + for tool_call in tool_calls] + + async def _add_tool_result( + self, + thread_id: str, + tool_call: Dict[str, Any], + result: ToolResult, + strategy: Union[XmlAddingStrategy, str] = "assistant_message", + assistant_message_id: Optional[str] = None, + parsing_details: Optional[Dict[str, Any]] = None + ) -> Optional[Dict[str, Any]]: # Return the full message object + """Add a tool result to the conversation thread based on the specified format. + + This method formats tool results and adds them to the conversation history, + making them visible to the LLM in subsequent interactions. Results can be + added either as native tool messages (OpenAI format) or as XML-wrapped content + with a specified role (user or assistant). + + Args: + thread_id: ID of the conversation thread + tool_call: The original tool call that produced this result + result: The result from the tool execution + strategy: How to add XML tool results to the conversation + ("user_message", "assistant_message", or "inline_edit") + assistant_message_id: ID of the assistant message that generated this tool call + parsing_details: Detailed parsing info for XML calls (attributes, elements, etc.) + """ + try: + message_obj = None # Initialize message_obj + + # Create metadata with assistant_message_id if provided + metadata = {} + if assistant_message_id: + metadata["assistant_message_id"] = assistant_message_id + logger.info(f"Linking tool result to assistant message: {assistant_message_id}") + self.trace.event(name="linking_tool_result_to_assistant_message", level="DEFAULT", status_message=(f"Linking tool result to assistant message: {assistant_message_id}")) + + # --- Add parsing details to metadata if available --- + if parsing_details: + metadata["parsing_details"] = parsing_details + logger.info("Adding parsing_details to tool result metadata") + self.trace.event(name="adding_parsing_details_to_tool_result_metadata", level="DEFAULT", status_message=(f"Adding parsing_details to tool result metadata"), metadata={"parsing_details": parsing_details}) + # --- + + # Check if this is a native function call (has id field) + if "id" in tool_call: + # Format as a proper tool message according to OpenAI spec + function_name = tool_call.get("function_name", "") + + # Format the tool result content - tool role needs string content + if isinstance(result, str): + content = result + elif hasattr(result, 'output'): + # If it's a ToolResult object + if isinstance(result.output, dict) or isinstance(result.output, list): + # If output is already a dict or list, convert to JSON string + content = json.dumps(result.output) + else: + # Otherwise just use the string representation + content = str(result.output) + else: + # Fallback to string representation of the whole result + content = str(result) + + logger.info(f"Formatted tool result content: {content[:100]}...") + self.trace.event(name="formatted_tool_result_content", level="DEFAULT", status_message=(f"Formatted tool result content: {content[:100]}...")) + + # Create the tool response message with proper format + tool_message = { + "role": "tool", + "tool_call_id": tool_call["id"], + "name": function_name, + "content": content + } + + logger.info(f"Adding native tool result for tool_call_id={tool_call['id']} with role=tool") + self.trace.event(name="adding_native_tool_result_for_tool_call_id", level="DEFAULT", status_message=(f"Adding native tool result for tool_call_id={tool_call['id']} with role=tool")) + + # Add as a tool message to the conversation history + # This makes the result visible to the LLM in the next turn + message_obj = await self.add_message( + thread_id=thread_id, + type="tool", # Special type for tool responses + content=tool_message, + is_llm_message=True, + metadata=metadata + ) + return message_obj # Return the full message object + + # Check if this is an MCP tool (function_name starts with "call_mcp_tool") + function_name = tool_call.get("function_name", "") + + # Check if this is an MCP tool - either the old call_mcp_tool or a dynamically registered MCP tool + is_mcp_tool = False + if function_name == "call_mcp_tool": + is_mcp_tool = True + else: + # Check if the result indicates it's an MCP tool by looking for MCP metadata + if hasattr(result, 'output') and isinstance(result.output, str): + # Check for MCP metadata pattern in the output + if "MCP Tool Result from" in result.output and "Tool Metadata:" in result.output: + is_mcp_tool = True + # Also check for MCP metadata in JSON format + elif "mcp_metadata" in result.output: + is_mcp_tool = True + + if is_mcp_tool: + # Special handling for MCP tools - make content prominent and LLM-friendly + result_role = "user" if strategy == "user_message" else "assistant" + + # Extract the actual content from the ToolResult + if hasattr(result, 'output'): + mcp_content = str(result.output) + else: + mcp_content = str(result) + + # Create a simple, LLM-friendly message format that puts content first + simple_message = { + "role": result_role, + "content": mcp_content # Direct content, no complex nesting + } + + logger.info(f"Adding MCP tool result with simplified format for LLM visibility") + self.trace.event(name="adding_mcp_tool_result_simplified", level="DEFAULT", status_message="Adding MCP tool result with simplified format for LLM visibility") + + message_obj = await self.add_message( + thread_id=thread_id, + type="tool", + content=simple_message, + is_llm_message=True, + metadata=metadata + ) + return message_obj + + # For XML and other non-native tools, use the new structured format + # Determine message role based on strategy + result_role = "user" if strategy == "user_message" else "assistant" + + # Create the new structured tool result format + structured_result = self._create_structured_tool_result(tool_call, result, parsing_details) + + # Add the message with the appropriate role to the conversation history + # This allows the LLM to see the tool result in subsequent interactions + result_message = { + "role": result_role, + "content": json.dumps(structured_result) + } + message_obj = await self.add_message( + thread_id=thread_id, + type="tool", + content=result_message, + is_llm_message=True, + metadata=metadata + ) + return message_obj # Return the full message object + except Exception as e: + logger.error(f"Error adding tool result: {str(e)}", exc_info=True) + self.trace.event(name="error_adding_tool_result", level="ERROR", status_message=(f"Error adding tool result: {str(e)}"), metadata={"tool_call": tool_call, "result": result, "strategy": strategy, "assistant_message_id": assistant_message_id, "parsing_details": parsing_details}) + # Fallback to a simple message + try: + fallback_message = { + "role": "user", + "content": str(result) + } + message_obj = await self.add_message( + thread_id=thread_id, + type="tool", + content=fallback_message, + is_llm_message=True, + metadata={"assistant_message_id": assistant_message_id} if assistant_message_id else {} + ) + return message_obj # Return the full message object + except Exception as e2: + logger.error(f"Failed even with fallback message: {str(e2)}", exc_info=True) + self.trace.event(name="failed_even_with_fallback_message", level="ERROR", status_message=(f"Failed even with fallback message: {str(e2)}"), metadata={"tool_call": tool_call, "result": result, "strategy": strategy, "assistant_message_id": assistant_message_id, "parsing_details": parsing_details}) + return None # Return None on error + + def _create_structured_tool_result(self, tool_call: Dict[str, Any], result: ToolResult, parsing_details: Optional[Dict[str, Any]] = None): + """Create a structured tool result format that's tool-agnostic and provides rich information. + + Args: + tool_call: The original tool call that was executed + result: The result from the tool execution + parsing_details: Optional parsing details for XML calls + + Returns: + Structured dictionary containing tool execution information + """ + # Extract tool information + function_name = tool_call.get("function_name", "unknown") + xml_tag_name = tool_call.get("xml_tag_name") + arguments = tool_call.get("arguments", {}) + tool_call_id = tool_call.get("id") + logger.info(f"Creating structured tool result for tool_call: {tool_call}") + + # Process the output - if it's a JSON string, parse it back to an object + output = result.output if hasattr(result, 'output') else str(result) + if isinstance(output, str): + try: + # Try to parse as JSON to provide structured data to frontend + parsed_output = safe_json_parse(output) + # If parsing succeeded and we got a dict/list, use the parsed version + if isinstance(parsed_output, (dict, list)): + output = parsed_output + # Otherwise keep the original string + except Exception: + # If parsing fails, keep the original string + pass + + # Create the structured result + structured_result_v1 = { + "tool_execution": { + "function_name": function_name, + "xml_tag_name": xml_tag_name, + "tool_call_id": tool_call_id, + "arguments": arguments, + "result": { + "success": result.success if hasattr(result, 'success') else True, + "output": output, # Now properly structured for frontend + "error": getattr(result, 'error', None) if hasattr(result, 'error') else None + }, + # "execution_details": { + # "timestamp": datetime.now(timezone.utc).isoformat(), + # "parsing_details": parsing_details + # } + } + } + + # STRUCTURED_OUTPUT_TOOLS = { + # "str_replace", + # "get_data_provider_endpoints", + # } + + # summary_output = result.output if hasattr(result, 'output') else str(result) + + # if xml_tag_name: + # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" + # summary = f"Tool '{xml_tag_name}' {status}. Output: {summary_output}" + # else: + # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" + # summary = f"Function '{function_name}' {status}. Output: {summary_output}" + + # if self.is_agent_builder: + # return summary + # if function_name in STRUCTURED_OUTPUT_TOOLS: + # return structured_result_v1 + # else: + # return summary + + summary_output = result.output if hasattr(result, 'output') else str(result) + success_status = structured_result_v1["tool_execution"]["result"]["success"] + + # # Create a more comprehensive summary for the LLM + # if xml_tag_name: + # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" + # summary = f"Tool '{xml_tag_name}' {status}. Output: {summary_output}" + # else: + # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" + # summary = f"Function '{function_name}' {status}. Output: {summary_output}" + + # if self.is_agent_builder: + # return summary + # elif function_name == "get_data_provider_endpoints": + # logger.info(f"Returning sumnary for data provider call: {summary}") + # return summary + + return structured_result_v1 + + def _format_xml_tool_result(self, tool_call: Dict[str, Any], result: ToolResult) -> str: + """Format a tool result wrapped in a tag. + + DEPRECATED: This method is kept for backwards compatibility. + New implementations should use _create_structured_tool_result instead. + + Args: + tool_call: The tool call that was executed + result: The result of the tool execution + + Returns: + String containing the formatted result wrapped in tag + """ + # Always use xml_tag_name if it exists + if "xml_tag_name" in tool_call: + xml_tag_name = tool_call["xml_tag_name"] + return f" <{xml_tag_name}> {str(result)} " + + # Non-XML tool, just return the function result + function_name = tool_call["function_name"] + return f"Result for {function_name}: {str(result)}" + + def _create_tool_context(self, tool_call: Dict[str, Any], tool_index: int, assistant_message_id: Optional[str] = None, parsing_details: Optional[Dict[str, Any]] = None) -> ToolExecutionContext: + """Create a tool execution context with display name and parsing details populated.""" + context = ToolExecutionContext( + tool_call=tool_call, + tool_index=tool_index, + assistant_message_id=assistant_message_id, + parsing_details=parsing_details + ) + + # Set function_name and xml_tag_name fields + if "xml_tag_name" in tool_call: + context.xml_tag_name = tool_call["xml_tag_name"] + context.function_name = tool_call.get("function_name", tool_call["xml_tag_name"]) + else: + # For non-XML tools, use function name directly + context.function_name = tool_call.get("function_name", "unknown") + context.xml_tag_name = None + + return context + + async def _yield_and_save_tool_started(self, context: ToolExecutionContext, thread_id: str, thread_run_id: str) -> Optional[Dict[str, Any]]: + """Formats, saves, and returns a tool started status message.""" + tool_name = context.xml_tag_name or context.function_name + content = { + "role": "assistant", "status_type": "tool_started", + "function_name": context.function_name, "xml_tag_name": context.xml_tag_name, + "message": f"Starting execution of {tool_name}", "tool_index": context.tool_index, + "tool_call_id": context.tool_call.get("id") # Include tool_call ID if native + } + metadata = {"thread_run_id": thread_run_id} + 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 the full object (or None if saving failed) + + async def _yield_and_save_tool_completed(self, context: ToolExecutionContext, tool_message_id: Optional[str], thread_id: str, thread_run_id: str) -> Optional[Dict[str, Any]]: + """Formats, saves, and returns a tool completed/failed status message.""" + if not context.result: + # Delegate to error saving if result is missing (e.g., execution failed) + return await self._yield_and_save_tool_error(context, thread_id, thread_run_id) + + tool_name = context.xml_tag_name or context.function_name + status_type = "tool_completed" if context.result.success else "tool_failed" + message_text = f"Tool {tool_name} {'completed successfully' if context.result.success else 'failed'}" + + content = { + "role": "assistant", "status_type": status_type, + "function_name": context.function_name, "xml_tag_name": context.xml_tag_name, + "message": message_text, "tool_index": context.tool_index, + "tool_call_id": context.tool_call.get("id") + } + metadata = {"thread_run_id": thread_run_id} + # Add the *actual* tool result message ID to the metadata if available and successful + if context.result.success and tool_message_id: + metadata["linked_tool_result_message_id"] = tool_message_id + + # <<< ADDED: Signal if this is a terminating tool >>> + if context.function_name in ['ask', 'complete']: + metadata["agent_should_terminate"] = True + logger.info(f"Marking tool status for '{context.function_name}' with termination signal.") + self.trace.event(name="marking_tool_status_for_termination", level="DEFAULT", status_message=(f"Marking tool status for '{context.function_name}' with termination signal.")) + # <<< END ADDED >>> + + 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 + + async def _yield_and_save_tool_error(self, context: ToolExecutionContext, thread_id: str, thread_run_id: str) -> Optional[Dict[str, Any]]: + """Formats, saves, and returns a tool error status message.""" + error_msg = str(context.error) if context.error else "Unknown error during tool execution" + tool_name = context.xml_tag_name or context.function_name + content = { + "role": "assistant", "status_type": "tool_error", + "function_name": context.function_name, "xml_tag_name": context.xml_tag_name, + "message": f"Error executing tool {tool_name}: {error_msg}", + "tool_index": context.tool_index, + "tool_call_id": context.tool_call.get("id") + } + metadata = {"thread_run_id": thread_run_id} + # Save the status message with is_llm_message=False + 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 diff --git a/app/daytona/utils/thread_manager.py b/app/daytona/utils/thread_manager.py new file mode 100644 index 000000000..2eb963af6 --- /dev/null +++ b/app/daytona/utils/thread_manager.py @@ -0,0 +1,787 @@ +""" +Conversation thread management system for AgentPress. + +This module provides comprehensive conversation management, including: +- Thread creation and persistence +- Message handling with support for text and images +- Tool registration and execution +- LLM interaction with streaming support +- Error handling and cleanup +- Context summarization to manage token limits +""" + +import json +from typing import List, Dict, Any, Optional, Type, Union, AsyncGenerator, Literal +from services.llm import make_llm_api_call +from agentpress.tool import Tool +from agentpress.tool_registry import ToolRegistry +from agentpress.context_manager import ContextManager +from agentpress.response_processor import ( + ResponseProcessor, + ProcessorConfig +) +from services.supabase import DBConnection +from utils.logger import logger +from langfuse.client import StatefulGenerationClient, StatefulTraceClient +from services.langfuse import langfuse +import datetime +from litellm import token_counter + +# Type alias for tool choice +ToolChoice = Literal["auto", "required", "none"] + +class ThreadManager: + """Manages conversation threads with LLM models and tool execution. + + Provides comprehensive conversation management, handling message threading, + tool registration, and LLM interactions with support for both standard and + XML-based tool execution patterns. + """ + + def __init__(self, trace: Optional[StatefulTraceClient] = None, is_agent_builder: bool = False, target_agent_id: Optional[str] = None, agent_config: Optional[dict] = None): + """Initialize ThreadManager. + + Args: + trace: Optional trace client for logging + is_agent_builder: Whether this is an agent builder session + target_agent_id: ID of the agent being built (if in agent builder mode) + agent_config: Optional agent configuration with version information + """ + self.db = DBConnection() + self.tool_registry = ToolRegistry() + self.trace = trace + self.is_agent_builder = is_agent_builder + self.target_agent_id = target_agent_id + self.agent_config = agent_config + if not self.trace: + self.trace = langfuse.trace(name="anonymous:thread_manager") + self.response_processor = ResponseProcessor( + tool_registry=self.tool_registry, + add_message_callback=self.add_message, + trace=self.trace, + is_agent_builder=self.is_agent_builder, + target_agent_id=self.target_agent_id, + agent_config=self.agent_config + ) + 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, dict) and "interactive_elements" 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 + if isinstance(parsed_content, dict) and "interactive_elements" in content: return True + except (json.JSONDecodeError, TypeError): + pass + return False + + def _compress_message(self, msg_content: Union[str, dict], message_id: Optional[str] = None, max_length: int = 3000) -> Union[str, dict]: + """Compress the message content.""" + # print("max_length", max_length) + if isinstance(msg_content, str): + if len(msg_content) > max_length: + return msg_content[:max_length] + "... (truncated)" + f"\n\nmessage_id \"{message_id}\"\nUse expand-message tool to see contents" + else: + return msg_content + elif isinstance(msg_content, dict): + if len(json.dumps(msg_content)) > max_length: + return json.dumps(msg_content)[:max_length] + "... (truncated)" + f"\n\nmessage_id \"{message_id}\"\nUse expand-message tool to see contents" + else: + return msg_content + + def _safe_truncate(self, msg_content: Union[str, dict], max_length: int = 100000) -> Union[str, dict]: + """Truncate the message content safely by removing the middle portion.""" + max_length = min(max_length, 100000) + if isinstance(msg_content, str): + if len(msg_content) > max_length: + # Calculate how much to keep from start and end + keep_length = max_length - 150 # Reserve space for truncation message + start_length = keep_length // 2 + end_length = keep_length - start_length + + start_part = msg_content[:start_length] + end_part = msg_content[-end_length:] if end_length > 0 else "" + + return start_part + f"\n\n... (middle truncated) ...\n\n" + end_part + f"\n\nThis message is too long, repeat relevant information in your response to remember it" + else: + return msg_content + elif isinstance(msg_content, dict): + json_str = json.dumps(msg_content) + if len(json_str) > max_length: + # Calculate how much to keep from start and end + keep_length = max_length - 150 # Reserve space for truncation message + start_length = keep_length // 2 + end_length = keep_length - start_length + + start_part = json_str[:start_length] + end_part = json_str[-end_length:] if end_length > 0 else "" + + return start_part + f"\n\n... (middle truncated) ...\n\n" + end_part + f"\n\nThis message is too long, repeat relevant information in your response to remember it" + else: + return msg_content + + def _compress_tool_result_messages(self, messages: List[Dict[str, Any]], llm_model: str, max_tokens: Optional[int], token_threshold: Optional[int] = 1000) -> List[Dict[str, Any]]: + """Compress the tool result messages except the most recent one.""" + uncompressed_total_token_count = token_counter(model=llm_model, messages=messages) + + if uncompressed_total_token_count > (max_tokens or (100 * 1000)): + _i = 0 # Count the number of ToolResult messages + for msg in reversed(messages): # Start from the end and work backwards + 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 > token_threshold: # If the message is too long + if _i > 1: # If this is not the most recent ToolResult message + message_id = msg.get('message_id') # Get the message_id + if message_id: + msg["content"] = self._compress_message(msg["content"], message_id, token_threshold * 3) + else: + logger.warning(f"UNEXPECTED: Message has no message_id {str(msg)[:100]}") + else: + msg["content"] = self._safe_truncate(msg["content"], int(max_tokens * 2)) + return messages + + def _compress_user_messages(self, messages: List[Dict[str, Any]], llm_model: str, max_tokens: Optional[int], token_threshold: Optional[int] = 1000) -> List[Dict[str, Any]]: + """Compress the user messages except the most recent one.""" + uncompressed_total_token_count = token_counter(model=llm_model, messages=messages) + + if uncompressed_total_token_count > (max_tokens or (100 * 1000)): + _i = 0 # Count the number of User messages + for msg in reversed(messages): # Start from the end and work backwards + if msg.get('role') == 'user': # Only compress User messages + _i += 1 # Count the number of User messages + msg_token_count = token_counter(messages=[msg]) # Count the number of tokens in the message + if msg_token_count > token_threshold: # If the message is too long + if _i > 1: # If this is not the most recent User message + message_id = msg.get('message_id') # Get the message_id + if message_id: + msg["content"] = self._compress_message(msg["content"], message_id, token_threshold * 3) + else: + logger.warning(f"UNEXPECTED: Message has no message_id {str(msg)[:100]}") + else: + msg["content"] = self._safe_truncate(msg["content"], int(max_tokens * 2)) + return messages + + def _compress_assistant_messages(self, messages: List[Dict[str, Any]], llm_model: str, max_tokens: Optional[int], token_threshold: Optional[int] = 1000) -> List[Dict[str, Any]]: + """Compress the assistant messages except the most recent one.""" + uncompressed_total_token_count = token_counter(model=llm_model, messages=messages) + if uncompressed_total_token_count > (max_tokens or (100 * 1000)): + _i = 0 # Count the number of Assistant messages + for msg in reversed(messages): # Start from the end and work backwards + if msg.get('role') == 'assistant': # Only compress Assistant messages + _i += 1 # Count the number of Assistant messages + msg_token_count = token_counter(messages=[msg]) # Count the number of tokens in the message + if msg_token_count > token_threshold: # If the message is too long + if _i > 1: # If this is not the most recent Assistant message + message_id = msg.get('message_id') # Get the message_id + if message_id: + msg["content"] = self._compress_message(msg["content"], message_id, token_threshold * 3) + else: + logger.warning(f"UNEXPECTED: Message has no message_id {str(msg)[:100]}") + else: + msg["content"] = self._safe_truncate(msg["content"], int(max_tokens * 2)) + + return messages + + + def _remove_meta_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Remove meta messages from the messages.""" + result: List[Dict[str, Any]] = [] + for msg in messages: + msg_content = msg.get('content') + # Try to parse msg_content as JSON if it's a string + if isinstance(msg_content, str): + try: msg_content = json.loads(msg_content) + except json.JSONDecodeError: pass + if isinstance(msg_content, dict): + # Create a copy to avoid modifying the original + msg_content_copy = msg_content.copy() + if "tool_execution" in msg_content_copy: + tool_execution = msg_content_copy["tool_execution"].copy() + if "arguments" in tool_execution: + del tool_execution["arguments"] + msg_content_copy["tool_execution"] = tool_execution + # Create a new message dict with the modified content + new_msg = msg.copy() + new_msg["content"] = json.dumps(msg_content_copy) + result.append(new_msg) + else: + result.append(msg) + return result + + def _compress_messages(self, messages: List[Dict[str, Any]], llm_model: str, max_tokens: Optional[int] = 41000, token_threshold: Optional[int] = 4096, max_iterations: int = 5) -> List[Dict[str, Any]]: + """Compress the messages. + token_threshold: must be a power of 2 + """ + + if 'sonnet' in llm_model.lower(): + max_tokens = 200 * 1000 - 64000 - 28000 + elif 'gpt' in llm_model.lower(): + max_tokens = 128 * 1000 - 28000 + elif 'gemini' in llm_model.lower(): + max_tokens = 1000 * 1000 - 300000 + elif 'deepseek' in llm_model.lower(): + max_tokens = 128 * 1000 - 28000 + else: + max_tokens = 41 * 1000 - 10000 + + result = messages + result = self._remove_meta_messages(result) + + uncompressed_total_token_count = token_counter(model=llm_model, messages=result) + + result = self._compress_tool_result_messages(result, llm_model, max_tokens, token_threshold) + result = self._compress_user_messages(result, llm_model, max_tokens, token_threshold) + result = self._compress_assistant_messages(result, llm_model, max_tokens, token_threshold) + + compressed_token_count = token_counter(model=llm_model, messages=result) + + logger.info(f"_compress_messages: {uncompressed_total_token_count} -> {compressed_token_count}") # Log the token compression for debugging later + + if max_iterations <= 0: + logger.warning(f"_compress_messages: Max iterations reached, omitting messages") + result = self._compress_messages_by_omitting_messages(messages, llm_model, max_tokens) + return result + + if (compressed_token_count > max_tokens): + logger.warning(f"Further token compression is needed: {compressed_token_count} > {max_tokens}") + result = self._compress_messages(messages, llm_model, max_tokens, int(token_threshold / 2), max_iterations - 1) + + return self._middle_out_messages(result) + + def _compress_messages_by_omitting_messages( + self, + messages: List[Dict[str, Any]], + llm_model: str, + max_tokens: Optional[int] = 41000, + removal_batch_size: int = 10, + min_messages_to_keep: int = 10 + ) -> List[Dict[str, Any]]: + """Compress the messages by omitting messages from the middle. + + Args: + messages: List of messages to compress + llm_model: Model name for token counting + max_tokens: Maximum allowed tokens + removal_batch_size: Number of messages to remove per iteration + min_messages_to_keep: Minimum number of messages to preserve + """ + if not messages: + return messages + + result = messages + result = self._remove_meta_messages(result) + + # Early exit if no compression needed + initial_token_count = token_counter(model=llm_model, messages=result) + max_allowed_tokens = max_tokens or (100 * 1000) + + if initial_token_count <= max_allowed_tokens: + return result + + # Separate system message (assumed to be first) from conversation messages + system_message = messages[0] if messages and messages[0].get('role') == 'system' else None + conversation_messages = result[1:] if system_message else result + + safety_limit = 500 + current_token_count = initial_token_count + + while current_token_count > max_allowed_tokens and safety_limit > 0: + safety_limit -= 1 + + if len(conversation_messages) <= min_messages_to_keep: + logger.warning(f"Cannot compress further: only {len(conversation_messages)} messages remain (min: {min_messages_to_keep})") + break + + # Calculate removal strategy based on current message count + if len(conversation_messages) > (removal_batch_size * 2): + # Remove from middle, keeping recent and early context + middle_start = len(conversation_messages) // 2 - (removal_batch_size // 2) + middle_end = middle_start + removal_batch_size + conversation_messages = conversation_messages[:middle_start] + conversation_messages[middle_end:] + else: + # Remove from earlier messages, preserving recent context + messages_to_remove = min(removal_batch_size, len(conversation_messages) // 2) + if messages_to_remove > 0: + conversation_messages = conversation_messages[messages_to_remove:] + else: + # Can't remove any more messages + break + + # Recalculate token count + messages_to_count = ([system_message] + conversation_messages) if system_message else conversation_messages + current_token_count = token_counter(model=llm_model, messages=messages_to_count) + + # Prepare final result + final_messages = ([system_message] + conversation_messages) if system_message else conversation_messages + final_token_count = token_counter(model=llm_model, messages=final_messages) + + logger.info(f"_compress_messages_by_omitting_messages: {initial_token_count} -> {final_token_count} tokens ({len(messages)} -> {len(final_messages)} messages)") + + return final_messages + + def _middle_out_messages(self, messages: List[Dict[str, Any]], max_messages: int = 320) -> List[Dict[str, Any]]: + """Remove messages from the middle of the list, keeping max_messages total.""" + if len(messages) <= max_messages: + return messages + + # Keep half from the beginning and half from the end + keep_start = max_messages // 2 + keep_end = max_messages - keep_start + + return messages[:keep_start] + messages[-keep_end:] + + + 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) + + async def add_message( + self, + thread_id: str, + type: str, + content: Union[Dict[str, Any], List[Any], str], + is_llm_message: bool = False, + metadata: Optional[Dict[str, Any]] = None, + agent_id: Optional[str] = None, + agent_version_id: Optional[str] = None + ): + """Add a message to the thread in the database. + + Args: + thread_id: The ID of the thread to add the message to. + type: The type of the message (e.g., 'text', 'image_url', 'tool_call', 'tool', 'user', 'assistant'). + content: The content of the message. Can be a dictionary, list, or string. + It will be stored as JSONB in the database. + is_llm_message: Flag indicating if the message originated from the LLM. + Defaults to False (user message). + metadata: Optional dictionary for additional message metadata. + Defaults to None, stored as an empty JSONB object if None. + agent_id: Optional ID of the agent associated with this message. + agent_version_id: Optional ID of the specific agent version used. + """ + logger.debug(f"Adding message of type '{type}' to thread {thread_id} (agent: {agent_id}, version: {agent_version_id})") + client = await self.db.client + + # Prepare data for insertion + data_to_insert = { + 'thread_id': thread_id, + 'type': type, + 'content': content, + 'is_llm_message': is_llm_message, + 'metadata': metadata or {}, + } + + # Add agent information if provided + if agent_id: + data_to_insert['agent_id'] = agent_id + if agent_version_id: + data_to_insert['agent_version_id'] = agent_version_id + + try: + # Add returning='representation' to get the inserted row data including the id + result = await client.table('messages').insert(data_to_insert, returning='representation').execute() + logger.info(f"Successfully added message to thread {thread_id}") + + if result.data and len(result.data) > 0 and isinstance(result.data[0], dict) and 'message_id' in result.data[0]: + return result.data[0] + else: + logger.error(f"Insert operation failed or did not return expected data structure for thread {thread_id}. Result data: {result.data}") + return None + except Exception as e: + logger.error(f"Failed to add message to thread {thread_id}: {str(e)}", exc_info=True) + raise + + async def get_llm_messages(self, thread_id: str) -> List[Dict[str, Any]]: + """Get all messages for a thread. + + This method uses the SQL function which handles context truncation + by considering summary messages. + + Args: + thread_id: The ID of the thread to get messages for. + + Returns: + List of message objects. + """ + logger.debug(f"Getting messages for thread {thread_id}") + client = await self.db.client + + try: + # result = await client.rpc('get_llm_formatted_messages', {'p_thread_id': thread_id}).execute() + + # Fetch messages in batches of 1000 to avoid overloading the database + all_messages = [] + batch_size = 1000 + offset = 0 + + while True: + result = await client.table('messages').select('message_id, content').eq('thread_id', thread_id).eq('is_llm_message', True).order('created_at').range(offset, offset + batch_size - 1).execute() + + if not result.data or len(result.data) == 0: + break + + all_messages.extend(result.data) + + # If we got fewer than batch_size records, we've reached the end + if len(result.data) < batch_size: + break + + offset += batch_size + + # Use all_messages instead of result.data in the rest of the method + result_data = all_messages + + # Parse the returned data which might be stringified JSON + if not result_data: + return [] + + # Return properly parsed JSON objects + messages = [] + for item in result_data: + if isinstance(item['content'], str): + try: + parsed_item = json.loads(item['content']) + parsed_item['message_id'] = item['message_id'] + messages.append(parsed_item) + except json.JSONDecodeError: + logger.error(f"Failed to parse message: {item['content']}") + else: + content = item['content'] + content['message_id'] = item['message_id'] + messages.append(content) + + return messages + + except Exception as e: + logger.error(f"Failed to get messages for thread {thread_id}: {str(e)}", exc_info=True) + return [] + + async def run_thread( + self, + thread_id: str, + system_prompt: Dict[str, Any], + stream: bool = True, + temporary_message: Optional[Dict[str, Any]] = None, + llm_model: str = "gpt-4o", + llm_temperature: float = 0, + llm_max_tokens: Optional[int] = None, + processor_config: Optional[ProcessorConfig] = None, + tool_choice: ToolChoice = "auto", + native_max_auto_continues: int = 25, + max_xml_tool_calls: int = 0, + include_xml_examples: bool = False, + enable_thinking: Optional[bool] = False, + reasoning_effort: Optional[str] = 'low', + enable_context_manager: bool = True, + generation: Optional[StatefulGenerationClient] = None, + ) -> Union[Dict[str, Any], AsyncGenerator]: + """Run a conversation thread with LLM integration and tool execution. + + Args: + thread_id: The ID of the thread to run + system_prompt: System message to set the assistant's behavior + stream: Use streaming API for the LLM response + temporary_message: Optional temporary user message for this run only + llm_model: The name of the LLM model to use + llm_temperature: Temperature parameter for response randomness (0-1) + llm_max_tokens: Maximum tokens in the LLM response + processor_config: Configuration for the response processor + tool_choice: Tool choice preference ("auto", "required", "none") + native_max_auto_continues: Maximum number of automatic continuations when + finish_reason="tool_calls" (0 disables auto-continue) + max_xml_tool_calls: Maximum number of XML tool calls to allow (0 = no limit) + include_xml_examples: Whether to include XML tool examples in the system prompt + enable_thinking: Whether to enable thinking before making a decision + reasoning_effort: The effort level for reasoning + enable_context_manager: Whether to enable automatic context summarization. + + Returns: + An async generator yielding response chunks or error dict + """ + + logger.info(f"Starting thread execution for thread {thread_id}") + logger.info(f"Using model: {llm_model}") + # Log parameters + logger.info(f"Parameters: model={llm_model}, temperature={llm_temperature}, max_tokens={llm_max_tokens}") + logger.info(f"Auto-continue: max={native_max_auto_continues}, XML tool limit={max_xml_tool_calls}") + + # Log model info + logger.info(f"🤖 Thread {thread_id}: Using model {llm_model}") + + # Apply max_xml_tool_calls if specified and not already set in config + if max_xml_tool_calls > 0 and not processor_config.max_xml_tool_calls: + processor_config.max_xml_tool_calls = max_xml_tool_calls + + # Create a working copy of the system prompt to potentially modify + working_system_prompt = system_prompt.copy() + + # Add XML examples to system prompt if requested, do this only ONCE before the loop + if include_xml_examples and processor_config.xml_tool_calling: + xml_examples = self.tool_registry.get_xml_examples() + if xml_examples: + examples_content = """ +--- XML TOOL CALLING --- + +In this environment you have access to a set of tools you can use to answer the user's question. The tools are specified in XML format. +Format your tool calls using the specified XML tags. Place parameters marked as 'attribute' within the opening tag (e.g., ``). Place parameters marked as 'content' between the opening and closing tags. Place parameters marked as 'element' within their own child tags (e.g., `value`). Refer to the examples provided below for the exact structure of each tool. +String and scalar parameters should be specified as attributes, while content goes between tags. +Note that spaces for string values are not stripped. The output is parsed with regular expressions. + +Here are the XML tools available with examples: +""" + for tag_name, example in xml_examples.items(): + examples_content += f"<{tag_name}> Example: {example}\\n" + + # # Save examples content to a file + # try: + # with open('xml_examples.txt', 'w') as f: + # f.write(examples_content) + # logger.debug("Saved XML examples to xml_examples.txt") + # except Exception as e: + # logger.error(f"Failed to save XML examples to file: {e}") + + system_content = working_system_prompt.get('content') + + if isinstance(system_content, str): + working_system_prompt['content'] += examples_content + logger.debug("Appended XML examples to string system prompt content.") + elif isinstance(system_content, list): + appended = False + for item in working_system_prompt['content']: # Modify the copy + if isinstance(item, dict) and item.get('type') == 'text' and 'text' in item: + item['text'] += examples_content + logger.debug("Appended XML examples to the first text block in list system prompt content.") + appended = True + break + if not appended: + logger.warning("System prompt content is a list but no text block found to append XML examples.") + else: + logger.warning(f"System prompt content is of unexpected type ({type(system_content)}), cannot add XML examples.") + # Control whether we need to auto-continue due to tool_calls finish reason + auto_continue = True + auto_continue_count = 0 + + # Define inner function to handle a single run + async def _run_once(temp_msg=None): + try: + # Ensure processor_config is available in this scope + nonlocal processor_config + # Note: processor_config is now guaranteed to exist due to check above + + # 1. Get messages from thread for LLM call + messages = await self.get_llm_messages(thread_id) + + # 2. Check token count before proceeding + token_count = 0 + try: + # Use the potentially modified working_system_prompt for token counting + token_count = token_counter(model=llm_model, messages=[working_system_prompt] + messages) + token_threshold = self.context_manager.token_threshold + logger.info(f"Thread {thread_id} token count: {token_count}/{token_threshold} ({(token_count/token_threshold)*100:.1f}%)") + + # if token_count >= token_threshold and enable_context_manager: + # logger.info(f"Thread token count ({token_count}) exceeds threshold ({token_threshold}), summarizing...") + # summarized = await self.context_manager.check_and_summarize_if_needed( + # thread_id=thread_id, + # add_message_callback=self.add_message, + # model=llm_model, + # force=True + # ) + # if summarized: + # logger.info("Summarization complete, fetching updated messages with summary") + # messages = await self.get_llm_messages(thread_id) + # # Recount tokens after summarization, using the modified prompt + # new_token_count = token_counter(model=llm_model, messages=[working_system_prompt] + messages) + # logger.info(f"After summarization: token count reduced from {token_count} to {new_token_count}") + # else: + # logger.warning("Summarization failed or wasn't needed - proceeding with original messages") + # elif not enable_context_manager: + # logger.info("Automatic summarization disabled. Skipping token count check and summarization.") + + except Exception as e: + logger.error(f"Error counting tokens or summarizing: {str(e)}") + + # 3. Prepare messages for LLM call + add temporary message if it exists + # Use the working_system_prompt which may contain the XML examples + prepared_messages = [working_system_prompt] + + # Find the last user message index + last_user_index = -1 + for i, msg in enumerate(messages): + if msg.get('role') == 'user': + last_user_index = i + + # Insert temporary message before the last user message if it exists + if temp_msg and last_user_index >= 0: + prepared_messages.extend(messages[:last_user_index]) + prepared_messages.append(temp_msg) + prepared_messages.extend(messages[last_user_index:]) + logger.debug("Added temporary message before the last user message") + else: + # If no user message or no temporary message, just add all messages + prepared_messages.extend(messages) + if temp_msg: + prepared_messages.append(temp_msg) + logger.debug("Added temporary message to the end of prepared messages") + + # 4. Prepare tools for LLM call + openapi_tool_schemas = None + if processor_config.native_tool_calling: + openapi_tool_schemas = self.tool_registry.get_openapi_schemas() + logger.debug(f"Retrieved {len(openapi_tool_schemas) if openapi_tool_schemas else 0} OpenAPI tool schemas") + + prepared_messages = self._compress_messages(prepared_messages, llm_model) + + # 5. Make LLM API call + logger.debug("Making LLM API call") + try: + if generation: + generation.update( + input=prepared_messages, + start_time=datetime.datetime.now(datetime.timezone.utc), + model=llm_model, + model_parameters={ + "max_tokens": llm_max_tokens, + "temperature": llm_temperature, + "enable_thinking": enable_thinking, + "reasoning_effort": reasoning_effort, + "tool_choice": tool_choice, + "tools": openapi_tool_schemas, + } + ) + llm_response = await make_llm_api_call( + prepared_messages, # Pass the potentially modified messages + llm_model, + temperature=llm_temperature, + max_tokens=llm_max_tokens, + tools=openapi_tool_schemas, + tool_choice=tool_choice if processor_config.native_tool_calling else None, + stream=stream, + enable_thinking=enable_thinking, + reasoning_effort=reasoning_effort + ) + logger.debug("Successfully received raw LLM API response stream/object") + + except Exception as e: + logger.error(f"Failed to make LLM API call: {str(e)}", exc_info=True) + raise + + # 6. Process LLM response using the ResponseProcessor + if stream: + logger.debug("Processing streaming response") + response_generator = self.response_processor.process_streaming_response( + llm_response=llm_response, + thread_id=thread_id, + config=processor_config, + prompt_messages=prepared_messages, + llm_model=llm_model, + ) + + return response_generator + else: + logger.debug("Processing non-streaming response") + # Pass through the response generator without try/except to let errors propagate up + response_generator = self.response_processor.process_non_streaming_response( + llm_response=llm_response, + thread_id=thread_id, + config=processor_config, + prompt_messages=prepared_messages, + llm_model=llm_model, + ) + return response_generator # Return the generator + + except Exception as e: + 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) + } + + # Define a wrapper generator that handles auto-continue logic + async def auto_continue_wrapper(): + nonlocal auto_continue, auto_continue_count + + while auto_continue and (native_max_auto_continues == 0 or auto_continue_count < native_max_auto_continues): + # Reset auto_continue for this iteration + auto_continue = False + + # Run the thread once, passing the potentially modified system prompt + # Pass temp_msg only on the first iteration + try: + response_gen = await _run_once(temporary_message if auto_continue_count == 0 else None) + + # Handle error responses + if isinstance(response_gen, dict) and "status" in response_gen and response_gen["status"] == "error": + logger.error(f"Error in auto_continue_wrapper: {response_gen.get('message', 'Unknown error')}") + yield response_gen + return # Exit the generator on error + + # Process each chunk + try: + async for chunk in response_gen: + # Check if this is a finish reason chunk with tool_calls or xml_tool_limit_reached + if chunk.get('type') == 'finish': + if chunk.get('finish_reason') == 'tool_calls': + # Only auto-continue if enabled (max > 0) + if native_max_auto_continues > 0: + logger.info(f"Detected finish_reason='tool_calls', auto-continuing ({auto_continue_count + 1}/{native_max_auto_continues})") + auto_continue = True + auto_continue_count += 1 + # Don't yield the finish chunk to avoid confusing the client + continue + elif chunk.get('finish_reason') == 'xml_tool_limit_reached': + # Don't auto-continue if XML tool limit was reached + logger.info(f"Detected finish_reason='xml_tool_limit_reached', stopping auto-continue") + auto_continue = False + # Still yield the chunk to inform the client + + # Otherwise just yield the chunk normally + yield chunk + + # If not auto-continuing, we're done + if not auto_continue: + break + except Exception as e: + # If there's an exception, log it, yield an error status, and stop execution + logger.error(f"Error in auto_continue_wrapper generator: {str(e)}", exc_info=True) + yield { + "type": "status", + "status": "error", + "message": f"Error in thread processing: {str(e)}" + } + return # Exit the generator on any error + except Exception as outer_e: + # Catch exceptions from _run_once itself + logger.error(f"Error executing thread: {str(outer_e)}", exc_info=True) + yield { + "type": "status", + "status": "error", + "message": f"Error executing thread: {str(outer_e)}" + } + return # Exit immediately on exception from _run_once + + # If we've reached the max auto-continues, log a warning + if auto_continue and auto_continue_count >= native_max_auto_continues: + logger.warning(f"Reached maximum auto-continue limit ({native_max_auto_continues}), stopping.") + yield { + "type": "content", + "content": f"\n[Agent reached maximum auto-continue limit of {native_max_auto_continues}]" + } + + # If auto-continue is disabled (max=0), just run once + if native_max_auto_continues == 0: + logger.info("Auto-continue is disabled (native_max_auto_continues=0)") + # Pass the potentially modified system prompt and temp message + return await _run_once(temporary_message) + + # Otherwise return the auto-continue wrapper generator + return auto_continue_wrapper() diff --git a/app/daytona/utils/tool.py b/app/daytona/utils/tool.py new file mode 100644 index 000000000..de7a50458 --- /dev/null +++ b/app/daytona/utils/tool.py @@ -0,0 +1,240 @@ +""" +Core tool system providing the foundation for creating and managing tools. + +This module defines the base classes and decorators for creating tools in AgentPress: +- Tool base class for implementing tool functionality +- Schema decorators for OpenAPI and XML tool definitions +- Result containers for standardized tool outputs +""" + +from typing import Dict, Any, Union, Optional, List +from dataclasses import dataclass, field +from abc import ABC +import json +import inspect +from enum import Enum +from utils.logger import logger + +class SchemaType(Enum): + """Enumeration of supported schema types for tool definitions.""" + OPENAPI = "openapi" + XML = "xml" + CUSTOM = "custom" + +@dataclass +class XMLNodeMapping: + """Maps an XML node to a function parameter. + + Attributes: + param_name (str): Name of the function parameter + node_type (str): Type of node ("element", "attribute", or "content") + path (str): XPath-like path to the node ("." means root element) + required (bool): Whether the parameter is required (defaults to True) + """ + param_name: str + node_type: str = "element" + path: str = "." + required: bool = True + +@dataclass +class XMLTagSchema: + """Schema definition for XML tool tags. + + Attributes: + tag_name (str): Root tag name for the tool + mappings (List[XMLNodeMapping]): Parameter mappings for the tag + example (str, optional): Example showing tag usage + + Methods: + add_mapping: Add a new parameter mapping to the schema + """ + tag_name: str + mappings: List[XMLNodeMapping] = field(default_factory=list) + example: Optional[str] = None + + def add_mapping(self, param_name: str, node_type: str = "element", path: str = ".", required: bool = True) -> None: + """Add a new node mapping to the schema. + + Args: + param_name: Name of the function parameter + node_type: Type of node ("element", "attribute", or "content") + path: XPath-like path to the node + required: Whether the parameter is required + """ + self.mappings.append(XMLNodeMapping( + param_name=param_name, + node_type=node_type, + path=path, + required=required + )) + logger.debug(f"Added XML mapping for parameter '{param_name}' with type '{node_type}' at path '{path}', required={required}") + +@dataclass +class ToolSchema: + """Container for tool schemas with type information. + + Attributes: + schema_type (SchemaType): Type of schema (OpenAPI, XML, or Custom) + schema (Dict[str, Any]): The actual schema definition + xml_schema (XMLTagSchema, optional): XML-specific schema if applicable + """ + schema_type: SchemaType + schema: Dict[str, Any] + xml_schema: Optional[XMLTagSchema] = None + +@dataclass +class ToolResult: + """Container for tool execution results. + + Attributes: + success (bool): Whether the tool execution succeeded + output (str): Output message or error description + """ + success: bool + output: str + +class Tool(ABC): + """Abstract base class for all tools. + + Provides the foundation for implementing tools with schema registration + and result handling capabilities. + + Attributes: + _schemas (Dict[str, List[ToolSchema]]): Registered schemas for tool methods + + Methods: + get_schemas: Get all registered tool schemas + success_response: Create a successful result + fail_response: Create a failed result + """ + + def __init__(self): + """Initialize tool with empty schema registry.""" + self._schemas: Dict[str, List[ToolSchema]] = {} + logger.debug(f"Initializing tool class: {self.__class__.__name__}") + self._register_schemas() + + def _register_schemas(self): + """Register schemas from all decorated methods.""" + for name, method in inspect.getmembers(self, predicate=inspect.ismethod): + if hasattr(method, 'tool_schemas'): + self._schemas[name] = method.tool_schemas + logger.debug(f"Registered schemas for method '{name}' in {self.__class__.__name__}") + + def get_schemas(self) -> Dict[str, List[ToolSchema]]: + """Get all registered tool schemas. + + Returns: + Dict mapping method names to their schema definitions + """ + return self._schemas + + def success_response(self, data: Union[Dict[str, Any], str]) -> ToolResult: + """Create a successful tool result. + + Args: + data: Result data (dictionary or string) + + Returns: + ToolResult with success=True and formatted output + """ + if isinstance(data, str): + text = data + else: + text = json.dumps(data, indent=2) + logger.debug(f"Created success response for {self.__class__.__name__}") + return ToolResult(success=True, output=text) + + def fail_response(self, msg: str) -> ToolResult: + """Create a failed tool result. + + Args: + msg: Error message describing the failure + + Returns: + ToolResult with success=False and error message + """ + logger.debug(f"Tool {self.__class__.__name__} returned failed result: {msg}") + return ToolResult(success=False, output=msg) + +def _add_schema(func, schema: ToolSchema): + """Helper to add schema to a function.""" + if not hasattr(func, 'tool_schemas'): + func.tool_schemas = [] + func.tool_schemas.append(schema) + logger.debug(f"Added {schema.schema_type.value} schema to function {func.__name__}") + return func + +def openapi_schema(schema: Dict[str, Any]): + """Decorator for OpenAPI schema tools.""" + def decorator(func): + logger.debug(f"Applying OpenAPI schema to function {func.__name__}") + return _add_schema(func, ToolSchema( + schema_type=SchemaType.OPENAPI, + schema=schema + )) + return decorator + +def xml_schema( + tag_name: str, + mappings: List[Dict[str, Any]] = None, + example: str = None +): + """ + Decorator for XML schema tools with improved node mapping. + + Args: + tag_name: Name of the root XML tag + mappings: List of mapping definitions, each containing: + - param_name: Name of the function parameter + - node_type: "element", "attribute", or "content" + - path: Path to the node (default "." for root) + - required: Whether the parameter is required (default True) + example: Optional example showing how to use the XML tag + + Example: + @xml_schema( + tag_name="str-replace", + mappings=[ + {"param_name": "file_path", "node_type": "attribute", "path": "."}, + {"param_name": "old_str", "node_type": "element", "path": "old_str"}, + {"param_name": "new_str", "node_type": "element", "path": "new_str"} + ], + example=''' + + text to replace + replacement text + + ''' + ) + """ + def decorator(func): + logger.debug(f"Applying XML schema with tag '{tag_name}' to function {func.__name__}") + xml_schema = XMLTagSchema(tag_name=tag_name, example=example) + + # Add mappings + if mappings: + for mapping in mappings: + xml_schema.add_mapping( + param_name=mapping["param_name"], + node_type=mapping.get("node_type", "element"), + path=mapping.get("path", "."), + required=mapping.get("required", True) + ) + + return _add_schema(func, ToolSchema( + schema_type=SchemaType.XML, + schema={}, # OpenAPI schema could be added here if needed + xml_schema=xml_schema + )) + return decorator + +def custom_schema(schema: Dict[str, Any]): + """Decorator for custom schema tools.""" + def decorator(func): + logger.debug(f"Applying custom schema to function {func.__name__}") + return _add_schema(func, ToolSchema( + schema_type=SchemaType.CUSTOM, + schema=schema + )) + return decorator diff --git a/app/daytona/utils/tool_registry.py b/app/daytona/utils/tool_registry.py new file mode 100644 index 000000000..74fe57092 --- /dev/null +++ b/app/daytona/utils/tool_registry.py @@ -0,0 +1,152 @@ +from typing import Dict, Type, Any, List, Optional, Callable +from tool import Tool, SchemaType +from utils.logger import logger + + +class ToolRegistry: + """Registry for managing and accessing tools. + + Maintains a collection of tool instances and their schemas, allowing for + selective registration of tool functions and easy access to tool capabilities. + + Attributes: + tools (Dict[str, Dict[str, Any]]): OpenAPI-style tools and schemas + xml_tools (Dict[str, Dict[str, Any]]): XML-style tools and schemas + + Methods: + register_tool: Register a tool with optional function filtering + get_tool: Get a specific tool by name + get_xml_tool: Get a tool by XML tag name + get_openapi_schemas: Get OpenAPI schemas for function calling + get_xml_examples: Get examples of XML tool usage + """ + + def __init__(self): + """Initialize a new ToolRegistry instance.""" + self.tools = {} + self.xml_tools = {} + logger.debug("Initialized new ToolRegistry instance") + + def register_tool(self, tool_class: Type[Tool], function_names: Optional[List[str]] = None, **kwargs): + """Register a tool with optional function filtering. + + Args: + tool_class: The tool class to register + function_names: Optional list of specific functions to register + **kwargs: Additional arguments passed to tool initialization + + Notes: + - If function_names is None, all functions are registered + - Handles both OpenAPI and XML schema registration + """ + logger.debug(f"Registering tool class: {tool_class.__name__}") + tool_instance = tool_class(**kwargs) + schemas = tool_instance.get_schemas() + + logger.debug(f"Available schemas for {tool_class.__name__}: {list(schemas.keys())}") + + registered_openapi = 0 + registered_xml = 0 + + for func_name, schema_list in schemas.items(): + if function_names is None or func_name in function_names: + for schema in schema_list: + if schema.schema_type == SchemaType.OPENAPI: + self.tools[func_name] = { + "instance": tool_instance, + "schema": schema + } + registered_openapi += 1 + logger.debug(f"Registered OpenAPI function {func_name} from {tool_class.__name__}") + + if schema.schema_type == SchemaType.XML and schema.xml_schema: + self.xml_tools[schema.xml_schema.tag_name] = { + "instance": tool_instance, + "method": func_name, + "schema": schema + } + registered_xml += 1 + logger.debug(f"Registered XML tag {schema.xml_schema.tag_name} -> {func_name} from {tool_class.__name__}") + + logger.debug(f"Tool registration complete for {tool_class.__name__}: {registered_openapi} OpenAPI functions, {registered_xml} XML tags") + + def get_available_functions(self) -> Dict[str, Callable]: + """Get all available tool functions. + + Returns: + Dict mapping function names to their implementations + """ + available_functions = {} + + # Get OpenAPI tool functions + for tool_name, tool_info in self.tools.items(): + tool_instance = tool_info['instance'] + function_name = tool_name + function = getattr(tool_instance, function_name) + available_functions[function_name] = function + + # Get XML tool functions + for tag_name, tool_info in self.xml_tools.items(): + tool_instance = tool_info['instance'] + method_name = tool_info['method'] + function = getattr(tool_instance, method_name) + available_functions[method_name] = function + + logger.debug(f"Retrieved {len(available_functions)} available functions") + return available_functions + + def get_tool(self, tool_name: str) -> Dict[str, Any]: + """Get a specific tool by name. + + Args: + tool_name: Name of the tool function + + Returns: + Dict containing tool instance and schema, or empty dict if not found + """ + tool = self.tools.get(tool_name, {}) + if not tool: + logger.warning(f"Tool not found: {tool_name}") + return tool + + def get_xml_tool(self, tag_name: str) -> Dict[str, Any]: + """Get tool info by XML tag name. + + Args: + tag_name: XML tag name for the tool + + Returns: + Dict containing tool instance, method name, and schema + """ + tool = self.xml_tools.get(tag_name, {}) + if not tool: + logger.warning(f"XML tool not found for tag: {tag_name}") + return tool + + def get_openapi_schemas(self) -> List[Dict[str, Any]]: + """Get OpenAPI schemas for function calling. + + Returns: + List of OpenAPI-compatible schema definitions + """ + schemas = [ + tool_info['schema'].schema + for tool_info in self.tools.values() + if tool_info['schema'].schema_type == SchemaType.OPENAPI + ] + logger.debug(f"Retrieved {len(schemas)} OpenAPI schemas") + return schemas + + def get_xml_examples(self) -> Dict[str, str]: + """Get all XML tag examples. + + Returns: + Dict mapping tag names to their example usage + """ + examples = {} + for tool_info in self.xml_tools.values(): + schema = tool_info['schema'] + if schema.xml_schema and schema.xml_schema.example: + examples[schema.xml_schema.tag_name] = schema.xml_schema.example + logger.debug(f"Retrieved {len(examples)} XML examples") + return examples diff --git a/app/daytona/utils/xml_tool_parser.py b/app/daytona/utils/xml_tool_parser.py new file mode 100644 index 000000000..b35f0232e --- /dev/null +++ b/app/daytona/utils/xml_tool_parser.py @@ -0,0 +1,302 @@ +""" +XML Tool Call Parser Module + +This module provides a reliable XML tool call parsing system that supports +the Cursor-style format with structured function_calls blocks. +""" + +import json +import logging +import re +from dataclasses import dataclass +from typing import List, Dict, Any, Optional, Tuple + +logger = logging.getLogger(__name__) + + +@dataclass +class XMLToolCall: + """Represents a parsed XML tool call.""" + function_name: str + parameters: Dict[str, Any] + raw_xml: str + parsing_details: Dict[str, Any] + + +class XMLToolParser: + """ + Parser for XML tool calls using the Cursor-style format: + + + + param_value + ... + + + """ + + # Regex patterns for extracting XML blocks + FUNCTION_CALLS_PATTERN = re.compile( + r'(.*?)', + re.DOTALL | re.IGNORECASE + ) + + INVOKE_PATTERN = re.compile( + r'(.*?)', + re.DOTALL | re.IGNORECASE + ) + + PARAMETER_PATTERN = re.compile( + r'(.*?)', + re.DOTALL | re.IGNORECASE + ) + + def __init__(self, strict_mode: bool = False): + """ + Initialize the XML tool parser. + + Args: + strict_mode: If True, only accept the exact format. If False, + also try to parse legacy formats for backwards compatibility. + """ + self.strict_mode = strict_mode + + def parse_content(self, content: str) -> List[XMLToolCall]: + """ + Parse XML tool calls from content. + + Args: + content: The text content potentially containing XML tool calls + + Returns: + List of parsed XMLToolCall objects + """ + tool_calls = [] + + # First, try to find function_calls blocks + function_calls_matches = self.FUNCTION_CALLS_PATTERN.findall(content) + + for fc_content in function_calls_matches: + # Find all invoke blocks within this function_calls block + invoke_matches = self.INVOKE_PATTERN.findall(fc_content) + + for function_name, invoke_content in invoke_matches: + try: + tool_call = self._parse_invoke_block( + function_name, + invoke_content, + fc_content + ) + if tool_call: + tool_calls.append(tool_call) + except Exception as e: + logger.error(f"Error parsing invoke block for {function_name}: {e}") + + # If not in strict mode and no tool calls found, try legacy format + if not self.strict_mode and not tool_calls: + tool_calls.extend(self._parse_legacy_format(content)) + + return tool_calls + + def _parse_invoke_block( + self, + function_name: str, + invoke_content: str, + full_block: str + ) -> Optional[XMLToolCall]: + """Parse a single invoke block into an XMLToolCall.""" + parameters = {} + parsing_details = { + "format": "v2", + "function_name": function_name, + "raw_parameters": {} + } + + # Extract all parameters + param_matches = self.PARAMETER_PATTERN.findall(invoke_content) + + for param_name, param_value in param_matches: + # Clean up the parameter value + param_value = param_value.strip() + + # Try to parse as JSON if it looks like JSON + parsed_value = self._parse_parameter_value(param_value) + + parameters[param_name] = parsed_value + parsing_details["raw_parameters"][param_name] = param_value + + # Extract the raw XML for this specific invoke + invoke_pattern = re.compile( + rf'.*?', + re.DOTALL | re.IGNORECASE + ) + raw_xml_match = invoke_pattern.search(full_block) + raw_xml = raw_xml_match.group(0) if raw_xml_match else f"..." + + return XMLToolCall( + function_name=function_name, + parameters=parameters, + raw_xml=raw_xml, + parsing_details=parsing_details + ) + + @staticmethod + def _parse_parameter_value(value: str) -> Any: + """ + Parse a parameter value, attempting to convert to appropriate type. + + Args: + value: The string value to parse + + Returns: + Parsed value (could be dict, list, bool, int, float, or str) + """ + value = value.strip() + + # Try to parse as JSON first + if value.startswith(('{', '[')): + try: + return json.loads(value) + except json.JSONDecodeError: + pass + + # Try to parse as boolean + if value.lower() in ('true', 'false'): + return value.lower() == 'true' + + # Try to parse as number + try: + if '.' in value: + return float(value) + else: + return int(value) + except ValueError: + pass + + # Return as string + return value + + def _parse_legacy_format(self, content: str) -> List[XMLToolCall]: + """ + Parse legacy XML tool formats for backwards compatibility. + This handles formats like ... or + ... + """ + tool_calls = [] + + # Pattern for finding XML-like tags + tag_pattern = re.compile(r'<([a-zA-Z][\w\-]*)((?:\s+[\w\-]+=["\'][^"\']*["\'])*)\s*>(.*?)', re.DOTALL) + + for match in tag_pattern.finditer(content): + tag_name = match.group(1) + attributes_str = match.group(2) + inner_content = match.group(3) + + # Skip our own format tags + if tag_name in ('function_calls', 'invoke', 'parameter'): + continue + + parameters = {} + parsing_details = { + "format": "legacy", + "tag_name": tag_name, + "attributes": {}, + "inner_content": inner_content.strip() + } + + # Parse attributes + if attributes_str: + attr_pattern = re.compile(r'([\w\-]+)=["\']([^"\']*)["\']') + for attr_match in attr_pattern.finditer(attributes_str): + attr_name = attr_match.group(1) + attr_value = attr_match.group(2) + parameters[attr_name] = self._parse_parameter_value(attr_value) + parsing_details["attributes"][attr_name] = attr_value + + # If there's inner content and no attributes, use it as a 'content' parameter + if inner_content.strip() and not parameters: + parameters['content'] = inner_content.strip() + + # Convert tag name to function name (e.g., create-file -> create_file) + function_name = tag_name.replace('-', '_') + + tool_calls.append(XMLToolCall( + function_name=function_name, + parameters=parameters, + raw_xml=match.group(0), + parsing_details=parsing_details + )) + + return tool_calls + + @staticmethod + def format_tool_call(function_name: str, parameters: Dict[str, Any]) -> str: + """ + Format a tool call in the Cursor-style XML format. + + Args: + function_name: Name of the function to call + parameters: Dictionary of parameters + + Returns: + Formatted XML string + """ + lines = ['', ''.format(function_name)] + + for param_name, param_value in parameters.items(): + # Convert value to string representation + if isinstance(param_value, (dict, list)): + value_str = json.dumps(param_value) + elif isinstance(param_value, bool): + value_str = str(param_value).lower() + else: + value_str = str(param_value) + + lines.append('{}'.format( + param_name, value_str + )) + + lines.extend(['', '']) + return '\n'.join(lines) + + @staticmethod + def validate_tool_call(tool_call: XMLToolCall, expected_params: Optional[Dict[str, type]] = None) -> Tuple[bool, Optional[str]]: + """ + Validate a tool call against expected parameters. + + Args: + tool_call: The XMLToolCall to validate + expected_params: Optional dict of parameter names to expected types + + Returns: + Tuple of (is_valid, error_message) + """ + if not tool_call.function_name: + return False, "Function name is required" + + if expected_params: + for param_name, expected_type in expected_params.items(): + if param_name not in tool_call.parameters: + return False, f"Missing required parameter: {param_name}" + + param_value = tool_call.parameters[param_name] + if not isinstance(param_value, expected_type): + return False, f"Parameter {param_name} should be of type {expected_type.__name__}" + + return True, None + + +# Convenience function for quick parsing +def parse_xml_tool_calls(content: str, strict_mode: bool = False) -> List[XMLToolCall]: + """ + Parse XML tool calls from content. + + Args: + content: The text content potentially containing XML tool calls + strict_mode: If True, only accept the Cursor-style format + + Returns: + List of parsed XMLToolCall objects + """ + parser = XMLToolParser(strict_mode=strict_mode) + return parser.parse_content(content) \ No newline at end of file diff --git a/app/tool/sb_browser_tool.py b/app/tool/sb_browser_tool.py index 74bcfe601..2f8f08846 100644 --- a/app/tool/sb_browser_tool.py +++ b/app/tool/sb_browser_tool.py @@ -4,11 +4,11 @@ import io from PIL import Image -from agentpress.tool import ToolResult, openapi_schema, xml_schema -from agentpress.thread_manager import ThreadManager -from sandbox.tool_base import SandboxToolsBase -from utils.logger import logger -from utils.s3_upload_utils import upload_base64_image +from app.daytona.utils.tool import ToolResult, openapi_schema, xml_schema +from app.daytona.utils.thread_manager import ThreadManager +from app.daytona.tool_base import SandboxToolsBase +from app.utils.logger import logger +from app.utils.s3_upload_utils import upload_base64_image class SandboxBrowserTool(SandboxToolsBase): From 951368615553cbc55eb4942d3ff73fe12fb54db7 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Mon, 30 Jun 2025 23:12:44 +0800 Subject: [PATCH 04/33] modify BaseTool --- app/agentpress/__init__.py | 1 + app/agentpress/context_manager.py | 298 ++++ app/agentpress/response_processor.py | 1890 ++++++++++++++++++++++++++ app/agentpress/thread_manager.py | 787 +++++++++++ app/agentpress/tool.py | 240 ++++ app/agentpress/tool_registry.py | 152 +++ app/agentpress/utils/__init__.py | 1 + app/agentpress/utils/json_helpers.py | 174 +++ app/agentpress/xml_tool_parser.py | 300 ++++ app/daytona/tool_base.py | 2 +- app/services/billing.py | 969 +++++++++++++ app/services/docker/redis.conf | 1 + app/services/email.py | 192 +++ app/services/email_api.py | 70 + app/services/langfuse.py | 12 + app/services/llm.py | 411 ++++++ app/services/mcp_custom.py | 129 ++ app/services/mcp_temp.py | 299 ++++ app/services/redis.py | 153 +++ app/services/supabase.py | 113 ++ app/services/transcription.py | 76 ++ app/tool/base.py | 105 +- app/tool/data_providers_tool.py | 188 --- app/tool/expand_msg_tool.py | 103 -- app/tool/mcp_tool_wrapper.py | 715 ---------- app/tool/message_tool.py | 270 ---- app/tool/sb_browser_tool.py | 148 +- app/tool/sb_deploy_tool.py | 38 +- app/tool/sb_expose_tool.py | 22 +- app/tool/sb_files_tool.py | 70 +- app/tool/sb_shell_tool.py | 78 +- app/tool/sb_vision_tool.py | 26 +- app/tool/update_agent_tool.py | 889 ------------ app/utils/files_utils.py | 20 +- 34 files changed, 6572 insertions(+), 2370 deletions(-) create mode 100644 app/agentpress/__init__.py create mode 100644 app/agentpress/context_manager.py create mode 100644 app/agentpress/response_processor.py create mode 100644 app/agentpress/thread_manager.py create mode 100644 app/agentpress/tool.py create mode 100644 app/agentpress/tool_registry.py create mode 100644 app/agentpress/utils/__init__.py create mode 100644 app/agentpress/utils/json_helpers.py create mode 100644 app/agentpress/xml_tool_parser.py create mode 100644 app/services/billing.py create mode 100644 app/services/docker/redis.conf create mode 100644 app/services/email.py create mode 100644 app/services/email_api.py create mode 100644 app/services/langfuse.py create mode 100644 app/services/llm.py create mode 100644 app/services/mcp_custom.py create mode 100644 app/services/mcp_temp.py create mode 100644 app/services/redis.py create mode 100644 app/services/supabase.py create mode 100644 app/services/transcription.py delete mode 100644 app/tool/data_providers_tool.py delete mode 100644 app/tool/expand_msg_tool.py delete mode 100644 app/tool/mcp_tool_wrapper.py delete mode 100644 app/tool/message_tool.py delete mode 100644 app/tool/update_agent_tool.py diff --git a/app/agentpress/__init__.py b/app/agentpress/__init__.py new file mode 100644 index 000000000..8ab9008ad --- /dev/null +++ b/app/agentpress/__init__.py @@ -0,0 +1 @@ +# Utility functions and constants for agent tools \ No newline at end of file diff --git a/app/agentpress/context_manager.py b/app/agentpress/context_manager.py new file mode 100644 index 000000000..11405f40b --- /dev/null +++ b/app/agentpress/context_manager.py @@ -0,0 +1,298 @@ +""" +Context Management for AgentPress Threads. + +This module handles token counting and thread summarization to prevent +reaching the context window limitations of LLM models. +""" + +import json +from typing import List, Dict, Any, Optional + +from litellm import token_counter, completion_cost +from services.supabase import DBConnection +from services.llm import make_llm_api_call +from utils.logger import logger + +# Constants for token management +DEFAULT_TOKEN_THRESHOLD = 120000 # 80k tokens threshold for summarization +SUMMARY_TARGET_TOKENS = 10000 # Target ~10k tokens for the summary message +RESERVE_TOKENS = 5000 # Reserve tokens for new messages + +class ContextManager: + """Manages thread context including token counting and summarization.""" + + def __init__(self, token_threshold: int = DEFAULT_TOKEN_THRESHOLD): + """Initialize the ContextManager. + + Args: + token_threshold: Token count threshold to trigger summarization + """ + self.db = DBConnection() + self.token_threshold = token_threshold + + async def get_thread_token_count(self, thread_id: str) -> int: + """Get the current token count for a thread using LiteLLM. + + Args: + thread_id: ID of the thread to analyze + + Returns: + The total token count for relevant messages in the thread + """ + logger.debug(f"Getting token count for thread {thread_id}") + + try: + # Get messages for the thread + messages = await self.get_messages_for_summarization(thread_id) + + if not messages: + logger.debug(f"No messages found for thread {thread_id}") + return 0 + + # Use litellm's token_counter for accurate model-specific counting + # This is much more accurate than the SQL-based estimation + token_count = token_counter(model="gpt-4", messages=messages) + + logger.info(f"Thread {thread_id} has {token_count} tokens (calculated with litellm)") + return token_count + + except Exception as e: + logger.error(f"Error getting token count: {str(e)}") + return 0 + + async def get_messages_for_summarization(self, thread_id: str) -> List[Dict[str, Any]]: + """Get all LLM messages from the thread that need to be summarized. + + This gets messages after the most recent summary or all messages if + no summary exists. Unlike get_llm_messages, this includes ALL messages + since the last summary, even if we're generating a new summary. + + Args: + thread_id: ID of the thread to get messages from + + Returns: + List of message objects to summarize + """ + logger.debug(f"Getting messages for summarization for thread {thread_id}") + client = await self.db.client + + try: + # Find the most recent summary message + summary_result = await client.table('messages').select('created_at') \ + .eq('thread_id', thread_id) \ + .eq('type', 'summary') \ + .eq('is_llm_message', True) \ + .order('created_at', desc=True) \ + .limit(1) \ + .execute() + + # Get messages after the most recent summary or all messages if no summary + if summary_result.data and len(summary_result.data) > 0: + last_summary_time = summary_result.data[0]['created_at'] + logger.debug(f"Found last summary at {last_summary_time}") + + # Get all messages after the summary, but NOT including the summary itself + messages_result = await client.table('messages').select('*') \ + .eq('thread_id', thread_id) \ + .eq('is_llm_message', True) \ + .gt('created_at', last_summary_time) \ + .order('created_at') \ + .execute() + else: + logger.debug("No previous summary found, getting all messages") + # Get all messages + messages_result = await client.table('messages').select('*') \ + .eq('thread_id', thread_id) \ + .eq('is_llm_message', True) \ + .order('created_at') \ + .execute() + + # Parse the message content if needed + messages = [] + for msg in messages_result.data: + # Skip existing summary messages - we don't want to summarize summaries + if msg.get('type') == 'summary': + logger.debug(f"Skipping summary message from {msg.get('created_at')}") + continue + + # Parse content if it's a string + content = msg['content'] + if isinstance(content, str): + try: + content = json.loads(content) + except json.JSONDecodeError: + pass # Keep as string if not valid JSON + + # Ensure we have the proper format for the LLM + if 'role' not in content and 'type' in msg: + # Convert message type to role if needed + role = msg['type'] + if role == 'assistant' or role == 'user' or role == 'system' or role == 'tool': + content = {'role': role, 'content': content} + + messages.append(content) + + logger.info(f"Got {len(messages)} messages to summarize for thread {thread_id}") + return messages + + except Exception as e: + logger.error(f"Error getting messages for summarization: {str(e)}", exc_info=True) + return [] + + async def create_summary( + self, + thread_id: str, + messages: List[Dict[str, Any]], + model: str = "gpt-4o-mini" + ) -> Optional[Dict[str, Any]]: + """Generate a summary of conversation messages. + + Args: + thread_id: ID of the thread to summarize + messages: Messages to summarize + model: LLM model to use for summarization + + Returns: + Summary message object or None if summarization failed + """ + if not messages: + logger.warning("No messages to summarize") + return None + + logger.info(f"Creating summary for thread {thread_id} with {len(messages)} messages") + + # Create system message with summarization instructions + system_message = { + "role": "system", + "content": f"""You are a specialized summarization assistant. Your task is to create a concise but comprehensive summary of the conversation history. + +The summary should: +1. Preserve all key information including decisions, conclusions, and important context +2. Include any tools that were used and their results +3. Maintain chronological order of events +4. Be presented as a narrated list of key points with section headers +5. Include only factual information from the conversation (no new information) +6. Be concise but detailed enough that the conversation can continue with this summary as context + +VERY IMPORTANT: This summary will replace older parts of the conversation in the LLM's context window, so ensure it contains ALL key information and LATEST STATE OF THE CONVERSATION - SO WE WILL KNOW HOW TO PICK UP WHERE WE LEFT OFF. + + +THE CONVERSATION HISTORY TO SUMMARIZE IS AS FOLLOWS: +=============================================================== +==================== CONVERSATION HISTORY ==================== +{messages} +==================== END OF CONVERSATION HISTORY ==================== +=============================================================== +""" + } + + try: + # Call LLM to generate summary + response = await make_llm_api_call( + model_name=model, + messages=[system_message, {"role": "user", "content": "PLEASE PROVIDE THE SUMMARY NOW."}], + temperature=0, + max_tokens=SUMMARY_TARGET_TOKENS, + stream=False + ) + + if response and hasattr(response, 'choices') and response.choices: + summary_content = response.choices[0].message.content + + # Track token usage + try: + token_count = token_counter(model=model, messages=[{"role": "user", "content": summary_content}]) + cost = completion_cost(model=model, prompt="", completion=summary_content) + logger.info(f"Summary generated with {token_count} tokens at cost ${cost:.6f}") + except Exception as e: + logger.error(f"Error calculating token usage: {str(e)}") + + # Format the summary message with clear beginning and end markers + formatted_summary = f""" +======== CONVERSATION HISTORY SUMMARY ======== + +{summary_content} + +======== END OF SUMMARY ======== + +The above is a summary of the conversation history. The conversation continues below. +""" + + # Format the summary message + summary_message = { + "role": "user", + "content": formatted_summary + } + + return summary_message + else: + logger.error("Failed to generate summary: Invalid response") + return None + + except Exception as e: + logger.error(f"Error creating summary: {str(e)}", exc_info=True) + return None + + async def check_and_summarize_if_needed( + self, + thread_id: str, + add_message_callback, + model: str = "gpt-4o-mini", + force: bool = False + ) -> bool: + """Check if thread needs summarization and summarize if so. + + Args: + thread_id: ID of the thread to check + add_message_callback: Callback to add the summary message to the thread + model: LLM model to use for summarization + force: Whether to force summarization regardless of token count + + Returns: + True if summarization was performed, False otherwise + """ + try: + # Get token count using LiteLLM (accurate model-specific counting) + token_count = await self.get_thread_token_count(thread_id) + + # If token count is below threshold and not forcing, no summarization needed + if token_count < self.token_threshold and not force: + logger.debug(f"Thread {thread_id} has {token_count} tokens, below threshold {self.token_threshold}") + return False + + # Log reason for summarization + if force: + logger.info(f"Forced summarization of thread {thread_id} with {token_count} tokens") + else: + logger.info(f"Thread {thread_id} exceeds token threshold ({token_count} >= {self.token_threshold}), summarizing...") + + # Get messages to summarize + messages = await self.get_messages_for_summarization(thread_id) + + # If there are too few messages, don't summarize + if len(messages) < 3: + logger.info(f"Thread {thread_id} has too few messages ({len(messages)}) to summarize") + return False + + # Create summary + summary = await self.create_summary(thread_id, messages, model) + + if summary: + # Add summary message to thread + await add_message_callback( + thread_id=thread_id, + type="summary", + content=summary, + is_llm_message=True, + metadata={"token_count": token_count} + ) + + logger.info(f"Successfully added summary to thread {thread_id}") + return True + else: + logger.error(f"Failed to create summary for thread {thread_id}") + return False + + except Exception as e: + logger.error(f"Error in check_and_summarize_if_needed: {str(e)}", exc_info=True) + return False \ No newline at end of file diff --git a/app/agentpress/response_processor.py b/app/agentpress/response_processor.py new file mode 100644 index 000000000..68cd6bdc1 --- /dev/null +++ b/app/agentpress/response_processor.py @@ -0,0 +1,1890 @@ +""" +Response processing module for AgentPress. + +This module handles the processing of LLM responses, including: +- Streaming and non-streaming response handling +- XML and native tool call detection and parsing +- Tool execution orchestration +- Message formatting and persistence +""" + +import json +import re +import uuid +import asyncio +from datetime import datetime, timezone +from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple, Union, Callable, Literal +from dataclasses import dataclass +from utils.logger import logger +from agentpress.tool import ToolResult +from agentpress.tool_registry import ToolRegistry +from agentpress.xml_tool_parser import XMLToolParser +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"] + +# Type alias for tool execution strategy +ToolExecutionStrategy = Literal["sequential", "parallel"] + +@dataclass +class ToolExecutionContext: + """Context for a tool execution including call details, result, and display info.""" + tool_call: Dict[str, Any] + tool_index: int + result: Optional[ToolResult] = None + function_name: Optional[str] = None + xml_tag_name: Optional[str] = None + error: Optional[Exception] = None + assistant_message_id: Optional[str] = None + parsing_details: Optional[Dict[str, Any]] = None + +@dataclass +class ProcessorConfig: + """ + Configuration for response processing and tool execution. + + This class controls how the LLM's responses are processed, including how tool calls + are detected, executed, and their results handled. + + Attributes: + xml_tool_calling: Enable XML-based tool call detection (...) + native_tool_calling: Enable OpenAI-style function calling format + execute_tools: Whether to automatically execute detected tool calls + execute_on_stream: For streaming, execute tools as they appear vs. at the end + tool_execution_strategy: How to execute multiple tools ("sequential" or "parallel") + xml_adding_strategy: How to add XML tool results to the conversation + max_xml_tool_calls: Maximum number of XML tool calls to process (0 = no limit) + """ + + xml_tool_calling: bool = True + native_tool_calling: bool = False + + execute_tools: bool = True + execute_on_stream: bool = False + tool_execution_strategy: ToolExecutionStrategy = "sequential" + xml_adding_strategy: XmlAddingStrategy = "assistant_message" + max_xml_tool_calls: int = 0 # 0 means no limit + + def __post_init__(self): + """Validate configuration after initialization.""" + if self.xml_tool_calling is False and self.native_tool_calling is False and self.execute_tools: + raise ValueError("At least one tool calling format (XML or native) must be enabled if execute_tools is True") + + if self.xml_adding_strategy not in ["user_message", "assistant_message", "inline_edit"]: + raise ValueError("xml_adding_strategy must be 'user_message', 'assistant_message', or 'inline_edit'") + + if self.max_xml_tool_calls < 0: + raise ValueError("max_xml_tool_calls must be a non-negative integer (0 = no limit)") + +class ResponseProcessor: + """Processes LLM responses, extracting and executing tool calls.""" + + def __init__(self, tool_registry: ToolRegistry, add_message_callback: Callable, trace: Optional[StatefulTraceClient] = None, is_agent_builder: bool = False, target_agent_id: Optional[str] = None, agent_config: Optional[dict] = None): + """Initialize the ResponseProcessor. + + Args: + tool_registry: Registry of available tools + add_message_callback: Callback function to add messages to the thread. + MUST return the full saved message object (dict) or None. + agent_config: Optional agent configuration with version information + """ + self.tool_registry = tool_registry + self.add_message = add_message_callback + self.trace = trace + if not self.trace: + self.trace = langfuse.trace(name="anonymous:response_processor") + # Initialize the XML parser with backwards compatibility + self.xml_parser = XMLToolParser(strict_mode=False) + self.is_agent_builder = is_agent_builder + self.target_agent_id = target_agent_id + self.agent_config = agent_config + + async def _yield_message(self, message_obj: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Helper to yield a message with proper formatting. + + Ensures that content and metadata are JSON strings for client compatibility. + """ + if message_obj: + return format_for_yield(message_obj) + + async def _add_message_with_agent_info( + self, + thread_id: str, + type: str, + content: Union[Dict[str, Any], List[Any], str], + is_llm_message: bool = False, + metadata: Optional[Dict[str, Any]] = None + ): + """Helper to add a message with agent version information if available.""" + agent_id = None + agent_version_id = None + + if self.agent_config: + agent_id = self.agent_config.get('agent_id') + agent_version_id = self.agent_config.get('current_version_id') + + return await self.add_message( + thread_id=thread_id, + type=type, + content=content, + is_llm_message=is_llm_message, + metadata=metadata, + agent_id=agent_id, + agent_version_id=agent_version_id + ) + + async def process_streaming_response( + self, + llm_response: AsyncGenerator, + thread_id: str, + prompt_messages: List[Dict[str, Any]], + llm_model: str, + config: ProcessorConfig = ProcessorConfig(), + ) -> AsyncGenerator[Dict[str, Any], None]: + """Process a streaming LLM response, handling tool calls and execution. + + Args: + llm_response: Streaming response from the LLM + thread_id: ID of the conversation thread + prompt_messages: List of messages sent to the LLM (the prompt) + llm_model: The name of the LLM model used + config: Configuration for parsing and execution + + Yields: + Complete message objects matching the DB schema, except for content chunks. + """ + accumulated_content = "" + tool_calls_buffer = {} + current_xml_content = "" + xml_chunks_buffer = [] + pending_tool_executions = [] + yielded_tool_indices = set() # Stores indices of tools whose *status* has been yielded + tool_index = 0 + xml_tool_call_count = 0 + finish_reason = None + last_assistant_message_object = None # Store the final saved assistant message object + 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}") + + thread_run_id = str(uuid.uuid4()) + + try: + # --- Save and Yield Start Events --- + start_content = {"status_type": "thread_run_start", "thread_run_id": thread_run_id} + start_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=start_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if start_msg_obj: yield format_for_yield(start_msg_obj) + + assist_start_content = {"status_type": "assistant_response_start"} + assist_start_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=assist_start_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if assist_start_msg_obj: yield format_for_yield(assist_start_msg_obj) + # --- End Start Events --- + + __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}") + + if hasattr(chunk, 'choices') and chunk.choices: + delta = chunk.choices[0].delta if hasattr(chunk.choices[0], 'delta') else None + + # Check for and log Anthropic thinking content + if delta and hasattr(delta, 'reasoning_content') and delta.reasoning_content: + if not has_printed_thinking_prefix: + # print("[THINKING]: ", end='', flush=True) + has_printed_thinking_prefix = True + # print(delta.reasoning_content, end='', flush=True) + # Append reasoning to main content to be saved in the final message + accumulated_content += delta.reasoning_content + + # Process content chunk + if delta and hasattr(delta, 'content') and delta.content: + chunk_content = delta.content + # print(chunk_content, end='', flush=True) + accumulated_content += chunk_content + current_xml_content += chunk_content + + if not (config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls): + # Yield ONLY content chunk (don't save) + now_chunk = datetime.now(timezone.utc).isoformat() + yield { + "sequence": __sequence, + "message_id": None, "thread_id": thread_id, "type": "assistant", + "is_llm_message": True, + "content": to_json_string({"role": "assistant", "content": chunk_content}), + "metadata": to_json_string({"stream_status": "chunk", "thread_run_id": thread_run_id}), + "created_at": now_chunk, "updated_at": now_chunk + } + __sequence += 1 + else: + logger.info("XML tool call limit reached - not yielding more content chunks") + self.trace.event(name="xml_tool_call_limit_reached", level="DEFAULT", status_message=(f"XML tool call limit reached - not yielding more content chunks")) + + # --- Process XML Tool Calls (if enabled and limit not reached) --- + if config.xml_tool_calling and not (config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls): + xml_chunks = self._extract_xml_chunks(current_xml_content) + for xml_chunk in xml_chunks: + current_xml_content = current_xml_content.replace(xml_chunk, "", 1) + xml_chunks_buffer.append(xml_chunk) + result = self._parse_xml_tool_call(xml_chunk) + if result: + tool_call, parsing_details = result + xml_tool_call_count += 1 + current_assistant_id = last_assistant_message_object['message_id'] if last_assistant_message_object else None + context = self._create_tool_context( + tool_call, tool_index, current_assistant_id, parsing_details + ) + + if config.execute_tools and config.execute_on_stream: + # Save and Yield tool_started status + started_msg_obj = await self._yield_and_save_tool_started(context, thread_id, thread_run_id) + if started_msg_obj: yield format_for_yield(started_msg_obj) + yielded_tool_indices.add(tool_index) # Mark status as yielded + + execution_task = asyncio.create_task(self._execute_tool(tool_call)) + pending_tool_executions.append({ + "task": execution_task, "tool_call": tool_call, + "tool_index": tool_index, "context": context + }) + tool_index += 1 + + if config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls: + logger.debug(f"Reached XML tool call limit ({config.max_xml_tool_calls})") + finish_reason = "xml_tool_limit_reached" + break # Stop processing more XML chunks in this delta + + # --- Process Native Tool Call Chunks --- + if config.native_tool_calling and delta and hasattr(delta, 'tool_calls') and delta.tool_calls: + for tool_call_chunk in delta.tool_calls: + # Yield Native Tool Call Chunk (transient status, not saved) + # ... (safe extraction logic for tool_call_data_chunk) ... + tool_call_data_chunk = {} # Placeholder for extracted data + if hasattr(tool_call_chunk, 'model_dump'): tool_call_data_chunk = tool_call_chunk.model_dump() + else: # Manual extraction... + if hasattr(tool_call_chunk, 'id'): tool_call_data_chunk['id'] = tool_call_chunk.id + if hasattr(tool_call_chunk, 'index'): tool_call_data_chunk['index'] = tool_call_chunk.index + if hasattr(tool_call_chunk, 'type'): tool_call_data_chunk['type'] = tool_call_chunk.type + if hasattr(tool_call_chunk, 'function'): + tool_call_data_chunk['function'] = {} + if hasattr(tool_call_chunk.function, 'name'): tool_call_data_chunk['function']['name'] = tool_call_chunk.function.name + if hasattr(tool_call_chunk.function, 'arguments'): tool_call_data_chunk['function']['arguments'] = tool_call_chunk.function.arguments if isinstance(tool_call_chunk.function.arguments, str) else to_json_string(tool_call_chunk.function.arguments) + + + now_tool_chunk = datetime.now(timezone.utc).isoformat() + yield { + "message_id": None, "thread_id": thread_id, "type": "status", "is_llm_message": True, + "content": to_json_string({"role": "assistant", "status_type": "tool_call_chunk", "tool_call_chunk": tool_call_data_chunk}), + "metadata": to_json_string({"thread_run_id": thread_run_id}), + "created_at": now_tool_chunk, "updated_at": now_tool_chunk + } + + # --- Buffer and Execute Complete Native Tool Calls --- + if not hasattr(tool_call_chunk, 'function'): continue + idx = tool_call_chunk.index if hasattr(tool_call_chunk, 'index') else 0 + # ... (buffer update logic remains same) ... + # ... (check complete logic remains same) ... + has_complete_tool_call = False # Placeholder + if (tool_calls_buffer.get(idx) and + tool_calls_buffer[idx]['id'] and + tool_calls_buffer[idx]['function']['name'] and + tool_calls_buffer[idx]['function']['arguments']): + try: + safe_json_parse(tool_calls_buffer[idx]['function']['arguments']) + has_complete_tool_call = True + except json.JSONDecodeError: pass + + + if has_complete_tool_call and config.execute_tools and config.execute_on_stream: + current_tool = tool_calls_buffer[idx] + tool_call_data = { + "function_name": current_tool['function']['name'], + "arguments": safe_json_parse(current_tool['function']['arguments']), + "id": current_tool['id'] + } + current_assistant_id = last_assistant_message_object['message_id'] if last_assistant_message_object else None + context = self._create_tool_context( + tool_call_data, tool_index, current_assistant_id + ) + + # Save and Yield tool_started status + started_msg_obj = await self._yield_and_save_tool_started(context, thread_id, thread_run_id) + if started_msg_obj: yield format_for_yield(started_msg_obj) + yielded_tool_indices.add(tool_index) # Mark status as yielded + + execution_task = asyncio.create_task(self._execute_tool(tool_call_data)) + pending_tool_executions.append({ + "task": execution_task, "tool_call": tool_call_data, + "tool_index": tool_index, "context": context + }) + tool_index += 1 + + if finish_reason == "xml_tool_limit_reached": + logger.info("Stopping stream processing after loop due to XML tool call limit") + self.trace.event(name="stopping_stream_processing_after_loop_due_to_xml_tool_call_limit", level="DEFAULT", status_message=(f"Stopping stream processing after loop due to XML tool call limit")) + break + + # 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) + if pending_tool_executions: + logger.info(f"Waiting for {len(pending_tool_executions)} pending streamed tool executions") + self.trace.event(name="waiting_for_pending_streamed_tool_executions", level="DEFAULT", status_message=(f"Waiting for {len(pending_tool_executions)} pending streamed tool executions")) + # ... (asyncio.wait logic) ... + pending_tasks = [execution["task"] for execution in pending_tool_executions] + done, _ = await asyncio.wait(pending_tasks) + + for execution in pending_tool_executions: + tool_idx = execution.get("tool_index", -1) + context = execution["context"] + tool_name = context.function_name + + # Check if status was already yielded during stream run + if tool_idx in yielded_tool_indices: + logger.debug(f"Status for tool index {tool_idx} already yielded.") + # Still need to process the result for the buffer + try: + if execution["task"].done(): + result = execution["task"].result() + context.result = result + tool_results_buffer.append((execution["tool_call"], result, tool_idx, context)) + + if tool_name in ['ask', 'complete']: + logger.info(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.") + self.trace.event(name="terminating_tool_completed_during_streaming", level="DEFAULT", status_message=(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.")) + agent_should_terminate = True + + else: # Should not happen with asyncio.wait + logger.warning(f"Task for tool index {tool_idx} not done after wait.") + self.trace.event(name="task_for_tool_index_not_done_after_wait", level="WARNING", status_message=(f"Task for tool index {tool_idx} not done after wait.")) + except Exception as e: + logger.error(f"Error getting result for pending tool execution {tool_idx}: {str(e)}") + self.trace.event(name="error_getting_result_for_pending_tool_execution", level="ERROR", status_message=(f"Error getting result for pending tool execution {tool_idx}: {str(e)}")) + context.error = e + # Save and Yield tool error status message (even if started was yielded) + error_msg_obj = await self._yield_and_save_tool_error(context, thread_id, thread_run_id) + if error_msg_obj: yield format_for_yield(error_msg_obj) + continue # Skip further status yielding for this tool index + + # If status wasn't yielded before (shouldn't happen with current logic), yield it now + try: + if execution["task"].done(): + result = execution["task"].result() + context.result = result + tool_results_buffer.append((execution["tool_call"], result, tool_idx, context)) + + # Check if this is a terminating tool + if tool_name in ['ask', 'complete']: + logger.info(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.") + self.trace.event(name="terminating_tool_completed_during_streaming", level="DEFAULT", status_message=(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.")) + agent_should_terminate = True + + # Save and Yield tool completed/failed status + completed_msg_obj = await self._yield_and_save_tool_completed( + context, None, thread_id, thread_run_id + ) + if completed_msg_obj: yield format_for_yield(completed_msg_obj) + yielded_tool_indices.add(tool_idx) + except Exception as e: + logger.error(f"Error getting result/yielding status for pending tool execution {tool_idx}: {str(e)}") + self.trace.event(name="error_getting_result_yielding_status_for_pending_tool_execution", level="ERROR", status_message=(f"Error getting result/yielding status for pending tool execution {tool_idx}: {str(e)}")) + context.error = e + # Save and Yield tool error status + error_msg_obj = await self._yield_and_save_tool_error(context, thread_id, thread_run_id) + if error_msg_obj: yield format_for_yield(error_msg_obj) + yielded_tool_indices.add(tool_idx) + + + # Save and yield finish status if limit was reached + if finish_reason == "xml_tool_limit_reached": + finish_content = {"status_type": "finish", "finish_reason": "xml_tool_limit_reached"} + finish_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=finish_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if finish_msg_obj: yield format_for_yield(finish_msg_obj) + logger.info(f"Stream finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls") + self.trace.event(name="stream_finished_with_reason_xml_tool_limit_reached_after_xml_tool_calls", level="DEFAULT", status_message=(f"Stream finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls")) + + # --- SAVE and YIELD Final Assistant Message --- + if accumulated_content: + # ... (Truncate accumulated_content logic) ... + if config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls and xml_chunks_buffer: + last_xml_chunk = xml_chunks_buffer[-1] + last_chunk_end_pos = accumulated_content.find(last_xml_chunk) + len(last_xml_chunk) + if last_chunk_end_pos > 0: + accumulated_content = accumulated_content[:last_chunk_end_pos] + + # ... (Extract complete_native_tool_calls logic) ... + # 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']: + try: + args = safe_json_parse(tc_buf['function']['arguments']) + complete_native_tool_calls.append({ + "id": tc_buf['id'], "type": "function", + "function": {"name": tc_buf['function']['name'],"arguments": args} + }) + except json.JSONDecodeError: continue + + message_data = { # Dict to be saved in 'content' + "role": "assistant", "content": accumulated_content, + "tool_calls": complete_native_tool_calls or None + } + + last_assistant_message_object = await self._add_message_with_agent_info( + thread_id=thread_id, type="assistant", content=message_data, + is_llm_message=True, metadata={"thread_run_id": thread_run_id} + ) + + if last_assistant_message_object: + # Yield the complete saved object, adding stream_status metadata just for yield + yield_metadata = ensure_dict(last_assistant_message_object.get('metadata'), {}) + yield_metadata['stream_status'] = 'complete' + # Format the message for yielding + yield_message = last_assistant_message_object.copy() + yield_message['metadata'] = yield_metadata + yield format_for_yield(yield_message) + else: + logger.error(f"Failed to save final assistant message for thread {thread_id}") + self.trace.event(name="failed_to_save_final_assistant_message_for_thread", level="ERROR", status_message=(f"Failed to save final assistant message for thread {thread_id}")) + # Save and yield an error status + err_content = {"role": "system", "status_type": "error", "message": "Failed to save final assistant message"} + err_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=err_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if err_msg_obj: yield format_for_yield(err_msg_obj) + + # --- Process All Tool Results Now --- + if config.execute_tools: + final_tool_calls_to_process = [] + # ... (Gather final_tool_calls_to_process from native and XML buffers) ... + # Gather native tool calls from buffer + if config.native_tool_calling and complete_native_tool_calls: + for tc in complete_native_tool_calls: + final_tool_calls_to_process.append({ + "function_name": tc["function"]["name"], + "arguments": tc["function"]["arguments"], # Already parsed object + "id": tc["id"] + }) + # Gather XML tool calls from buffer (up to limit) + parsed_xml_data = [] + if config.xml_tool_calling: + # Reparse remaining content just in case (should be empty if processed correctly) + xml_chunks = self._extract_xml_chunks(current_xml_content) + xml_chunks_buffer.extend(xml_chunks) + # Process only chunks not already handled in the stream loop + remaining_limit = config.max_xml_tool_calls - xml_tool_call_count if config.max_xml_tool_calls > 0 else len(xml_chunks_buffer) + xml_chunks_to_process = xml_chunks_buffer[:remaining_limit] # Ensure limit is respected + + for chunk in xml_chunks_to_process: + parsed_result = self._parse_xml_tool_call(chunk) + if parsed_result: + tool_call, parsing_details = parsed_result + # Avoid adding if already processed during streaming + if not any(exec['tool_call'] == tool_call for exec in pending_tool_executions): + final_tool_calls_to_process.append(tool_call) + parsed_xml_data.append({'tool_call': tool_call, 'parsing_details': parsing_details}) + + + all_tool_data_map = {} # tool_index -> {'tool_call': ..., 'parsing_details': ...} + # Add native tool data + native_tool_index = 0 + if config.native_tool_calling and complete_native_tool_calls: + for tc in complete_native_tool_calls: + # Find the corresponding entry in final_tool_calls_to_process if needed + # For now, assume order matches if only native used + exec_tool_call = { + "function_name": tc["function"]["name"], + "arguments": tc["function"]["arguments"], + "id": tc["id"] + } + all_tool_data_map[native_tool_index] = {"tool_call": exec_tool_call, "parsing_details": None} + native_tool_index += 1 + + # Add XML tool data + xml_tool_index_start = native_tool_index + for idx, item in enumerate(parsed_xml_data): + all_tool_data_map[xml_tool_index_start + idx] = item + + + tool_results_map = {} # tool_index -> (tool_call, result, context) + + # Populate from buffer if executed on stream + if config.execute_on_stream and tool_results_buffer: + logger.info(f"Processing {len(tool_results_buffer)} buffered tool results") + self.trace.event(name="processing_buffered_tool_results", level="DEFAULT", status_message=(f"Processing {len(tool_results_buffer)} buffered tool results")) + for tool_call, result, tool_idx, context in tool_results_buffer: + if last_assistant_message_object: context.assistant_message_id = last_assistant_message_object['message_id'] + tool_results_map[tool_idx] = (tool_call, result, context) + + # Or execute now if not streamed + elif final_tool_calls_to_process and not config.execute_on_stream: + logger.info(f"Executing {len(final_tool_calls_to_process)} tools ({config.tool_execution_strategy}) after stream") + self.trace.event(name="executing_tools_after_stream", level="DEFAULT", status_message=(f"Executing {len(final_tool_calls_to_process)} tools ({config.tool_execution_strategy}) after stream")) + results_list = await self._execute_tools(final_tool_calls_to_process, config.tool_execution_strategy) + current_tool_idx = 0 + for tc, res in results_list: + # Map back using all_tool_data_map which has correct indices + if current_tool_idx in all_tool_data_map: + tool_data = all_tool_data_map[current_tool_idx] + context = self._create_tool_context( + tc, current_tool_idx, + last_assistant_message_object['message_id'] if last_assistant_message_object else None, + tool_data.get('parsing_details') + ) + context.result = res + tool_results_map[current_tool_idx] = (tc, res, context) + else: + logger.warning(f"Could not map result for tool index {current_tool_idx}") + self.trace.event(name="could_not_map_result_for_tool_index", level="WARNING", status_message=(f"Could not map result for tool index {current_tool_idx}")) + current_tool_idx += 1 + + # Save and Yield each result message + if tool_results_map: + logger.info(f"Saving and yielding {len(tool_results_map)} final tool result messages") + self.trace.event(name="saving_and_yielding_final_tool_result_messages", level="DEFAULT", status_message=(f"Saving and yielding {len(tool_results_map)} final tool result messages")) + for tool_idx in sorted(tool_results_map.keys()): + tool_call, result, context = tool_results_map[tool_idx] + context.result = result + if not context.assistant_message_id and last_assistant_message_object: + context.assistant_message_id = last_assistant_message_object['message_id'] + + # Yield start status ONLY IF executing non-streamed (already yielded if streamed) + if not config.execute_on_stream and tool_idx not in yielded_tool_indices: + started_msg_obj = await self._yield_and_save_tool_started(context, thread_id, thread_run_id) + if started_msg_obj: yield format_for_yield(started_msg_obj) + yielded_tool_indices.add(tool_idx) # Mark status yielded + + # Save the tool result message to DB + saved_tool_result_object = await self._add_tool_result( # Returns full object or None + thread_id, tool_call, result, config.xml_adding_strategy, + context.assistant_message_id, context.parsing_details + ) + + # Yield completed/failed status (linked to saved result ID if available) + completed_msg_obj = await self._yield_and_save_tool_completed( + context, + saved_tool_result_object['message_id'] if saved_tool_result_object else None, + thread_id, thread_run_id + ) + if completed_msg_obj: yield format_for_yield(completed_msg_obj) + # Don't add to yielded_tool_indices here, completion status is separate yield + + # Yield the saved tool result object + if saved_tool_result_object: + tool_result_message_objects[tool_idx] = saved_tool_result_object + yield format_for_yield(saved_tool_result_object) + else: + logger.error(f"Failed to save tool result for index {tool_idx}, not yielding result message.") + 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? + + # --- Final Finish Status --- + if finish_reason and finish_reason != "xml_tool_limit_reached": + finish_content = {"status_type": "finish", "finish_reason": finish_reason} + finish_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=finish_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if finish_msg_obj: yield format_for_yield(finish_msg_obj) + + # Check if agent should terminate after processing pending tools + if agent_should_terminate: + logger.info("Agent termination requested after executing ask/complete tool. Stopping further processing.") + self.trace.event(name="agent_termination_requested", level="DEFAULT", status_message="Agent termination requested after executing ask/complete tool. Stopping further processing.") + + # Set finish reason to indicate termination + finish_reason = "agent_terminated" + + # Save and yield termination status + finish_content = {"status_type": "finish", "finish_reason": "agent_terminated"} + finish_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=finish_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + 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)}")) + # Save and yield error status message + err_content = {"role": "system", "status_type": "error", "message": str(e)} + err_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=err_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} + ) + if err_msg_obj: yield format_for_yield(err_msg_obj) # Yield the saved error message + + # Re-raise the same exception (not a new one) to ensure proper error propagation + logger.critical(f"Re-raising error to stop further processing: {str(e)}") + self.trace.event(name="re_raising_error_to_stop_further_processing", level="ERROR", status_message=(f"Re-raising error to stop further processing: {str(e)}")) + raise # Use bare 'raise' to preserve the original exception with its traceback + + finally: + # Save and Yield the final thread_run_end status + try: + end_content = {"status_type": "thread_run_end"} + end_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=end_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} + ) + if end_msg_obj: yield format_for_yield(end_msg_obj) + except Exception as final_e: + logger.error(f"Error in finally block: {str(final_e)}", exc_info=True) + self.trace.event(name="error_in_finally_block", level="ERROR", status_message=(f"Error in finally block: {str(final_e)}")) + + async def process_non_streaming_response( + self, + llm_response: Any, + thread_id: str, + prompt_messages: List[Dict[str, Any]], + llm_model: str, + config: ProcessorConfig = ProcessorConfig(), + ) -> AsyncGenerator[Dict[str, Any], None]: + """Process a non-streaming LLM response, handling tool calls and execution. + + Args: + llm_response: Response from the LLM + thread_id: ID of the conversation thread + prompt_messages: List of messages sent to the LLM (the prompt) + llm_model: The name of the LLM model used + config: Configuration for parsing and execution + + Yields: + Complete message objects matching the DB schema. + """ + content = "" + thread_run_id = str(uuid.uuid4()) + all_tool_data = [] # Stores {'tool_call': ..., 'parsing_details': ...} + tool_index = 0 + assistant_message_object = None + tool_result_message_objects = {} + finish_reason = None + native_tool_calls_for_message = [] + + try: + # Save and Yield thread_run_start status message + start_content = {"status_type": "thread_run_start", "thread_run_id": thread_run_id} + start_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=start_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if start_msg_obj: yield format_for_yield(start_msg_obj) + + # Extract finish_reason, content, tool calls + if hasattr(llm_response, 'choices') and llm_response.choices: + if hasattr(llm_response.choices[0], 'finish_reason'): + finish_reason = llm_response.choices[0].finish_reason + logger.info(f"Non-streaming finish_reason: {finish_reason}") + self.trace.event(name="non_streaming_finish_reason", level="DEFAULT", status_message=(f"Non-streaming finish_reason: {finish_reason}")) + response_message = llm_response.choices[0].message if hasattr(llm_response.choices[0], 'message') else None + if response_message: + if hasattr(response_message, 'content') and response_message.content: + content = response_message.content + if config.xml_tool_calling: + parsed_xml_data = self._parse_xml_tool_calls(content) + if config.max_xml_tool_calls > 0 and len(parsed_xml_data) > config.max_xml_tool_calls: + # Truncate content and tool data if limit exceeded + # ... (Truncation logic similar to streaming) ... + if parsed_xml_data: + xml_chunks = self._extract_xml_chunks(content)[:config.max_xml_tool_calls] + if xml_chunks: + last_chunk = xml_chunks[-1] + last_chunk_pos = content.find(last_chunk) + if last_chunk_pos >= 0: content = content[:last_chunk_pos + len(last_chunk)] + parsed_xml_data = parsed_xml_data[:config.max_xml_tool_calls] + finish_reason = "xml_tool_limit_reached" + all_tool_data.extend(parsed_xml_data) + + if config.native_tool_calling and hasattr(response_message, 'tool_calls') and response_message.tool_calls: + for tool_call in response_message.tool_calls: + if hasattr(tool_call, 'function'): + exec_tool_call = { + "function_name": tool_call.function.name, + "arguments": safe_json_parse(tool_call.function.arguments) if isinstance(tool_call.function.arguments, str) else tool_call.function.arguments, + "id": tool_call.id if hasattr(tool_call, 'id') else str(uuid.uuid4()) + } + all_tool_data.append({"tool_call": exec_tool_call, "parsing_details": None}) + native_tool_calls_for_message.append({ + "id": exec_tool_call["id"], "type": "function", + "function": { + "name": tool_call.function.name, + "arguments": tool_call.function.arguments if isinstance(tool_call.function.arguments, str) else to_json_string(tool_call.function.arguments) + } + }) + + + # --- SAVE and YIELD Final Assistant Message --- + message_data = {"role": "assistant", "content": content, "tool_calls": native_tool_calls_for_message or None} + assistant_message_object = await self._add_message_with_agent_info( + thread_id=thread_id, type="assistant", content=message_data, + is_llm_message=True, metadata={"thread_run_id": thread_run_id} + ) + if assistant_message_object: + yield assistant_message_object + else: + logger.error(f"Failed to save non-streaming assistant message for thread {thread_id}") + self.trace.event(name="failed_to_save_non_streaming_assistant_message_for_thread", level="ERROR", status_message=(f"Failed to save non-streaming assistant message for thread {thread_id}")) + err_content = {"role": "system", "status_type": "error", "message": "Failed to save assistant message"} + err_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=err_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + if err_msg_obj: yield format_for_yield(err_msg_obj) + + # --- 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}") + self.trace.event(name="executing_tools_with_strategy", level="DEFAULT", status_message=(f"Executing {len(tool_calls_to_execute)} tools with strategy: {config.tool_execution_strategy}")) + tool_results = await self._execute_tools(tool_calls_to_execute, config.tool_execution_strategy) + + for i, (returned_tool_call, result) in enumerate(tool_results): + original_data = all_tool_data[i] + tool_call_from_data = original_data['tool_call'] + parsing_details = original_data['parsing_details'] + current_assistant_id = assistant_message_object['message_id'] if assistant_message_object else None + + context = self._create_tool_context( + tool_call_from_data, tool_index, current_assistant_id, parsing_details + ) + context.result = result + + # Save and Yield start status + started_msg_obj = await self._yield_and_save_tool_started(context, thread_id, thread_run_id) + if started_msg_obj: yield format_for_yield(started_msg_obj) + + # Save tool result + saved_tool_result_object = await self._add_tool_result( + thread_id, tool_call_from_data, result, config.xml_adding_strategy, + current_assistant_id, parsing_details + ) + + # Save and Yield completed/failed status + completed_msg_obj = await self._yield_and_save_tool_completed( + context, + saved_tool_result_object['message_id'] if saved_tool_result_object else None, + thread_id, thread_run_id + ) + if completed_msg_obj: yield format_for_yield(completed_msg_obj) + + # Yield the saved tool result object + if saved_tool_result_object: + tool_result_message_objects[tool_index] = saved_tool_result_object + yield format_for_yield(saved_tool_result_object) + else: + logger.error(f"Failed to save tool result for index {tool_index}") + self.trace.event(name="failed_to_save_tool_result_for_index", level="ERROR", status_message=(f"Failed to save tool result for index {tool_index}")) + + tool_index += 1 + + # --- Save and Yield Final Status --- + if finish_reason: + finish_content = {"status_type": "finish", "finish_reason": finish_reason} + finish_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=finish_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id} + ) + 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)}")) + # Save and yield error status + err_content = {"role": "system", "status_type": "error", "message": str(e)} + err_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=err_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} + ) + if err_msg_obj: yield format_for_yield(err_msg_obj) + + # Re-raise the same exception (not a new one) to ensure proper error propagation + logger.critical(f"Re-raising error to stop further processing: {str(e)}") + self.trace.event(name="re_raising_error_to_stop_further_processing", level="CRITICAL", status_message=(f"Re-raising error to stop further processing: {str(e)}")) + raise # Use bare 'raise' to preserve the original exception with its traceback + + finally: + # Save and Yield the final thread_run_end status + end_content = {"status_type": "thread_run_end"} + end_msg_obj = await self.add_message( + thread_id=thread_id, type="status", content=end_content, + is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} + ) + if end_msg_obj: yield format_for_yield(end_msg_obj) + + # XML parsing methods + def _extract_tag_content(self, xml_chunk: str, tag_name: str) -> Tuple[Optional[str], Optional[str]]: + """Extract content between opening and closing tags, handling nested tags.""" + start_tag = f'<{tag_name}' + end_tag = f'' + + try: + # Find start tag position + start_pos = xml_chunk.find(start_tag) + if start_pos == -1: + return None, xml_chunk + + # Find end of opening tag + tag_end = xml_chunk.find('>', start_pos) + if tag_end == -1: + return None, xml_chunk + + # Find matching closing tag + content_start = tag_end + 1 + nesting_level = 1 + pos = content_start + + while nesting_level > 0 and pos < len(xml_chunk): + next_start = xml_chunk.find(start_tag, pos) + next_end = xml_chunk.find(end_tag, pos) + + if next_end == -1: + return None, xml_chunk + + if next_start != -1 and next_start < next_end: + nesting_level += 1 + pos = next_start + len(start_tag) + else: + nesting_level -= 1 + if nesting_level == 0: + content = xml_chunk[content_start:next_end] + remaining = xml_chunk[next_end + len(end_tag):] + return content, remaining + else: + pos = next_end + len(end_tag) + + return None, xml_chunk + + except Exception as e: + logger.error(f"Error extracting tag content: {e}") + self.trace.event(name="error_extracting_tag_content", level="ERROR", status_message=(f"Error extracting tag content: {e}")) + return None, xml_chunk + + def _extract_attribute(self, opening_tag: str, attr_name: str) -> Optional[str]: + """Extract attribute value from opening tag.""" + try: + # Handle both single and double quotes with raw strings + patterns = [ + fr'{attr_name}="([^"]*)"', # Double quotes + fr"{attr_name}='([^']*)'", # Single quotes + fr'{attr_name}=([^\s/>;]+)' # No quotes - fixed escape sequence + ] + + for pattern in patterns: + match = re.search(pattern, opening_tag) + if match: + value = match.group(1) + # Unescape common XML entities + value = value.replace('"', '"').replace(''', "'") + value = value.replace('<', '<').replace('>', '>') + value = value.replace('&', '&') + return value + + return None + + except Exception as e: + logger.error(f"Error extracting attribute: {e}") + self.trace.event(name="error_extracting_attribute", level="ERROR", status_message=(f"Error extracting attribute: {e}")) + return None + + def _extract_xml_chunks(self, content: str) -> List[str]: + """Extract complete XML chunks using start and end pattern matching.""" + chunks = [] + pos = 0 + + try: + # First, look for new format blocks + start_pattern = '' + end_pattern = '' + + while pos < len(content): + # Find the next function_calls block + start_pos = content.find(start_pattern, pos) + if start_pos == -1: + break + + # Find the matching end tag + end_pos = content.find(end_pattern, start_pos) + if end_pos == -1: + break + + # Extract the complete block including tags + chunk_end = end_pos + len(end_pattern) + chunk = content[start_pos:chunk_end] + chunks.append(chunk) + + # Move position past this chunk + pos = chunk_end + + # If no new format found, fall back to old format for backwards compatibility + if not chunks: + pos = 0 + while pos < len(content): + # Find the next tool tag + next_tag_start = -1 + current_tag = None + + # Find the earliest occurrence of any registered tag + for tag_name in self.tool_registry.xml_tools.keys(): + start_pattern = f'<{tag_name}' + tag_pos = content.find(start_pattern, pos) + + if tag_pos != -1 and (next_tag_start == -1 or tag_pos < next_tag_start): + next_tag_start = tag_pos + current_tag = tag_name + + if next_tag_start == -1 or not current_tag: + break + + # Find the matching end tag + end_pattern = f'' + tag_stack = [] + chunk_start = next_tag_start + current_pos = next_tag_start + + while current_pos < len(content): + # Look for next start or end tag of the same type + next_start = content.find(f'<{current_tag}', current_pos + 1) + next_end = content.find(end_pattern, current_pos) + + if next_end == -1: # No closing tag found + break + + if next_start != -1 and next_start < next_end: + # Found nested start tag + tag_stack.append(next_start) + current_pos = next_start + 1 + else: + # Found end tag + if not tag_stack: # This is our matching end tag + chunk_end = next_end + len(end_pattern) + chunk = content[chunk_start:chunk_end] + chunks.append(chunk) + pos = chunk_end + break + else: + # Pop nested tag + tag_stack.pop() + current_pos = next_end + 1 + + if current_pos >= len(content): # Reached end without finding closing tag + break + + pos = max(pos + 1, current_pos) + + except Exception as e: + logger.error(f"Error extracting XML chunks: {e}") + logger.error(f"Content was: {content}") + self.trace.event(name="error_extracting_xml_chunks", level="ERROR", status_message=(f"Error extracting XML chunks: {e}"), metadata={"content": content}) + + return chunks + + def _parse_xml_tool_call(self, xml_chunk: str) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]: + """Parse XML chunk into tool call format and return parsing details. + + Returns: + Tuple of (tool_call, parsing_details) or None if parsing fails. + - tool_call: Dict with 'function_name', 'xml_tag_name', 'arguments' + - parsing_details: Dict with 'attributes', 'elements', 'text_content', 'root_content' + """ + try: + # Check if this is the new format (contains ) + if '' in xml_chunk and ']+)', xml_chunk) + if not tag_match: + logger.error(f"No tag found in XML chunk: {xml_chunk}") + self.trace.event(name="no_tag_found_in_xml_chunk", level="ERROR", status_message=(f"No tag found in XML chunk: {xml_chunk}")) + return None + + # This is the XML tag as it appears in the text (e.g., "create-file") + xml_tag_name = tag_match.group(1) + logger.info(f"Found XML tag: {xml_tag_name}") + self.trace.event(name="found_xml_tag", level="DEFAULT", status_message=(f"Found XML tag: {xml_tag_name}")) + + # Get tool info and schema from registry + tool_info = self.tool_registry.get_xml_tool(xml_tag_name) + if not tool_info or not tool_info['schema'].xml_schema: + logger.error(f"No tool or schema found for tag: {xml_tag_name}") + self.trace.event(name="no_tool_or_schema_found_for_tag", level="ERROR", status_message=(f"No tool or schema found for tag: {xml_tag_name}")) + return None + + # This is the actual function name to call (e.g., "create_file") + function_name = tool_info['method'] + + schema = tool_info['schema'].xml_schema + params = {} + remaining_chunk = xml_chunk + + # --- Store detailed parsing info --- + parsing_details = { + "attributes": {}, + "elements": {}, + "text_content": None, + "root_content": None, + "raw_chunk": xml_chunk # Store the original chunk for reference + } + # --- + + # Process each mapping + for mapping in schema.mappings: + try: + if mapping.node_type == "attribute": + # Extract attribute from opening tag + opening_tag = remaining_chunk.split('>', 1)[0] + value = self._extract_attribute(opening_tag, mapping.param_name) + if value is not None: + params[mapping.param_name] = value + parsing_details["attributes"][mapping.param_name] = value # Store raw attribute + # logger.info(f"Found attribute {mapping.param_name}: {value}") + + elif mapping.node_type == "element": + # Extract element content + content, remaining_chunk = self._extract_tag_content(remaining_chunk, mapping.path) + if content is not None: + params[mapping.param_name] = content.strip() + parsing_details["elements"][mapping.param_name] = content.strip() # Store raw element content + # logger.info(f"Found element {mapping.param_name}: {content.strip()}") + + elif mapping.node_type == "text": + # Extract text content + content, _ = self._extract_tag_content(remaining_chunk, xml_tag_name) + if content is not None: + params[mapping.param_name] = content.strip() + parsing_details["text_content"] = content.strip() # Store raw text content + # logger.info(f"Found text content for {mapping.param_name}: {content.strip()}") + + elif mapping.node_type == "content": + # Extract root content + content, _ = self._extract_tag_content(remaining_chunk, xml_tag_name) + if content is not None: + params[mapping.param_name] = content.strip() + parsing_details["root_content"] = content.strip() # Store raw root content + # logger.info(f"Found root content for {mapping.param_name}") + + except Exception as e: + logger.error(f"Error processing mapping {mapping}: {e}") + self.trace.event(name="error_processing_mapping", level="ERROR", status_message=(f"Error processing mapping {mapping}: {e}")) + continue + + # Create tool call with clear separation between function_name and xml_tag_name + tool_call = { + "function_name": function_name, # The actual method to call (e.g., create_file) + "xml_tag_name": xml_tag_name, # The original XML tag (e.g., create-file) + "arguments": params # The extracted parameters + } + + # logger.debug(f"Parsed old format tool call: {tool_call["function_name"]}") + return tool_call, parsing_details # Return both dicts + + except Exception as e: + logger.error(f"Error parsing XML chunk: {e}") + logger.error(f"XML chunk was: {xml_chunk}") + self.trace.event(name="error_parsing_xml_chunk", level="ERROR", status_message=(f"Error parsing XML chunk: {e}"), metadata={"xml_chunk": xml_chunk}) + return None + + def _parse_xml_tool_calls(self, content: str) -> List[Dict[str, Any]]: + """Parse XML tool calls from content string. + + Returns: + List of dictionaries, each containing {'tool_call': ..., 'parsing_details': ...} + """ + parsed_data = [] + + try: + xml_chunks = self._extract_xml_chunks(content) + + for xml_chunk in xml_chunks: + result = self._parse_xml_tool_call(xml_chunk) + if result: + tool_call, parsing_details = result + parsed_data.append({ + "tool_call": tool_call, + "parsing_details": parsing_details + }) + + except Exception as e: + logger.error(f"Error parsing XML tool calls: {e}", exc_info=True) + self.trace.event(name="error_parsing_xml_tool_calls", level="ERROR", status_message=(f"Error parsing XML tool calls: {e}"), metadata={"content": content}) + + return parsed_data + + # Tool execution methods + async def _execute_tool(self, tool_call: Dict[str, Any]) -> ToolResult: + """Execute a single tool call and return the result.""" + span = self.trace.span(name=f"execute_tool.{tool_call['function_name']}", input=tool_call["arguments"]) + try: + function_name = tool_call["function_name"] + arguments = tool_call["arguments"] + + logger.info(f"Executing tool: {function_name} with arguments: {arguments}") + self.trace.event(name="executing_tool", level="DEFAULT", status_message=(f"Executing tool: {function_name} with arguments: {arguments}")) + + if isinstance(arguments, str): + try: + arguments = safe_json_parse(arguments) + except json.JSONDecodeError: + arguments = {"text": arguments} + + # Get available functions from tool registry + available_functions = self.tool_registry.get_available_functions() + + # Look up the function by name + tool_fn = available_functions.get(function_name) + if not tool_fn: + logger.error(f"Tool function '{function_name}' not found in registry") + span.end(status_message="tool_not_found", level="ERROR") + return ToolResult(success=False, output=f"Tool function '{function_name}' not found") + + logger.debug(f"Found tool function for '{function_name}', executing...") + result = await tool_fn(**arguments) + logger.info(f"Tool execution complete: {function_name} -> {result}") + span.end(status_message="tool_executed", output=result) + return result + except Exception as e: + logger.error(f"Error executing tool {tool_call['function_name']}: {str(e)}", exc_info=True) + span.end(status_message="tool_execution_error", output=f"Error executing tool: {str(e)}", level="ERROR") + return ToolResult(success=False, output=f"Error executing tool: {str(e)}") + + async def _execute_tools( + self, + tool_calls: List[Dict[str, Any]], + execution_strategy: ToolExecutionStrategy = "sequential" + ) -> List[Tuple[Dict[str, Any], ToolResult]]: + """Execute tool calls with the specified strategy. + + This is the main entry point for tool execution. It dispatches to the appropriate + execution method based on the provided strategy. + + Args: + tool_calls: List of tool calls to execute + execution_strategy: Strategy for executing tools: + - "sequential": Execute tools one after another, waiting for each to complete + - "parallel": Execute all tools simultaneously for better performance + + Returns: + List of tuples containing the original tool call and its result + """ + logger.info(f"Executing {len(tool_calls)} tools with strategy: {execution_strategy}") + self.trace.event(name="executing_tools_with_strategy", level="DEFAULT", status_message=(f"Executing {len(tool_calls)} tools with strategy: {execution_strategy}")) + + if execution_strategy == "sequential": + return await self._execute_tools_sequentially(tool_calls) + elif execution_strategy == "parallel": + return await self._execute_tools_in_parallel(tool_calls) + else: + logger.warning(f"Unknown execution strategy: {execution_strategy}, falling back to sequential") + return await self._execute_tools_sequentially(tool_calls) + + async def _execute_tools_sequentially(self, tool_calls: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], ToolResult]]: + """Execute tool calls sequentially and return results. + + This method executes tool calls one after another, waiting for each tool to complete + before starting the next one. This is useful when tools have dependencies on each other. + + Args: + tool_calls: List of tool calls to execute + + Returns: + List of tuples containing the original tool call and its result + """ + if not tool_calls: + return [] + + try: + tool_names = [t.get('function_name', 'unknown') for t in tool_calls] + logger.info(f"Executing {len(tool_calls)} tools sequentially: {tool_names}") + self.trace.event(name="executing_tools_sequentially", level="DEFAULT", status_message=(f"Executing {len(tool_calls)} tools sequentially: {tool_names}")) + + results = [] + for index, tool_call in enumerate(tool_calls): + tool_name = tool_call.get('function_name', 'unknown') + logger.debug(f"Executing tool {index+1}/{len(tool_calls)}: {tool_name}") + + try: + result = await self._execute_tool(tool_call) + results.append((tool_call, result)) + logger.debug(f"Completed tool {tool_name} with success={result.success}") + + # Check if this is a terminating tool (ask or complete) + if tool_name in ['ask', 'complete']: + logger.info(f"Terminating tool '{tool_name}' executed. Stopping further tool execution.") + self.trace.event(name="terminating_tool_executed", level="DEFAULT", status_message=(f"Terminating tool '{tool_name}' executed. Stopping further tool execution.")) + break # Stop executing remaining tools + + except Exception as e: + logger.error(f"Error executing tool {tool_name}: {str(e)}") + self.trace.event(name="error_executing_tool", level="ERROR", status_message=(f"Error executing tool {tool_name}: {str(e)}")) + error_result = ToolResult(success=False, output=f"Error executing tool: {str(e)}") + results.append((tool_call, error_result)) + + logger.info(f"Sequential execution completed for {len(results)} tools (out of {len(tool_calls)} total)") + self.trace.event(name="sequential_execution_completed", level="DEFAULT", status_message=(f"Sequential execution completed for {len(results)} tools (out of {len(tool_calls)} total)")) + return results + + except Exception as e: + logger.error(f"Error in sequential tool execution: {str(e)}", exc_info=True) + # Return partial results plus error results for remaining tools + completed_tool_names = [r[0].get('function_name', 'unknown') for r in results] if 'results' in locals() else [] + remaining_tools = [t for t in tool_calls if t.get('function_name', 'unknown') not in completed_tool_names] + + # Add error results for remaining tools + error_results = [(tool, ToolResult(success=False, output=f"Execution error: {str(e)}")) + for tool in remaining_tools] + + return (results if 'results' in locals() else []) + error_results + + async def _execute_tools_in_parallel(self, tool_calls: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], ToolResult]]: + """Execute tool calls in parallel and return results. + + This method executes all tool calls simultaneously using asyncio.gather, which + can significantly improve performance when executing multiple independent tools. + + Args: + tool_calls: List of tool calls to execute + + Returns: + List of tuples containing the original tool call and its result + """ + if not tool_calls: + return [] + + try: + tool_names = [t.get('function_name', 'unknown') for t in tool_calls] + logger.info(f"Executing {len(tool_calls)} tools in parallel: {tool_names}") + self.trace.event(name="executing_tools_in_parallel", level="DEFAULT", status_message=(f"Executing {len(tool_calls)} tools in parallel: {tool_names}")) + + # Create tasks for all tool calls + tasks = [self._execute_tool(tool_call) for tool_call in tool_calls] + + # Execute all tasks concurrently with error handling + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Process results and handle any exceptions + processed_results = [] + for i, (tool_call, result) in enumerate(zip(tool_calls, results)): + if isinstance(result, Exception): + logger.error(f"Error executing tool {tool_call.get('function_name', 'unknown')}: {str(result)}") + self.trace.event(name="error_executing_tool", level="ERROR", status_message=(f"Error executing tool {tool_call.get('function_name', 'unknown')}: {str(result)}")) + # Create error result + error_result = ToolResult(success=False, output=f"Error executing tool: {str(result)}") + processed_results.append((tool_call, error_result)) + else: + processed_results.append((tool_call, result)) + + logger.info(f"Parallel execution completed for {len(tool_calls)} tools") + self.trace.event(name="parallel_execution_completed", level="DEFAULT", status_message=(f"Parallel execution completed for {len(tool_calls)} tools")) + return processed_results + + except Exception as e: + logger.error(f"Error in parallel tool execution: {str(e)}", exc_info=True) + self.trace.event(name="error_in_parallel_tool_execution", level="ERROR", status_message=(f"Error in parallel tool execution: {str(e)}")) + # Return error results for all tools if the gather itself fails + return [(tool_call, ToolResult(success=False, output=f"Execution error: {str(e)}")) + for tool_call in tool_calls] + + async def _add_tool_result( + self, + thread_id: str, + tool_call: Dict[str, Any], + result: ToolResult, + strategy: Union[XmlAddingStrategy, str] = "assistant_message", + assistant_message_id: Optional[str] = None, + parsing_details: Optional[Dict[str, Any]] = None + ) -> Optional[Dict[str, Any]]: # Return the full message object + """Add a tool result to the conversation thread based on the specified format. + + This method formats tool results and adds them to the conversation history, + making them visible to the LLM in subsequent interactions. Results can be + added either as native tool messages (OpenAI format) or as XML-wrapped content + with a specified role (user or assistant). + + Args: + thread_id: ID of the conversation thread + tool_call: The original tool call that produced this result + result: The result from the tool execution + strategy: How to add XML tool results to the conversation + ("user_message", "assistant_message", or "inline_edit") + assistant_message_id: ID of the assistant message that generated this tool call + parsing_details: Detailed parsing info for XML calls (attributes, elements, etc.) + """ + try: + message_obj = None # Initialize message_obj + + # Create metadata with assistant_message_id if provided + metadata = {} + if assistant_message_id: + metadata["assistant_message_id"] = assistant_message_id + logger.info(f"Linking tool result to assistant message: {assistant_message_id}") + self.trace.event(name="linking_tool_result_to_assistant_message", level="DEFAULT", status_message=(f"Linking tool result to assistant message: {assistant_message_id}")) + + # --- Add parsing details to metadata if available --- + if parsing_details: + metadata["parsing_details"] = parsing_details + logger.info("Adding parsing_details to tool result metadata") + self.trace.event(name="adding_parsing_details_to_tool_result_metadata", level="DEFAULT", status_message=(f"Adding parsing_details to tool result metadata"), metadata={"parsing_details": parsing_details}) + # --- + + # Check if this is a native function call (has id field) + if "id" in tool_call: + # Format as a proper tool message according to OpenAI spec + function_name = tool_call.get("function_name", "") + + # Format the tool result content - tool role needs string content + if isinstance(result, str): + content = result + elif hasattr(result, 'output'): + # If it's a ToolResult object + if isinstance(result.output, dict) or isinstance(result.output, list): + # If output is already a dict or list, convert to JSON string + content = json.dumps(result.output) + else: + # Otherwise just use the string representation + content = str(result.output) + else: + # Fallback to string representation of the whole result + content = str(result) + + logger.info(f"Formatted tool result content: {content[:100]}...") + self.trace.event(name="formatted_tool_result_content", level="DEFAULT", status_message=(f"Formatted tool result content: {content[:100]}...")) + + # Create the tool response message with proper format + tool_message = { + "role": "tool", + "tool_call_id": tool_call["id"], + "name": function_name, + "content": content + } + + logger.info(f"Adding native tool result for tool_call_id={tool_call['id']} with role=tool") + self.trace.event(name="adding_native_tool_result_for_tool_call_id", level="DEFAULT", status_message=(f"Adding native tool result for tool_call_id={tool_call['id']} with role=tool")) + + # Add as a tool message to the conversation history + # This makes the result visible to the LLM in the next turn + message_obj = await self.add_message( + thread_id=thread_id, + type="tool", # Special type for tool responses + content=tool_message, + is_llm_message=True, + metadata=metadata + ) + return message_obj # Return the full message object + + # Check if this is an MCP tool (function_name starts with "call_mcp_tool") + function_name = tool_call.get("function_name", "") + + # Check if this is an MCP tool - either the old call_mcp_tool or a dynamically registered MCP tool + is_mcp_tool = False + if function_name == "call_mcp_tool": + is_mcp_tool = True + else: + # Check if the result indicates it's an MCP tool by looking for MCP metadata + if hasattr(result, 'output') and isinstance(result.output, str): + # Check for MCP metadata pattern in the output + if "MCP Tool Result from" in result.output and "Tool Metadata:" in result.output: + is_mcp_tool = True + # Also check for MCP metadata in JSON format + elif "mcp_metadata" in result.output: + is_mcp_tool = True + + if is_mcp_tool: + # Special handling for MCP tools - make content prominent and LLM-friendly + result_role = "user" if strategy == "user_message" else "assistant" + + # Extract the actual content from the ToolResult + if hasattr(result, 'output'): + mcp_content = str(result.output) + else: + mcp_content = str(result) + + # Create a simple, LLM-friendly message format that puts content first + simple_message = { + "role": result_role, + "content": mcp_content # Direct content, no complex nesting + } + + logger.info(f"Adding MCP tool result with simplified format for LLM visibility") + self.trace.event(name="adding_mcp_tool_result_simplified", level="DEFAULT", status_message="Adding MCP tool result with simplified format for LLM visibility") + + message_obj = await self.add_message( + thread_id=thread_id, + type="tool", + content=simple_message, + is_llm_message=True, + metadata=metadata + ) + return message_obj + + # For XML and other non-native tools, use the new structured format + # Determine message role based on strategy + result_role = "user" if strategy == "user_message" else "assistant" + + # Create the new structured tool result format + structured_result = self._create_structured_tool_result(tool_call, result, parsing_details) + + # Add the message with the appropriate role to the conversation history + # This allows the LLM to see the tool result in subsequent interactions + result_message = { + "role": result_role, + "content": json.dumps(structured_result) + } + message_obj = await self.add_message( + thread_id=thread_id, + type="tool", + content=result_message, + is_llm_message=True, + metadata=metadata + ) + return message_obj # Return the full message object + except Exception as e: + logger.error(f"Error adding tool result: {str(e)}", exc_info=True) + self.trace.event(name="error_adding_tool_result", level="ERROR", status_message=(f"Error adding tool result: {str(e)}"), metadata={"tool_call": tool_call, "result": result, "strategy": strategy, "assistant_message_id": assistant_message_id, "parsing_details": parsing_details}) + # Fallback to a simple message + try: + fallback_message = { + "role": "user", + "content": str(result) + } + message_obj = await self.add_message( + thread_id=thread_id, + type="tool", + content=fallback_message, + is_llm_message=True, + metadata={"assistant_message_id": assistant_message_id} if assistant_message_id else {} + ) + return message_obj # Return the full message object + except Exception as e2: + logger.error(f"Failed even with fallback message: {str(e2)}", exc_info=True) + self.trace.event(name="failed_even_with_fallback_message", level="ERROR", status_message=(f"Failed even with fallback message: {str(e2)}"), metadata={"tool_call": tool_call, "result": result, "strategy": strategy, "assistant_message_id": assistant_message_id, "parsing_details": parsing_details}) + return None # Return None on error + + def _create_structured_tool_result(self, tool_call: Dict[str, Any], result: ToolResult, parsing_details: Optional[Dict[str, Any]] = None): + """Create a structured tool result format that's tool-agnostic and provides rich information. + + Args: + tool_call: The original tool call that was executed + result: The result from the tool execution + parsing_details: Optional parsing details for XML calls + + Returns: + Structured dictionary containing tool execution information + """ + # Extract tool information + function_name = tool_call.get("function_name", "unknown") + xml_tag_name = tool_call.get("xml_tag_name") + arguments = tool_call.get("arguments", {}) + tool_call_id = tool_call.get("id") + logger.info(f"Creating structured tool result for tool_call: {tool_call}") + + # Process the output - if it's a JSON string, parse it back to an object + output = result.output if hasattr(result, 'output') else str(result) + if isinstance(output, str): + try: + # Try to parse as JSON to provide structured data to frontend + parsed_output = safe_json_parse(output) + # If parsing succeeded and we got a dict/list, use the parsed version + if isinstance(parsed_output, (dict, list)): + output = parsed_output + # Otherwise keep the original string + except Exception: + # If parsing fails, keep the original string + pass + + # Create the structured result + structured_result_v1 = { + "tool_execution": { + "function_name": function_name, + "xml_tag_name": xml_tag_name, + "tool_call_id": tool_call_id, + "arguments": arguments, + "result": { + "success": result.success if hasattr(result, 'success') else True, + "output": output, # Now properly structured for frontend + "error": getattr(result, 'error', None) if hasattr(result, 'error') else None + }, + # "execution_details": { + # "timestamp": datetime.now(timezone.utc).isoformat(), + # "parsing_details": parsing_details + # } + } + } + + # STRUCTURED_OUTPUT_TOOLS = { + # "str_replace", + # "get_data_provider_endpoints", + # } + + # summary_output = result.output if hasattr(result, 'output') else str(result) + + # if xml_tag_name: + # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" + # summary = f"Tool '{xml_tag_name}' {status}. Output: {summary_output}" + # else: + # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" + # summary = f"Function '{function_name}' {status}. Output: {summary_output}" + + # if self.is_agent_builder: + # return summary + # if function_name in STRUCTURED_OUTPUT_TOOLS: + # return structured_result_v1 + # else: + # return summary + + summary_output = result.output if hasattr(result, 'output') else str(result) + success_status = structured_result_v1["tool_execution"]["result"]["success"] + + # # Create a more comprehensive summary for the LLM + # if xml_tag_name: + # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" + # summary = f"Tool '{xml_tag_name}' {status}. Output: {summary_output}" + # else: + # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" + # summary = f"Function '{function_name}' {status}. Output: {summary_output}" + + # if self.is_agent_builder: + # return summary + # elif function_name == "get_data_provider_endpoints": + # logger.info(f"Returning sumnary for data provider call: {summary}") + # return summary + + return structured_result_v1 + + def _format_xml_tool_result(self, tool_call: Dict[str, Any], result: ToolResult) -> str: + """Format a tool result wrapped in a tag. + + DEPRECATED: This method is kept for backwards compatibility. + New implementations should use _create_structured_tool_result instead. + + Args: + tool_call: The tool call that was executed + result: The result of the tool execution + + Returns: + String containing the formatted result wrapped in tag + """ + # Always use xml_tag_name if it exists + if "xml_tag_name" in tool_call: + xml_tag_name = tool_call["xml_tag_name"] + return f" <{xml_tag_name}> {str(result)} " + + # Non-XML tool, just return the function result + function_name = tool_call["function_name"] + return f"Result for {function_name}: {str(result)}" + + def _create_tool_context(self, tool_call: Dict[str, Any], tool_index: int, assistant_message_id: Optional[str] = None, parsing_details: Optional[Dict[str, Any]] = None) -> ToolExecutionContext: + """Create a tool execution context with display name and parsing details populated.""" + context = ToolExecutionContext( + tool_call=tool_call, + tool_index=tool_index, + assistant_message_id=assistant_message_id, + parsing_details=parsing_details + ) + + # Set function_name and xml_tag_name fields + if "xml_tag_name" in tool_call: + context.xml_tag_name = tool_call["xml_tag_name"] + context.function_name = tool_call.get("function_name", tool_call["xml_tag_name"]) + else: + # For non-XML tools, use function name directly + context.function_name = tool_call.get("function_name", "unknown") + context.xml_tag_name = None + + return context + + async def _yield_and_save_tool_started(self, context: ToolExecutionContext, thread_id: str, thread_run_id: str) -> Optional[Dict[str, Any]]: + """Formats, saves, and returns a tool started status message.""" + tool_name = context.xml_tag_name or context.function_name + content = { + "role": "assistant", "status_type": "tool_started", + "function_name": context.function_name, "xml_tag_name": context.xml_tag_name, + "message": f"Starting execution of {tool_name}", "tool_index": context.tool_index, + "tool_call_id": context.tool_call.get("id") # Include tool_call ID if native + } + metadata = {"thread_run_id": thread_run_id} + 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 the full object (or None if saving failed) + + async def _yield_and_save_tool_completed(self, context: ToolExecutionContext, tool_message_id: Optional[str], thread_id: str, thread_run_id: str) -> Optional[Dict[str, Any]]: + """Formats, saves, and returns a tool completed/failed status message.""" + if not context.result: + # Delegate to error saving if result is missing (e.g., execution failed) + return await self._yield_and_save_tool_error(context, thread_id, thread_run_id) + + tool_name = context.xml_tag_name or context.function_name + status_type = "tool_completed" if context.result.success else "tool_failed" + message_text = f"Tool {tool_name} {'completed successfully' if context.result.success else 'failed'}" + + content = { + "role": "assistant", "status_type": status_type, + "function_name": context.function_name, "xml_tag_name": context.xml_tag_name, + "message": message_text, "tool_index": context.tool_index, + "tool_call_id": context.tool_call.get("id") + } + metadata = {"thread_run_id": thread_run_id} + # Add the *actual* tool result message ID to the metadata if available and successful + if context.result.success and tool_message_id: + metadata["linked_tool_result_message_id"] = tool_message_id + + # <<< ADDED: Signal if this is a terminating tool >>> + if context.function_name in ['ask', 'complete']: + metadata["agent_should_terminate"] = True + logger.info(f"Marking tool status for '{context.function_name}' with termination signal.") + self.trace.event(name="marking_tool_status_for_termination", level="DEFAULT", status_message=(f"Marking tool status for '{context.function_name}' with termination signal.")) + # <<< END ADDED >>> + + 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 + + async def _yield_and_save_tool_error(self, context: ToolExecutionContext, thread_id: str, thread_run_id: str) -> Optional[Dict[str, Any]]: + """Formats, saves, and returns a tool error status message.""" + error_msg = str(context.error) if context.error else "Unknown error during tool execution" + tool_name = context.xml_tag_name or context.function_name + content = { + "role": "assistant", "status_type": "tool_error", + "function_name": context.function_name, "xml_tag_name": context.xml_tag_name, + "message": f"Error executing tool {tool_name}: {error_msg}", + "tool_index": context.tool_index, + "tool_call_id": context.tool_call.get("id") + } + metadata = {"thread_run_id": thread_run_id} + # Save the status message with is_llm_message=False + 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 diff --git a/app/agentpress/thread_manager.py b/app/agentpress/thread_manager.py new file mode 100644 index 000000000..2eb963af6 --- /dev/null +++ b/app/agentpress/thread_manager.py @@ -0,0 +1,787 @@ +""" +Conversation thread management system for AgentPress. + +This module provides comprehensive conversation management, including: +- Thread creation and persistence +- Message handling with support for text and images +- Tool registration and execution +- LLM interaction with streaming support +- Error handling and cleanup +- Context summarization to manage token limits +""" + +import json +from typing import List, Dict, Any, Optional, Type, Union, AsyncGenerator, Literal +from services.llm import make_llm_api_call +from agentpress.tool import Tool +from agentpress.tool_registry import ToolRegistry +from agentpress.context_manager import ContextManager +from agentpress.response_processor import ( + ResponseProcessor, + ProcessorConfig +) +from services.supabase import DBConnection +from utils.logger import logger +from langfuse.client import StatefulGenerationClient, StatefulTraceClient +from services.langfuse import langfuse +import datetime +from litellm import token_counter + +# Type alias for tool choice +ToolChoice = Literal["auto", "required", "none"] + +class ThreadManager: + """Manages conversation threads with LLM models and tool execution. + + Provides comprehensive conversation management, handling message threading, + tool registration, and LLM interactions with support for both standard and + XML-based tool execution patterns. + """ + + def __init__(self, trace: Optional[StatefulTraceClient] = None, is_agent_builder: bool = False, target_agent_id: Optional[str] = None, agent_config: Optional[dict] = None): + """Initialize ThreadManager. + + Args: + trace: Optional trace client for logging + is_agent_builder: Whether this is an agent builder session + target_agent_id: ID of the agent being built (if in agent builder mode) + agent_config: Optional agent configuration with version information + """ + self.db = DBConnection() + self.tool_registry = ToolRegistry() + self.trace = trace + self.is_agent_builder = is_agent_builder + self.target_agent_id = target_agent_id + self.agent_config = agent_config + if not self.trace: + self.trace = langfuse.trace(name="anonymous:thread_manager") + self.response_processor = ResponseProcessor( + tool_registry=self.tool_registry, + add_message_callback=self.add_message, + trace=self.trace, + is_agent_builder=self.is_agent_builder, + target_agent_id=self.target_agent_id, + agent_config=self.agent_config + ) + 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, dict) and "interactive_elements" 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 + if isinstance(parsed_content, dict) and "interactive_elements" in content: return True + except (json.JSONDecodeError, TypeError): + pass + return False + + def _compress_message(self, msg_content: Union[str, dict], message_id: Optional[str] = None, max_length: int = 3000) -> Union[str, dict]: + """Compress the message content.""" + # print("max_length", max_length) + if isinstance(msg_content, str): + if len(msg_content) > max_length: + return msg_content[:max_length] + "... (truncated)" + f"\n\nmessage_id \"{message_id}\"\nUse expand-message tool to see contents" + else: + return msg_content + elif isinstance(msg_content, dict): + if len(json.dumps(msg_content)) > max_length: + return json.dumps(msg_content)[:max_length] + "... (truncated)" + f"\n\nmessage_id \"{message_id}\"\nUse expand-message tool to see contents" + else: + return msg_content + + def _safe_truncate(self, msg_content: Union[str, dict], max_length: int = 100000) -> Union[str, dict]: + """Truncate the message content safely by removing the middle portion.""" + max_length = min(max_length, 100000) + if isinstance(msg_content, str): + if len(msg_content) > max_length: + # Calculate how much to keep from start and end + keep_length = max_length - 150 # Reserve space for truncation message + start_length = keep_length // 2 + end_length = keep_length - start_length + + start_part = msg_content[:start_length] + end_part = msg_content[-end_length:] if end_length > 0 else "" + + return start_part + f"\n\n... (middle truncated) ...\n\n" + end_part + f"\n\nThis message is too long, repeat relevant information in your response to remember it" + else: + return msg_content + elif isinstance(msg_content, dict): + json_str = json.dumps(msg_content) + if len(json_str) > max_length: + # Calculate how much to keep from start and end + keep_length = max_length - 150 # Reserve space for truncation message + start_length = keep_length // 2 + end_length = keep_length - start_length + + start_part = json_str[:start_length] + end_part = json_str[-end_length:] if end_length > 0 else "" + + return start_part + f"\n\n... (middle truncated) ...\n\n" + end_part + f"\n\nThis message is too long, repeat relevant information in your response to remember it" + else: + return msg_content + + def _compress_tool_result_messages(self, messages: List[Dict[str, Any]], llm_model: str, max_tokens: Optional[int], token_threshold: Optional[int] = 1000) -> List[Dict[str, Any]]: + """Compress the tool result messages except the most recent one.""" + uncompressed_total_token_count = token_counter(model=llm_model, messages=messages) + + if uncompressed_total_token_count > (max_tokens or (100 * 1000)): + _i = 0 # Count the number of ToolResult messages + for msg in reversed(messages): # Start from the end and work backwards + 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 > token_threshold: # If the message is too long + if _i > 1: # If this is not the most recent ToolResult message + message_id = msg.get('message_id') # Get the message_id + if message_id: + msg["content"] = self._compress_message(msg["content"], message_id, token_threshold * 3) + else: + logger.warning(f"UNEXPECTED: Message has no message_id {str(msg)[:100]}") + else: + msg["content"] = self._safe_truncate(msg["content"], int(max_tokens * 2)) + return messages + + def _compress_user_messages(self, messages: List[Dict[str, Any]], llm_model: str, max_tokens: Optional[int], token_threshold: Optional[int] = 1000) -> List[Dict[str, Any]]: + """Compress the user messages except the most recent one.""" + uncompressed_total_token_count = token_counter(model=llm_model, messages=messages) + + if uncompressed_total_token_count > (max_tokens or (100 * 1000)): + _i = 0 # Count the number of User messages + for msg in reversed(messages): # Start from the end and work backwards + if msg.get('role') == 'user': # Only compress User messages + _i += 1 # Count the number of User messages + msg_token_count = token_counter(messages=[msg]) # Count the number of tokens in the message + if msg_token_count > token_threshold: # If the message is too long + if _i > 1: # If this is not the most recent User message + message_id = msg.get('message_id') # Get the message_id + if message_id: + msg["content"] = self._compress_message(msg["content"], message_id, token_threshold * 3) + else: + logger.warning(f"UNEXPECTED: Message has no message_id {str(msg)[:100]}") + else: + msg["content"] = self._safe_truncate(msg["content"], int(max_tokens * 2)) + return messages + + def _compress_assistant_messages(self, messages: List[Dict[str, Any]], llm_model: str, max_tokens: Optional[int], token_threshold: Optional[int] = 1000) -> List[Dict[str, Any]]: + """Compress the assistant messages except the most recent one.""" + uncompressed_total_token_count = token_counter(model=llm_model, messages=messages) + if uncompressed_total_token_count > (max_tokens or (100 * 1000)): + _i = 0 # Count the number of Assistant messages + for msg in reversed(messages): # Start from the end and work backwards + if msg.get('role') == 'assistant': # Only compress Assistant messages + _i += 1 # Count the number of Assistant messages + msg_token_count = token_counter(messages=[msg]) # Count the number of tokens in the message + if msg_token_count > token_threshold: # If the message is too long + if _i > 1: # If this is not the most recent Assistant message + message_id = msg.get('message_id') # Get the message_id + if message_id: + msg["content"] = self._compress_message(msg["content"], message_id, token_threshold * 3) + else: + logger.warning(f"UNEXPECTED: Message has no message_id {str(msg)[:100]}") + else: + msg["content"] = self._safe_truncate(msg["content"], int(max_tokens * 2)) + + return messages + + + def _remove_meta_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Remove meta messages from the messages.""" + result: List[Dict[str, Any]] = [] + for msg in messages: + msg_content = msg.get('content') + # Try to parse msg_content as JSON if it's a string + if isinstance(msg_content, str): + try: msg_content = json.loads(msg_content) + except json.JSONDecodeError: pass + if isinstance(msg_content, dict): + # Create a copy to avoid modifying the original + msg_content_copy = msg_content.copy() + if "tool_execution" in msg_content_copy: + tool_execution = msg_content_copy["tool_execution"].copy() + if "arguments" in tool_execution: + del tool_execution["arguments"] + msg_content_copy["tool_execution"] = tool_execution + # Create a new message dict with the modified content + new_msg = msg.copy() + new_msg["content"] = json.dumps(msg_content_copy) + result.append(new_msg) + else: + result.append(msg) + return result + + def _compress_messages(self, messages: List[Dict[str, Any]], llm_model: str, max_tokens: Optional[int] = 41000, token_threshold: Optional[int] = 4096, max_iterations: int = 5) -> List[Dict[str, Any]]: + """Compress the messages. + token_threshold: must be a power of 2 + """ + + if 'sonnet' in llm_model.lower(): + max_tokens = 200 * 1000 - 64000 - 28000 + elif 'gpt' in llm_model.lower(): + max_tokens = 128 * 1000 - 28000 + elif 'gemini' in llm_model.lower(): + max_tokens = 1000 * 1000 - 300000 + elif 'deepseek' in llm_model.lower(): + max_tokens = 128 * 1000 - 28000 + else: + max_tokens = 41 * 1000 - 10000 + + result = messages + result = self._remove_meta_messages(result) + + uncompressed_total_token_count = token_counter(model=llm_model, messages=result) + + result = self._compress_tool_result_messages(result, llm_model, max_tokens, token_threshold) + result = self._compress_user_messages(result, llm_model, max_tokens, token_threshold) + result = self._compress_assistant_messages(result, llm_model, max_tokens, token_threshold) + + compressed_token_count = token_counter(model=llm_model, messages=result) + + logger.info(f"_compress_messages: {uncompressed_total_token_count} -> {compressed_token_count}") # Log the token compression for debugging later + + if max_iterations <= 0: + logger.warning(f"_compress_messages: Max iterations reached, omitting messages") + result = self._compress_messages_by_omitting_messages(messages, llm_model, max_tokens) + return result + + if (compressed_token_count > max_tokens): + logger.warning(f"Further token compression is needed: {compressed_token_count} > {max_tokens}") + result = self._compress_messages(messages, llm_model, max_tokens, int(token_threshold / 2), max_iterations - 1) + + return self._middle_out_messages(result) + + def _compress_messages_by_omitting_messages( + self, + messages: List[Dict[str, Any]], + llm_model: str, + max_tokens: Optional[int] = 41000, + removal_batch_size: int = 10, + min_messages_to_keep: int = 10 + ) -> List[Dict[str, Any]]: + """Compress the messages by omitting messages from the middle. + + Args: + messages: List of messages to compress + llm_model: Model name for token counting + max_tokens: Maximum allowed tokens + removal_batch_size: Number of messages to remove per iteration + min_messages_to_keep: Minimum number of messages to preserve + """ + if not messages: + return messages + + result = messages + result = self._remove_meta_messages(result) + + # Early exit if no compression needed + initial_token_count = token_counter(model=llm_model, messages=result) + max_allowed_tokens = max_tokens or (100 * 1000) + + if initial_token_count <= max_allowed_tokens: + return result + + # Separate system message (assumed to be first) from conversation messages + system_message = messages[0] if messages and messages[0].get('role') == 'system' else None + conversation_messages = result[1:] if system_message else result + + safety_limit = 500 + current_token_count = initial_token_count + + while current_token_count > max_allowed_tokens and safety_limit > 0: + safety_limit -= 1 + + if len(conversation_messages) <= min_messages_to_keep: + logger.warning(f"Cannot compress further: only {len(conversation_messages)} messages remain (min: {min_messages_to_keep})") + break + + # Calculate removal strategy based on current message count + if len(conversation_messages) > (removal_batch_size * 2): + # Remove from middle, keeping recent and early context + middle_start = len(conversation_messages) // 2 - (removal_batch_size // 2) + middle_end = middle_start + removal_batch_size + conversation_messages = conversation_messages[:middle_start] + conversation_messages[middle_end:] + else: + # Remove from earlier messages, preserving recent context + messages_to_remove = min(removal_batch_size, len(conversation_messages) // 2) + if messages_to_remove > 0: + conversation_messages = conversation_messages[messages_to_remove:] + else: + # Can't remove any more messages + break + + # Recalculate token count + messages_to_count = ([system_message] + conversation_messages) if system_message else conversation_messages + current_token_count = token_counter(model=llm_model, messages=messages_to_count) + + # Prepare final result + final_messages = ([system_message] + conversation_messages) if system_message else conversation_messages + final_token_count = token_counter(model=llm_model, messages=final_messages) + + logger.info(f"_compress_messages_by_omitting_messages: {initial_token_count} -> {final_token_count} tokens ({len(messages)} -> {len(final_messages)} messages)") + + return final_messages + + def _middle_out_messages(self, messages: List[Dict[str, Any]], max_messages: int = 320) -> List[Dict[str, Any]]: + """Remove messages from the middle of the list, keeping max_messages total.""" + if len(messages) <= max_messages: + return messages + + # Keep half from the beginning and half from the end + keep_start = max_messages // 2 + keep_end = max_messages - keep_start + + return messages[:keep_start] + messages[-keep_end:] + + + 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) + + async def add_message( + self, + thread_id: str, + type: str, + content: Union[Dict[str, Any], List[Any], str], + is_llm_message: bool = False, + metadata: Optional[Dict[str, Any]] = None, + agent_id: Optional[str] = None, + agent_version_id: Optional[str] = None + ): + """Add a message to the thread in the database. + + Args: + thread_id: The ID of the thread to add the message to. + type: The type of the message (e.g., 'text', 'image_url', 'tool_call', 'tool', 'user', 'assistant'). + content: The content of the message. Can be a dictionary, list, or string. + It will be stored as JSONB in the database. + is_llm_message: Flag indicating if the message originated from the LLM. + Defaults to False (user message). + metadata: Optional dictionary for additional message metadata. + Defaults to None, stored as an empty JSONB object if None. + agent_id: Optional ID of the agent associated with this message. + agent_version_id: Optional ID of the specific agent version used. + """ + logger.debug(f"Adding message of type '{type}' to thread {thread_id} (agent: {agent_id}, version: {agent_version_id})") + client = await self.db.client + + # Prepare data for insertion + data_to_insert = { + 'thread_id': thread_id, + 'type': type, + 'content': content, + 'is_llm_message': is_llm_message, + 'metadata': metadata or {}, + } + + # Add agent information if provided + if agent_id: + data_to_insert['agent_id'] = agent_id + if agent_version_id: + data_to_insert['agent_version_id'] = agent_version_id + + try: + # Add returning='representation' to get the inserted row data including the id + result = await client.table('messages').insert(data_to_insert, returning='representation').execute() + logger.info(f"Successfully added message to thread {thread_id}") + + if result.data and len(result.data) > 0 and isinstance(result.data[0], dict) and 'message_id' in result.data[0]: + return result.data[0] + else: + logger.error(f"Insert operation failed or did not return expected data structure for thread {thread_id}. Result data: {result.data}") + return None + except Exception as e: + logger.error(f"Failed to add message to thread {thread_id}: {str(e)}", exc_info=True) + raise + + async def get_llm_messages(self, thread_id: str) -> List[Dict[str, Any]]: + """Get all messages for a thread. + + This method uses the SQL function which handles context truncation + by considering summary messages. + + Args: + thread_id: The ID of the thread to get messages for. + + Returns: + List of message objects. + """ + logger.debug(f"Getting messages for thread {thread_id}") + client = await self.db.client + + try: + # result = await client.rpc('get_llm_formatted_messages', {'p_thread_id': thread_id}).execute() + + # Fetch messages in batches of 1000 to avoid overloading the database + all_messages = [] + batch_size = 1000 + offset = 0 + + while True: + result = await client.table('messages').select('message_id, content').eq('thread_id', thread_id).eq('is_llm_message', True).order('created_at').range(offset, offset + batch_size - 1).execute() + + if not result.data or len(result.data) == 0: + break + + all_messages.extend(result.data) + + # If we got fewer than batch_size records, we've reached the end + if len(result.data) < batch_size: + break + + offset += batch_size + + # Use all_messages instead of result.data in the rest of the method + result_data = all_messages + + # Parse the returned data which might be stringified JSON + if not result_data: + return [] + + # Return properly parsed JSON objects + messages = [] + for item in result_data: + if isinstance(item['content'], str): + try: + parsed_item = json.loads(item['content']) + parsed_item['message_id'] = item['message_id'] + messages.append(parsed_item) + except json.JSONDecodeError: + logger.error(f"Failed to parse message: {item['content']}") + else: + content = item['content'] + content['message_id'] = item['message_id'] + messages.append(content) + + return messages + + except Exception as e: + logger.error(f"Failed to get messages for thread {thread_id}: {str(e)}", exc_info=True) + return [] + + async def run_thread( + self, + thread_id: str, + system_prompt: Dict[str, Any], + stream: bool = True, + temporary_message: Optional[Dict[str, Any]] = None, + llm_model: str = "gpt-4o", + llm_temperature: float = 0, + llm_max_tokens: Optional[int] = None, + processor_config: Optional[ProcessorConfig] = None, + tool_choice: ToolChoice = "auto", + native_max_auto_continues: int = 25, + max_xml_tool_calls: int = 0, + include_xml_examples: bool = False, + enable_thinking: Optional[bool] = False, + reasoning_effort: Optional[str] = 'low', + enable_context_manager: bool = True, + generation: Optional[StatefulGenerationClient] = None, + ) -> Union[Dict[str, Any], AsyncGenerator]: + """Run a conversation thread with LLM integration and tool execution. + + Args: + thread_id: The ID of the thread to run + system_prompt: System message to set the assistant's behavior + stream: Use streaming API for the LLM response + temporary_message: Optional temporary user message for this run only + llm_model: The name of the LLM model to use + llm_temperature: Temperature parameter for response randomness (0-1) + llm_max_tokens: Maximum tokens in the LLM response + processor_config: Configuration for the response processor + tool_choice: Tool choice preference ("auto", "required", "none") + native_max_auto_continues: Maximum number of automatic continuations when + finish_reason="tool_calls" (0 disables auto-continue) + max_xml_tool_calls: Maximum number of XML tool calls to allow (0 = no limit) + include_xml_examples: Whether to include XML tool examples in the system prompt + enable_thinking: Whether to enable thinking before making a decision + reasoning_effort: The effort level for reasoning + enable_context_manager: Whether to enable automatic context summarization. + + Returns: + An async generator yielding response chunks or error dict + """ + + logger.info(f"Starting thread execution for thread {thread_id}") + logger.info(f"Using model: {llm_model}") + # Log parameters + logger.info(f"Parameters: model={llm_model}, temperature={llm_temperature}, max_tokens={llm_max_tokens}") + logger.info(f"Auto-continue: max={native_max_auto_continues}, XML tool limit={max_xml_tool_calls}") + + # Log model info + logger.info(f"🤖 Thread {thread_id}: Using model {llm_model}") + + # Apply max_xml_tool_calls if specified and not already set in config + if max_xml_tool_calls > 0 and not processor_config.max_xml_tool_calls: + processor_config.max_xml_tool_calls = max_xml_tool_calls + + # Create a working copy of the system prompt to potentially modify + working_system_prompt = system_prompt.copy() + + # Add XML examples to system prompt if requested, do this only ONCE before the loop + if include_xml_examples and processor_config.xml_tool_calling: + xml_examples = self.tool_registry.get_xml_examples() + if xml_examples: + examples_content = """ +--- XML TOOL CALLING --- + +In this environment you have access to a set of tools you can use to answer the user's question. The tools are specified in XML format. +Format your tool calls using the specified XML tags. Place parameters marked as 'attribute' within the opening tag (e.g., ``). Place parameters marked as 'content' between the opening and closing tags. Place parameters marked as 'element' within their own child tags (e.g., `value`). Refer to the examples provided below for the exact structure of each tool. +String and scalar parameters should be specified as attributes, while content goes between tags. +Note that spaces for string values are not stripped. The output is parsed with regular expressions. + +Here are the XML tools available with examples: +""" + for tag_name, example in xml_examples.items(): + examples_content += f"<{tag_name}> Example: {example}\\n" + + # # Save examples content to a file + # try: + # with open('xml_examples.txt', 'w') as f: + # f.write(examples_content) + # logger.debug("Saved XML examples to xml_examples.txt") + # except Exception as e: + # logger.error(f"Failed to save XML examples to file: {e}") + + system_content = working_system_prompt.get('content') + + if isinstance(system_content, str): + working_system_prompt['content'] += examples_content + logger.debug("Appended XML examples to string system prompt content.") + elif isinstance(system_content, list): + appended = False + for item in working_system_prompt['content']: # Modify the copy + if isinstance(item, dict) and item.get('type') == 'text' and 'text' in item: + item['text'] += examples_content + logger.debug("Appended XML examples to the first text block in list system prompt content.") + appended = True + break + if not appended: + logger.warning("System prompt content is a list but no text block found to append XML examples.") + else: + logger.warning(f"System prompt content is of unexpected type ({type(system_content)}), cannot add XML examples.") + # Control whether we need to auto-continue due to tool_calls finish reason + auto_continue = True + auto_continue_count = 0 + + # Define inner function to handle a single run + async def _run_once(temp_msg=None): + try: + # Ensure processor_config is available in this scope + nonlocal processor_config + # Note: processor_config is now guaranteed to exist due to check above + + # 1. Get messages from thread for LLM call + messages = await self.get_llm_messages(thread_id) + + # 2. Check token count before proceeding + token_count = 0 + try: + # Use the potentially modified working_system_prompt for token counting + token_count = token_counter(model=llm_model, messages=[working_system_prompt] + messages) + token_threshold = self.context_manager.token_threshold + logger.info(f"Thread {thread_id} token count: {token_count}/{token_threshold} ({(token_count/token_threshold)*100:.1f}%)") + + # if token_count >= token_threshold and enable_context_manager: + # logger.info(f"Thread token count ({token_count}) exceeds threshold ({token_threshold}), summarizing...") + # summarized = await self.context_manager.check_and_summarize_if_needed( + # thread_id=thread_id, + # add_message_callback=self.add_message, + # model=llm_model, + # force=True + # ) + # if summarized: + # logger.info("Summarization complete, fetching updated messages with summary") + # messages = await self.get_llm_messages(thread_id) + # # Recount tokens after summarization, using the modified prompt + # new_token_count = token_counter(model=llm_model, messages=[working_system_prompt] + messages) + # logger.info(f"After summarization: token count reduced from {token_count} to {new_token_count}") + # else: + # logger.warning("Summarization failed or wasn't needed - proceeding with original messages") + # elif not enable_context_manager: + # logger.info("Automatic summarization disabled. Skipping token count check and summarization.") + + except Exception as e: + logger.error(f"Error counting tokens or summarizing: {str(e)}") + + # 3. Prepare messages for LLM call + add temporary message if it exists + # Use the working_system_prompt which may contain the XML examples + prepared_messages = [working_system_prompt] + + # Find the last user message index + last_user_index = -1 + for i, msg in enumerate(messages): + if msg.get('role') == 'user': + last_user_index = i + + # Insert temporary message before the last user message if it exists + if temp_msg and last_user_index >= 0: + prepared_messages.extend(messages[:last_user_index]) + prepared_messages.append(temp_msg) + prepared_messages.extend(messages[last_user_index:]) + logger.debug("Added temporary message before the last user message") + else: + # If no user message or no temporary message, just add all messages + prepared_messages.extend(messages) + if temp_msg: + prepared_messages.append(temp_msg) + logger.debug("Added temporary message to the end of prepared messages") + + # 4. Prepare tools for LLM call + openapi_tool_schemas = None + if processor_config.native_tool_calling: + openapi_tool_schemas = self.tool_registry.get_openapi_schemas() + logger.debug(f"Retrieved {len(openapi_tool_schemas) if openapi_tool_schemas else 0} OpenAPI tool schemas") + + prepared_messages = self._compress_messages(prepared_messages, llm_model) + + # 5. Make LLM API call + logger.debug("Making LLM API call") + try: + if generation: + generation.update( + input=prepared_messages, + start_time=datetime.datetime.now(datetime.timezone.utc), + model=llm_model, + model_parameters={ + "max_tokens": llm_max_tokens, + "temperature": llm_temperature, + "enable_thinking": enable_thinking, + "reasoning_effort": reasoning_effort, + "tool_choice": tool_choice, + "tools": openapi_tool_schemas, + } + ) + llm_response = await make_llm_api_call( + prepared_messages, # Pass the potentially modified messages + llm_model, + temperature=llm_temperature, + max_tokens=llm_max_tokens, + tools=openapi_tool_schemas, + tool_choice=tool_choice if processor_config.native_tool_calling else None, + stream=stream, + enable_thinking=enable_thinking, + reasoning_effort=reasoning_effort + ) + logger.debug("Successfully received raw LLM API response stream/object") + + except Exception as e: + logger.error(f"Failed to make LLM API call: {str(e)}", exc_info=True) + raise + + # 6. Process LLM response using the ResponseProcessor + if stream: + logger.debug("Processing streaming response") + response_generator = self.response_processor.process_streaming_response( + llm_response=llm_response, + thread_id=thread_id, + config=processor_config, + prompt_messages=prepared_messages, + llm_model=llm_model, + ) + + return response_generator + else: + logger.debug("Processing non-streaming response") + # Pass through the response generator without try/except to let errors propagate up + response_generator = self.response_processor.process_non_streaming_response( + llm_response=llm_response, + thread_id=thread_id, + config=processor_config, + prompt_messages=prepared_messages, + llm_model=llm_model, + ) + return response_generator # Return the generator + + except Exception as e: + 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) + } + + # Define a wrapper generator that handles auto-continue logic + async def auto_continue_wrapper(): + nonlocal auto_continue, auto_continue_count + + while auto_continue and (native_max_auto_continues == 0 or auto_continue_count < native_max_auto_continues): + # Reset auto_continue for this iteration + auto_continue = False + + # Run the thread once, passing the potentially modified system prompt + # Pass temp_msg only on the first iteration + try: + response_gen = await _run_once(temporary_message if auto_continue_count == 0 else None) + + # Handle error responses + if isinstance(response_gen, dict) and "status" in response_gen and response_gen["status"] == "error": + logger.error(f"Error in auto_continue_wrapper: {response_gen.get('message', 'Unknown error')}") + yield response_gen + return # Exit the generator on error + + # Process each chunk + try: + async for chunk in response_gen: + # Check if this is a finish reason chunk with tool_calls or xml_tool_limit_reached + if chunk.get('type') == 'finish': + if chunk.get('finish_reason') == 'tool_calls': + # Only auto-continue if enabled (max > 0) + if native_max_auto_continues > 0: + logger.info(f"Detected finish_reason='tool_calls', auto-continuing ({auto_continue_count + 1}/{native_max_auto_continues})") + auto_continue = True + auto_continue_count += 1 + # Don't yield the finish chunk to avoid confusing the client + continue + elif chunk.get('finish_reason') == 'xml_tool_limit_reached': + # Don't auto-continue if XML tool limit was reached + logger.info(f"Detected finish_reason='xml_tool_limit_reached', stopping auto-continue") + auto_continue = False + # Still yield the chunk to inform the client + + # Otherwise just yield the chunk normally + yield chunk + + # If not auto-continuing, we're done + if not auto_continue: + break + except Exception as e: + # If there's an exception, log it, yield an error status, and stop execution + logger.error(f"Error in auto_continue_wrapper generator: {str(e)}", exc_info=True) + yield { + "type": "status", + "status": "error", + "message": f"Error in thread processing: {str(e)}" + } + return # Exit the generator on any error + except Exception as outer_e: + # Catch exceptions from _run_once itself + logger.error(f"Error executing thread: {str(outer_e)}", exc_info=True) + yield { + "type": "status", + "status": "error", + "message": f"Error executing thread: {str(outer_e)}" + } + return # Exit immediately on exception from _run_once + + # If we've reached the max auto-continues, log a warning + if auto_continue and auto_continue_count >= native_max_auto_continues: + logger.warning(f"Reached maximum auto-continue limit ({native_max_auto_continues}), stopping.") + yield { + "type": "content", + "content": f"\n[Agent reached maximum auto-continue limit of {native_max_auto_continues}]" + } + + # If auto-continue is disabled (max=0), just run once + if native_max_auto_continues == 0: + logger.info("Auto-continue is disabled (native_max_auto_continues=0)") + # Pass the potentially modified system prompt and temp message + return await _run_once(temporary_message) + + # Otherwise return the auto-continue wrapper generator + return auto_continue_wrapper() diff --git a/app/agentpress/tool.py b/app/agentpress/tool.py new file mode 100644 index 000000000..de7a50458 --- /dev/null +++ b/app/agentpress/tool.py @@ -0,0 +1,240 @@ +""" +Core tool system providing the foundation for creating and managing tools. + +This module defines the base classes and decorators for creating tools in AgentPress: +- Tool base class for implementing tool functionality +- Schema decorators for OpenAPI and XML tool definitions +- Result containers for standardized tool outputs +""" + +from typing import Dict, Any, Union, Optional, List +from dataclasses import dataclass, field +from abc import ABC +import json +import inspect +from enum import Enum +from utils.logger import logger + +class SchemaType(Enum): + """Enumeration of supported schema types for tool definitions.""" + OPENAPI = "openapi" + XML = "xml" + CUSTOM = "custom" + +@dataclass +class XMLNodeMapping: + """Maps an XML node to a function parameter. + + Attributes: + param_name (str): Name of the function parameter + node_type (str): Type of node ("element", "attribute", or "content") + path (str): XPath-like path to the node ("." means root element) + required (bool): Whether the parameter is required (defaults to True) + """ + param_name: str + node_type: str = "element" + path: str = "." + required: bool = True + +@dataclass +class XMLTagSchema: + """Schema definition for XML tool tags. + + Attributes: + tag_name (str): Root tag name for the tool + mappings (List[XMLNodeMapping]): Parameter mappings for the tag + example (str, optional): Example showing tag usage + + Methods: + add_mapping: Add a new parameter mapping to the schema + """ + tag_name: str + mappings: List[XMLNodeMapping] = field(default_factory=list) + example: Optional[str] = None + + def add_mapping(self, param_name: str, node_type: str = "element", path: str = ".", required: bool = True) -> None: + """Add a new node mapping to the schema. + + Args: + param_name: Name of the function parameter + node_type: Type of node ("element", "attribute", or "content") + path: XPath-like path to the node + required: Whether the parameter is required + """ + self.mappings.append(XMLNodeMapping( + param_name=param_name, + node_type=node_type, + path=path, + required=required + )) + logger.debug(f"Added XML mapping for parameter '{param_name}' with type '{node_type}' at path '{path}', required={required}") + +@dataclass +class ToolSchema: + """Container for tool schemas with type information. + + Attributes: + schema_type (SchemaType): Type of schema (OpenAPI, XML, or Custom) + schema (Dict[str, Any]): The actual schema definition + xml_schema (XMLTagSchema, optional): XML-specific schema if applicable + """ + schema_type: SchemaType + schema: Dict[str, Any] + xml_schema: Optional[XMLTagSchema] = None + +@dataclass +class ToolResult: + """Container for tool execution results. + + Attributes: + success (bool): Whether the tool execution succeeded + output (str): Output message or error description + """ + success: bool + output: str + +class Tool(ABC): + """Abstract base class for all tools. + + Provides the foundation for implementing tools with schema registration + and result handling capabilities. + + Attributes: + _schemas (Dict[str, List[ToolSchema]]): Registered schemas for tool methods + + Methods: + get_schemas: Get all registered tool schemas + success_response: Create a successful result + fail_response: Create a failed result + """ + + def __init__(self): + """Initialize tool with empty schema registry.""" + self._schemas: Dict[str, List[ToolSchema]] = {} + logger.debug(f"Initializing tool class: {self.__class__.__name__}") + self._register_schemas() + + def _register_schemas(self): + """Register schemas from all decorated methods.""" + for name, method in inspect.getmembers(self, predicate=inspect.ismethod): + if hasattr(method, 'tool_schemas'): + self._schemas[name] = method.tool_schemas + logger.debug(f"Registered schemas for method '{name}' in {self.__class__.__name__}") + + def get_schemas(self) -> Dict[str, List[ToolSchema]]: + """Get all registered tool schemas. + + Returns: + Dict mapping method names to their schema definitions + """ + return self._schemas + + def success_response(self, data: Union[Dict[str, Any], str]) -> ToolResult: + """Create a successful tool result. + + Args: + data: Result data (dictionary or string) + + Returns: + ToolResult with success=True and formatted output + """ + if isinstance(data, str): + text = data + else: + text = json.dumps(data, indent=2) + logger.debug(f"Created success response for {self.__class__.__name__}") + return ToolResult(success=True, output=text) + + def fail_response(self, msg: str) -> ToolResult: + """Create a failed tool result. + + Args: + msg: Error message describing the failure + + Returns: + ToolResult with success=False and error message + """ + logger.debug(f"Tool {self.__class__.__name__} returned failed result: {msg}") + return ToolResult(success=False, output=msg) + +def _add_schema(func, schema: ToolSchema): + """Helper to add schema to a function.""" + if not hasattr(func, 'tool_schemas'): + func.tool_schemas = [] + func.tool_schemas.append(schema) + logger.debug(f"Added {schema.schema_type.value} schema to function {func.__name__}") + return func + +def openapi_schema(schema: Dict[str, Any]): + """Decorator for OpenAPI schema tools.""" + def decorator(func): + logger.debug(f"Applying OpenAPI schema to function {func.__name__}") + return _add_schema(func, ToolSchema( + schema_type=SchemaType.OPENAPI, + schema=schema + )) + return decorator + +def xml_schema( + tag_name: str, + mappings: List[Dict[str, Any]] = None, + example: str = None +): + """ + Decorator for XML schema tools with improved node mapping. + + Args: + tag_name: Name of the root XML tag + mappings: List of mapping definitions, each containing: + - param_name: Name of the function parameter + - node_type: "element", "attribute", or "content" + - path: Path to the node (default "." for root) + - required: Whether the parameter is required (default True) + example: Optional example showing how to use the XML tag + + Example: + @xml_schema( + tag_name="str-replace", + mappings=[ + {"param_name": "file_path", "node_type": "attribute", "path": "."}, + {"param_name": "old_str", "node_type": "element", "path": "old_str"}, + {"param_name": "new_str", "node_type": "element", "path": "new_str"} + ], + example=''' + + text to replace + replacement text + + ''' + ) + """ + def decorator(func): + logger.debug(f"Applying XML schema with tag '{tag_name}' to function {func.__name__}") + xml_schema = XMLTagSchema(tag_name=tag_name, example=example) + + # Add mappings + if mappings: + for mapping in mappings: + xml_schema.add_mapping( + param_name=mapping["param_name"], + node_type=mapping.get("node_type", "element"), + path=mapping.get("path", "."), + required=mapping.get("required", True) + ) + + return _add_schema(func, ToolSchema( + schema_type=SchemaType.XML, + schema={}, # OpenAPI schema could be added here if needed + xml_schema=xml_schema + )) + return decorator + +def custom_schema(schema: Dict[str, Any]): + """Decorator for custom schema tools.""" + def decorator(func): + logger.debug(f"Applying custom schema to function {func.__name__}") + return _add_schema(func, ToolSchema( + schema_type=SchemaType.CUSTOM, + schema=schema + )) + return decorator diff --git a/app/agentpress/tool_registry.py b/app/agentpress/tool_registry.py new file mode 100644 index 000000000..b50438a18 --- /dev/null +++ b/app/agentpress/tool_registry.py @@ -0,0 +1,152 @@ +from typing import Dict, Type, Any, List, Optional, Callable +from agentpress.tool import Tool, SchemaType +from utils.logger import logger + + +class ToolRegistry: + """Registry for managing and accessing tools. + + Maintains a collection of tool instances and their schemas, allowing for + selective registration of tool functions and easy access to tool capabilities. + + Attributes: + tools (Dict[str, Dict[str, Any]]): OpenAPI-style tools and schemas + xml_tools (Dict[str, Dict[str, Any]]): XML-style tools and schemas + + Methods: + register_tool: Register a tool with optional function filtering + get_tool: Get a specific tool by name + get_xml_tool: Get a tool by XML tag name + get_openapi_schemas: Get OpenAPI schemas for function calling + get_xml_examples: Get examples of XML tool usage + """ + + def __init__(self): + """Initialize a new ToolRegistry instance.""" + self.tools = {} + self.xml_tools = {} + logger.debug("Initialized new ToolRegistry instance") + + def register_tool(self, tool_class: Type[Tool], function_names: Optional[List[str]] = None, **kwargs): + """Register a tool with optional function filtering. + + Args: + tool_class: The tool class to register + function_names: Optional list of specific functions to register + **kwargs: Additional arguments passed to tool initialization + + Notes: + - If function_names is None, all functions are registered + - Handles both OpenAPI and XML schema registration + """ + logger.debug(f"Registering tool class: {tool_class.__name__}") + tool_instance = tool_class(**kwargs) + schemas = tool_instance.get_schemas() + + logger.debug(f"Available schemas for {tool_class.__name__}: {list(schemas.keys())}") + + registered_openapi = 0 + registered_xml = 0 + + for func_name, schema_list in schemas.items(): + if function_names is None or func_name in function_names: + for schema in schema_list: + if schema.schema_type == SchemaType.OPENAPI: + self.tools[func_name] = { + "instance": tool_instance, + "schema": schema + } + registered_openapi += 1 + logger.debug(f"Registered OpenAPI function {func_name} from {tool_class.__name__}") + + if schema.schema_type == SchemaType.XML and schema.xml_schema: + self.xml_tools[schema.xml_schema.tag_name] = { + "instance": tool_instance, + "method": func_name, + "schema": schema + } + registered_xml += 1 + logger.debug(f"Registered XML tag {schema.xml_schema.tag_name} -> {func_name} from {tool_class.__name__}") + + logger.debug(f"Tool registration complete for {tool_class.__name__}: {registered_openapi} OpenAPI functions, {registered_xml} XML tags") + + def get_available_functions(self) -> Dict[str, Callable]: + """Get all available tool functions. + + Returns: + Dict mapping function names to their implementations + """ + available_functions = {} + + # Get OpenAPI tool functions + for tool_name, tool_info in self.tools.items(): + tool_instance = tool_info['instance'] + function_name = tool_name + function = getattr(tool_instance, function_name) + available_functions[function_name] = function + + # Get XML tool functions + for tag_name, tool_info in self.xml_tools.items(): + tool_instance = tool_info['instance'] + method_name = tool_info['method'] + function = getattr(tool_instance, method_name) + available_functions[method_name] = function + + logger.debug(f"Retrieved {len(available_functions)} available functions") + return available_functions + + def get_tool(self, tool_name: str) -> Dict[str, Any]: + """Get a specific tool by name. + + Args: + tool_name: Name of the tool function + + Returns: + Dict containing tool instance and schema, or empty dict if not found + """ + tool = self.tools.get(tool_name, {}) + if not tool: + logger.warning(f"Tool not found: {tool_name}") + return tool + + def get_xml_tool(self, tag_name: str) -> Dict[str, Any]: + """Get tool info by XML tag name. + + Args: + tag_name: XML tag name for the tool + + Returns: + Dict containing tool instance, method name, and schema + """ + tool = self.xml_tools.get(tag_name, {}) + if not tool: + logger.warning(f"XML tool not found for tag: {tag_name}") + return tool + + def get_openapi_schemas(self) -> List[Dict[str, Any]]: + """Get OpenAPI schemas for function calling. + + Returns: + List of OpenAPI-compatible schema definitions + """ + schemas = [ + tool_info['schema'].schema + for tool_info in self.tools.values() + if tool_info['schema'].schema_type == SchemaType.OPENAPI + ] + logger.debug(f"Retrieved {len(schemas)} OpenAPI schemas") + return schemas + + def get_xml_examples(self) -> Dict[str, str]: + """Get all XML tag examples. + + Returns: + Dict mapping tag names to their example usage + """ + examples = {} + for tool_info in self.xml_tools.values(): + schema = tool_info['schema'] + if schema.xml_schema and schema.xml_schema.example: + examples[schema.xml_schema.tag_name] = schema.xml_schema.example + logger.debug(f"Retrieved {len(examples)} XML examples") + return examples diff --git a/app/agentpress/utils/__init__.py b/app/agentpress/utils/__init__.py new file mode 100644 index 000000000..e0dababfd --- /dev/null +++ b/app/agentpress/utils/__init__.py @@ -0,0 +1 @@ +# Utils module for AgentPress \ No newline at end of file diff --git a/app/agentpress/utils/json_helpers.py b/app/agentpress/utils/json_helpers.py new file mode 100644 index 000000000..3bda9092a --- /dev/null +++ b/app/agentpress/utils/json_helpers.py @@ -0,0 +1,174 @@ +""" +JSON helper utilities for handling both legacy (string) and new (dict/list) formats. + +These utilities help with the transition from storing JSON as strings to storing +them as proper JSONB objects in the database. +""" + +import json +from typing import Any, Union, Dict, List + + +def ensure_dict(value: Union[str, Dict[str, Any], None], default: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Ensure a value is a dictionary. + + Handles: + - None -> returns default or {} + - Dict -> returns as-is + - JSON string -> parses and returns dict + - Other -> returns default or {} + + Args: + value: The value to ensure is a dict + default: Default value if conversion fails + + Returns: + A dictionary + """ + if default is None: + default = {} + + if value is None: + return default + + if isinstance(value, dict): + return value + + if isinstance(value, str): + try: + parsed = json.loads(value) + if isinstance(parsed, dict): + return parsed + return default + except (json.JSONDecodeError, TypeError): + return default + + return default + + +def ensure_list(value: Union[str, List[Any], None], default: List[Any] = None) -> List[Any]: + """ + Ensure a value is a list. + + Handles: + - None -> returns default or [] + - List -> returns as-is + - JSON string -> parses and returns list + - Other -> returns default or [] + + Args: + value: The value to ensure is a list + default: Default value if conversion fails + + Returns: + A list + """ + if default is None: + default = [] + + if value is None: + return default + + if isinstance(value, list): + return value + + if isinstance(value, str): + try: + parsed = json.loads(value) + if isinstance(parsed, list): + return parsed + return default + except (json.JSONDecodeError, TypeError): + return default + + return default + + +def safe_json_parse(value: Union[str, Dict, List, Any], default: Any = None) -> Any: + """ + Safely parse a value that might be JSON string or already parsed. + + This handles the transition period where some data might be stored as + JSON strings (old format) and some as proper objects (new format). + + Args: + value: The value to parse + default: Default value if parsing fails + + Returns: + Parsed value or default + """ + if value is None: + return default + + # If it's already a dict or list, return as-is + if isinstance(value, (dict, list)): + return value + + # If it's a string, try to parse it + if isinstance(value, str): + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError): + # If it's not valid JSON, return the string itself + return value + + # For any other type, return as-is + return value + + +def to_json_string(value: Any) -> str: + """ + Convert a value to a JSON string if needed. + + This is used for backwards compatibility when yielding data that + expects JSON strings. + + Args: + value: The value to convert + + Returns: + JSON string representation + """ + if isinstance(value, str): + # If it's already a string, check if it's valid JSON + try: + json.loads(value) + return value # It's already a JSON string + except (json.JSONDecodeError, TypeError): + # It's a plain string, encode it as JSON + return json.dumps(value) + + # For all other types, convert to JSON + return json.dumps(value) + + +def format_for_yield(message_object: Dict[str, Any]) -> Dict[str, Any]: + """ + Format a message object for yielding, ensuring content and metadata are JSON strings. + + This maintains backward compatibility with clients expecting JSON strings + while the database now stores proper objects. + + Args: + message_object: The message object from the database + + Returns: + Message object with content and metadata as JSON strings + """ + if not message_object: + return message_object + + # Create a copy to avoid modifying the original + formatted = message_object.copy() + + # Ensure content is a JSON string + if 'content' in formatted and not isinstance(formatted['content'], str): + formatted['content'] = json.dumps(formatted['content']) + + # Ensure metadata is a JSON string + if 'metadata' in formatted and not isinstance(formatted['metadata'], str): + formatted['metadata'] = json.dumps(formatted['metadata']) + + return formatted \ No newline at end of file diff --git a/app/agentpress/xml_tool_parser.py b/app/agentpress/xml_tool_parser.py new file mode 100644 index 000000000..e882a66f0 --- /dev/null +++ b/app/agentpress/xml_tool_parser.py @@ -0,0 +1,300 @@ +""" +XML Tool Call Parser Module + +This module provides a reliable XML tool call parsing system that supports +the Cursor-style format with structured function_calls blocks. +""" + +import re +import xml.etree.ElementTree as ET +from typing import List, Dict, Any, Optional, Tuple +from dataclasses import dataclass +import json +import logging + +logger = logging.getLogger(__name__) + + +@dataclass +class XMLToolCall: + """Represents a parsed XML tool call.""" + function_name: str + parameters: Dict[str, Any] + raw_xml: str + parsing_details: Dict[str, Any] + + +class XMLToolParser: + """ + Parser for XML tool calls using the Cursor-style format: + + + + param_value + ... + + + """ + + # Regex patterns for extracting XML blocks + FUNCTION_CALLS_PATTERN = re.compile( + r'(.*?)', + re.DOTALL | re.IGNORECASE + ) + + INVOKE_PATTERN = re.compile( + r'(.*?)', + re.DOTALL | re.IGNORECASE + ) + + PARAMETER_PATTERN = re.compile( + r'(.*?)', + re.DOTALL | re.IGNORECASE + ) + + def __init__(self, strict_mode: bool = False): + """ + Initialize the XML tool parser. + + Args: + strict_mode: If True, only accept the exact format. If False, + also try to parse legacy formats for backwards compatibility. + """ + self.strict_mode = strict_mode + + def parse_content(self, content: str) -> List[XMLToolCall]: + """ + Parse XML tool calls from content. + + Args: + content: The text content potentially containing XML tool calls + + Returns: + List of parsed XMLToolCall objects + """ + tool_calls = [] + + # First, try to find function_calls blocks + function_calls_matches = self.FUNCTION_CALLS_PATTERN.findall(content) + + for fc_content in function_calls_matches: + # Find all invoke blocks within this function_calls block + invoke_matches = self.INVOKE_PATTERN.findall(fc_content) + + for function_name, invoke_content in invoke_matches: + try: + tool_call = self._parse_invoke_block( + function_name, + invoke_content, + fc_content + ) + if tool_call: + tool_calls.append(tool_call) + except Exception as e: + logger.error(f"Error parsing invoke block for {function_name}: {e}") + + # If not in strict mode and no tool calls found, try legacy format + if not self.strict_mode and not tool_calls: + tool_calls.extend(self._parse_legacy_format(content)) + + return tool_calls + + def _parse_invoke_block( + self, + function_name: str, + invoke_content: str, + full_block: str + ) -> Optional[XMLToolCall]: + """Parse a single invoke block into an XMLToolCall.""" + parameters = {} + parsing_details = { + "format": "v2", + "function_name": function_name, + "raw_parameters": {} + } + + # Extract all parameters + param_matches = self.PARAMETER_PATTERN.findall(invoke_content) + + for param_name, param_value in param_matches: + # Clean up the parameter value + param_value = param_value.strip() + + # Try to parse as JSON if it looks like JSON + parsed_value = self._parse_parameter_value(param_value) + + parameters[param_name] = parsed_value + parsing_details["raw_parameters"][param_name] = param_value + + # Extract the raw XML for this specific invoke + invoke_pattern = re.compile( + rf'.*?', + re.DOTALL | re.IGNORECASE + ) + raw_xml_match = invoke_pattern.search(full_block) + raw_xml = raw_xml_match.group(0) if raw_xml_match else f"..." + + return XMLToolCall( + function_name=function_name, + parameters=parameters, + raw_xml=raw_xml, + parsing_details=parsing_details + ) + + def _parse_parameter_value(self, value: str) -> Any: + """ + Parse a parameter value, attempting to convert to appropriate type. + + Args: + value: The string value to parse + + Returns: + Parsed value (could be dict, list, bool, int, float, or str) + """ + value = value.strip() + + # Try to parse as JSON first + if value.startswith(('{', '[')): + try: + return json.loads(value) + except json.JSONDecodeError: + pass + + # Try to parse as boolean + if value.lower() in ('true', 'false'): + return value.lower() == 'true' + + # Try to parse as number + try: + if '.' in value: + return float(value) + else: + return int(value) + except ValueError: + pass + + # Return as string + return value + + def _parse_legacy_format(self, content: str) -> List[XMLToolCall]: + """ + Parse legacy XML tool formats for backwards compatibility. + This handles formats like ... or + ... + """ + tool_calls = [] + + # Pattern for finding XML-like tags + tag_pattern = re.compile(r'<([a-zA-Z][\w\-]*)((?:\s+[\w\-]+=["\'][^"\']*["\'])*)\s*>(.*?)', re.DOTALL) + + for match in tag_pattern.finditer(content): + tag_name = match.group(1) + attributes_str = match.group(2) + inner_content = match.group(3) + + # Skip our own format tags + if tag_name in ('function_calls', 'invoke', 'parameter'): + continue + + parameters = {} + parsing_details = { + "format": "legacy", + "tag_name": tag_name, + "attributes": {}, + "inner_content": inner_content.strip() + } + + # Parse attributes + if attributes_str: + attr_pattern = re.compile(r'([\w\-]+)=["\']([^"\']*)["\']') + for attr_match in attr_pattern.finditer(attributes_str): + attr_name = attr_match.group(1) + attr_value = attr_match.group(2) + parameters[attr_name] = self._parse_parameter_value(attr_value) + parsing_details["attributes"][attr_name] = attr_value + + # If there's inner content and no attributes, use it as a 'content' parameter + if inner_content.strip() and not parameters: + parameters['content'] = inner_content.strip() + + # Convert tag name to function name (e.g., create-file -> create_file) + function_name = tag_name.replace('-', '_') + + tool_calls.append(XMLToolCall( + function_name=function_name, + parameters=parameters, + raw_xml=match.group(0), + parsing_details=parsing_details + )) + + return tool_calls + + def format_tool_call(self, function_name: str, parameters: Dict[str, Any]) -> str: + """ + Format a tool call in the Cursor-style XML format. + + Args: + function_name: Name of the function to call + parameters: Dictionary of parameters + + Returns: + Formatted XML string + """ + lines = ['', ''.format(function_name)] + + for param_name, param_value in parameters.items(): + # Convert value to string representation + if isinstance(param_value, (dict, list)): + value_str = json.dumps(param_value) + elif isinstance(param_value, bool): + value_str = str(param_value).lower() + else: + value_str = str(param_value) + + lines.append('{}'.format( + param_name, value_str + )) + + lines.extend(['', '']) + return '\n'.join(lines) + + def validate_tool_call(self, tool_call: XMLToolCall, expected_params: Optional[Dict[str, type]] = None) -> Tuple[bool, Optional[str]]: + """ + Validate a tool call against expected parameters. + + Args: + tool_call: The XMLToolCall to validate + expected_params: Optional dict of parameter names to expected types + + Returns: + Tuple of (is_valid, error_message) + """ + if not tool_call.function_name: + return False, "Function name is required" + + if expected_params: + for param_name, expected_type in expected_params.items(): + if param_name not in tool_call.parameters: + return False, f"Missing required parameter: {param_name}" + + param_value = tool_call.parameters[param_name] + if not isinstance(param_value, expected_type): + return False, f"Parameter {param_name} should be of type {expected_type.__name__}" + + return True, None + + +# Convenience function for quick parsing +def parse_xml_tool_calls(content: str, strict_mode: bool = False) -> List[XMLToolCall]: + """ + Parse XML tool calls from content. + + Args: + content: The text content potentially containing XML tool calls + strict_mode: If True, only accept the Cursor-style format + + Returns: + List of parsed XMLToolCall objects + """ + parser = XMLToolParser(strict_mode=strict_mode) + return parser.parse_content(content) \ No newline at end of file diff --git a/app/daytona/tool_base.py b/app/daytona/tool_base.py index 90446e4c9..70cb02dfe 100644 --- a/app/daytona/tool_base.py +++ b/app/daytona/tool_base.py @@ -4,7 +4,7 @@ from agentpress.thread_manager import ThreadManager from app.tool.base import BaseTool, ToolResult from daytona_sdk import Sandbox -from sandbox.sandbox import get_or_start_sandbox +from daytona.sandbox import get_or_start_sandbox from utils.logger import logger from utils.files_utils import clean_path diff --git a/app/services/billing.py b/app/services/billing.py new file mode 100644 index 000000000..67fa9bb40 --- /dev/null +++ b/app/services/billing.py @@ -0,0 +1,969 @@ +""" +Stripe Billing API implementation for Suna on top of Basejump. ONLY HAS SUPPOT FOR USER ACCOUNTS – no team accounts. As we are using the user_id as account_id as is the case with personal accounts. In personal accounts, the account_id equals the user_id. In team accounts, the account_id is unique. + +stripe listen --forward-to localhost:8000/api/billing/webhook +""" + +from fastapi import APIRouter, HTTPException, Depends, Request +from typing import Optional, Dict, Tuple +import stripe +from datetime import datetime, timezone +from utils.logger import logger +from utils.config import config, EnvMode +from services.supabase import DBConnection +from utils.auth_utils import get_current_user_id_from_jwt +from pydantic import BaseModel +from utils.constants import MODEL_ACCESS_TIERS, MODEL_NAME_ALIASES +import os + +# Initialize Stripe +stripe.api_key = config.STRIPE_SECRET_KEY + +# Initialize router +router = APIRouter(prefix="/billing", tags=["billing"]) + + +SUBSCRIPTION_TIERS = { + config.STRIPE_FREE_TIER_ID: {'name': 'free', 'minutes': 60}, + config.STRIPE_TIER_2_20_ID: {'name': 'tier_2_20', 'minutes': 120}, # 2 hours + config.STRIPE_TIER_6_50_ID: {'name': 'tier_6_50', 'minutes': 360}, # 6 hours + config.STRIPE_TIER_12_100_ID: {'name': 'tier_12_100', 'minutes': 720}, # 12 hours + config.STRIPE_TIER_25_200_ID: {'name': 'tier_25_200', 'minutes': 1500}, # 25 hours + config.STRIPE_TIER_50_400_ID: {'name': 'tier_50_400', 'minutes': 3000}, # 50 hours + config.STRIPE_TIER_125_800_ID: {'name': 'tier_125_800', 'minutes': 7500}, # 125 hours + config.STRIPE_TIER_200_1000_ID: {'name': 'tier_200_1000', 'minutes': 12000}, # 200 hours +} + +# Pydantic models for request/response validation +class CreateCheckoutSessionRequest(BaseModel): + price_id: str + success_url: str + cancel_url: str + tolt_referral: Optional[str] = None + +class CreatePortalSessionRequest(BaseModel): + return_url: str + +class SubscriptionStatus(BaseModel): + status: str # e.g., 'active', 'trialing', 'past_due', 'scheduled_downgrade', 'no_subscription' + plan_name: Optional[str] = None + price_id: Optional[str] = None # Added price ID + current_period_end: Optional[datetime] = None + cancel_at_period_end: bool = False + trial_end: Optional[datetime] = None + minutes_limit: Optional[int] = None + current_usage: Optional[float] = None + # Fields for scheduled changes + has_schedule: bool = False + scheduled_plan_name: Optional[str] = None + scheduled_price_id: Optional[str] = None # Added scheduled price ID + scheduled_change_date: Optional[datetime] = None + +# Helper functions +async def get_stripe_customer_id(client, user_id: str) -> Optional[str]: + """Get the Stripe customer ID for a user.""" + result = await client.schema('basejump').from_('billing_customers') \ + .select('id') \ + .eq('account_id', user_id) \ + .execute() + + if result.data and len(result.data) > 0: + return result.data[0]['id'] + return None + +async def create_stripe_customer(client, user_id: str, email: str) -> str: + """Create a new Stripe customer for a user.""" + # Create customer in Stripe + customer = stripe.Customer.create( + email=email, + metadata={"user_id": user_id} + ) + + # Store customer ID in Supabase + await client.schema('basejump').from_('billing_customers').insert({ + 'id': customer.id, + 'account_id': user_id, + 'email': email, + 'provider': 'stripe' + }).execute() + + return customer.id + +async def get_user_subscription(user_id: str) -> Optional[Dict]: + """Get the current subscription for a user from Stripe.""" + try: + # Get customer ID + db = DBConnection() + client = await db.client + customer_id = await get_stripe_customer_id(client, user_id) + + if not customer_id: + return None + + # Get all active subscriptions for the customer + subscriptions = stripe.Subscription.list( + customer=customer_id, + status='active' + ) + # print("Found subscriptions:", subscriptions) + + # Check if we have any subscriptions + if not subscriptions or not subscriptions.get('data'): + return None + + # Filter subscriptions to only include our product's subscriptions + our_subscriptions = [] + for sub in subscriptions['data']: + # Get the first subscription item + if sub.get('items') and sub['items'].get('data') and len(sub['items']['data']) > 0: + item = sub['items']['data'][0] + if item.get('price') and item['price'].get('id') in [ + config.STRIPE_FREE_TIER_ID, + config.STRIPE_TIER_2_20_ID, + config.STRIPE_TIER_6_50_ID, + config.STRIPE_TIER_12_100_ID, + config.STRIPE_TIER_25_200_ID, + config.STRIPE_TIER_50_400_ID, + config.STRIPE_TIER_125_800_ID, + config.STRIPE_TIER_200_1000_ID + ]: + our_subscriptions.append(sub) + + if not our_subscriptions: + return None + + # If there are multiple active subscriptions, we need to handle this + if len(our_subscriptions) > 1: + logger.warning(f"User {user_id} has multiple active subscriptions: {[sub['id'] for sub in our_subscriptions]}") + + # Get the most recent subscription + most_recent = max(our_subscriptions, key=lambda x: x['created']) + + # Cancel all other subscriptions + for sub in our_subscriptions: + if sub['id'] != most_recent['id']: + try: + stripe.Subscription.modify( + sub['id'], + cancel_at_period_end=True + ) + logger.info(f"Cancelled subscription {sub['id']} for user {user_id}") + except Exception as e: + logger.error(f"Error cancelling subscription {sub['id']}: {str(e)}") + + return most_recent + + return our_subscriptions[0] + + except Exception as e: + logger.error(f"Error getting subscription from Stripe: {str(e)}") + return None + +async def calculate_monthly_usage(client, user_id: str) -> float: + """Calculate total agent run minutes for the current month for a user.""" + # Get start of current month in UTC + now = datetime.now(timezone.utc) + start_of_month = datetime(now.year, now.month, 1, tzinfo=timezone.utc) + + # First get all threads for this user + threads_result = await client.table('threads') \ + .select('thread_id') \ + .eq('account_id', user_id) \ + .execute() + + if not threads_result.data: + return 0.0 + + thread_ids = [t['thread_id'] for t in threads_result.data] + + # Then get all agent runs for these threads in current month + runs_result = await client.table('agent_runs') \ + .select('started_at, completed_at') \ + .in_('thread_id', thread_ids) \ + .gte('started_at', start_of_month.isoformat()) \ + .execute() + + if not runs_result.data: + return 0.0 + + # Calculate total minutes + total_seconds = 0 + now_ts = now.timestamp() + + for run in runs_result.data: + start_time = datetime.fromisoformat(run['started_at'].replace('Z', '+00:00')).timestamp() + if run['completed_at']: + end_time = datetime.fromisoformat(run['completed_at'].replace('Z', '+00:00')).timestamp() + if start_time < end_time - 7200: + continue + else: + # if the start time is more than an hour ago, don't consider that time in total. else use the current time + if start_time < now_ts - 3600: + continue + else: + end_time = now_ts + + total_seconds += (end_time - start_time) + + return total_seconds / 60 # Convert to minutes + +async def get_allowed_models_for_user(client, user_id: str): + """ + Get the list of models allowed for a user based on their subscription tier. + + Returns: + List of model names allowed for the user's subscription tier. + """ + + subscription = await get_user_subscription(user_id) + tier_name = 'free' + + if subscription: + price_id = None + if subscription.get('items') and subscription['items'].get('data') and len(subscription['items']['data']) > 0: + price_id = subscription['items']['data'][0]['price']['id'] + else: + price_id = subscription.get('price_id', config.STRIPE_FREE_TIER_ID) + + # Get tier info for this price_id + tier_info = SUBSCRIPTION_TIERS.get(price_id) + if tier_info: + tier_name = tier_info['name'] + + # Return allowed models for this tier + return MODEL_ACCESS_TIERS.get(tier_name, MODEL_ACCESS_TIERS['free']) # Default to free tier if unknown + + +async def can_use_model(client, user_id: str, model_name: str): + if config.ENV_MODE == EnvMode.LOCAL: + logger.info("Running in local development mode - billing checks are disabled") + return True, "Local development mode - billing disabled", { + "price_id": "local_dev", + "plan_name": "Local Development", + "minutes_limit": "no limit" + } + + allowed_models = await get_allowed_models_for_user(client, user_id) + resolved_model = MODEL_NAME_ALIASES.get(model_name, model_name) + if resolved_model in allowed_models: + return True, "Model access allowed", allowed_models + + return False, f"Your current subscription plan does not include access to {model_name}. Please upgrade your subscription or choose from your available models: {', '.join(allowed_models)}", allowed_models + +async def check_billing_status(client, user_id: str) -> Tuple[bool, str, Optional[Dict]]: + """ + Check if a user can run agents based on their subscription and usage. + + Returns: + Tuple[bool, str, Optional[Dict]]: (can_run, message, subscription_info) + """ + if config.ENV_MODE == EnvMode.LOCAL: + logger.info("Running in local development mode - billing checks are disabled") + return True, "Local development mode - billing disabled", { + "price_id": "local_dev", + "plan_name": "Local Development", + "minutes_limit": "no limit" + } + + # Get current subscription + subscription = await get_user_subscription(user_id) + # print("Current subscription:", subscription) + + # If no subscription, they can use free tier + if not subscription: + subscription = { + 'price_id': config.STRIPE_FREE_TIER_ID, # Free tier + 'plan_name': 'free' + } + + # Extract price ID from subscription items + price_id = None + if subscription.get('items') and subscription['items'].get('data') and len(subscription['items']['data']) > 0: + price_id = subscription['items']['data'][0]['price']['id'] + else: + price_id = subscription.get('price_id', config.STRIPE_FREE_TIER_ID) + + # Get tier info - default to free tier if not found + tier_info = SUBSCRIPTION_TIERS.get(price_id) + if not tier_info: + logger.warning(f"Unknown subscription tier: {price_id}, defaulting to free tier") + tier_info = SUBSCRIPTION_TIERS[config.STRIPE_FREE_TIER_ID] + + # Calculate current month's usage + current_usage = await calculate_monthly_usage(client, user_id) + + # Check if within limits + if current_usage >= tier_info['minutes']: + return False, f"Monthly limit of {tier_info['minutes']} minutes reached. Please upgrade your plan or wait until next month.", subscription + + return True, "OK", subscription + +# API endpoints +@router.post("/create-checkout-session") +async def create_checkout_session( + request: CreateCheckoutSessionRequest, + current_user_id: str = Depends(get_current_user_id_from_jwt) +): + """Create a Stripe Checkout session or modify an existing subscription.""" + try: + # Get Supabase client + db = DBConnection() + client = await db.client + + # Get user email from auth.users + user_result = await client.auth.admin.get_user_by_id(current_user_id) + if not user_result: raise HTTPException(status_code=404, detail="User not found") + email = user_result.user.email + + # Get or create Stripe customer + customer_id = await get_stripe_customer_id(client, current_user_id) + if not customer_id: customer_id = await create_stripe_customer(client, current_user_id, email) + + # Get the target price and product ID + try: + price = stripe.Price.retrieve(request.price_id, expand=['product']) + product_id = price['product']['id'] + except stripe.error.InvalidRequestError: + raise HTTPException(status_code=400, detail=f"Invalid price ID: {request.price_id}") + + # Verify the price belongs to our product + if product_id != config.STRIPE_PRODUCT_ID: + raise HTTPException(status_code=400, detail="Price ID does not belong to the correct product.") + + # Check for existing subscription for our product + existing_subscription = await get_user_subscription(current_user_id) + # print("Existing subscription for product:", existing_subscription) + + if existing_subscription: + # --- Handle Subscription Change (Upgrade or Downgrade) --- + try: + subscription_id = existing_subscription['id'] + subscription_item = existing_subscription['items']['data'][0] + current_price_id = subscription_item['price']['id'] + + # Skip if already on this plan + if current_price_id == request.price_id: + return { + "subscription_id": subscription_id, + "status": "no_change", + "message": "Already subscribed to this plan.", + "details": { + "is_upgrade": None, + "effective_date": None, + "current_price": round(price['unit_amount'] / 100, 2) if price.get('unit_amount') else 0, + "new_price": round(price['unit_amount'] / 100, 2) if price.get('unit_amount') else 0, + } + } + + # Get current and new price details + current_price = stripe.Price.retrieve(current_price_id) + new_price = price # Already retrieved + is_upgrade = new_price['unit_amount'] > current_price['unit_amount'] + + if is_upgrade: + # --- Handle Upgrade --- Immediate modification + updated_subscription = stripe.Subscription.modify( + subscription_id, + items=[{ + 'id': subscription_item['id'], + 'price': request.price_id, + }], + proration_behavior='always_invoice', # Prorate and charge immediately + billing_cycle_anchor='now' # Reset billing cycle + ) + + # Update active status in database to true (customer has active subscription) + await client.schema('basejump').from_('billing_customers').update( + {'active': True} + ).eq('id', customer_id).execute() + logger.info(f"Updated customer {customer_id} active status to TRUE after subscription upgrade") + + latest_invoice = None + if updated_subscription.get('latest_invoice'): + latest_invoice = stripe.Invoice.retrieve(updated_subscription['latest_invoice']) + + return { + "subscription_id": updated_subscription['id'], + "status": "updated", + "message": "Subscription upgraded successfully", + "details": { + "is_upgrade": True, + "effective_date": "immediate", + "current_price": round(current_price['unit_amount'] / 100, 2) if current_price.get('unit_amount') else 0, + "new_price": round(new_price['unit_amount'] / 100, 2) if new_price.get('unit_amount') else 0, + "invoice": { + "id": latest_invoice['id'] if latest_invoice else None, + "status": latest_invoice['status'] if latest_invoice else None, + "amount_due": round(latest_invoice['amount_due'] / 100, 2) if latest_invoice else 0, + "amount_paid": round(latest_invoice['amount_paid'] / 100, 2) if latest_invoice else 0 + } if latest_invoice else None + } + } + else: + # --- Handle Downgrade --- Use Subscription Schedule + try: + current_period_end_ts = subscription_item['current_period_end'] + + # Retrieve the subscription again to get the schedule ID if it exists + # This ensures we have the latest state before creating/modifying schedule + sub_with_schedule = stripe.Subscription.retrieve(subscription_id) + schedule_id = sub_with_schedule.get('schedule') + + # Get the current phase configuration from the schedule or subscription + if schedule_id: + schedule = stripe.SubscriptionSchedule.retrieve(schedule_id) + # Find the current phase in the schedule + # This logic assumes simple schedules; might need refinement for complex ones + current_phase = None + for phase in reversed(schedule['phases']): + if phase['start_date'] <= datetime.now(timezone.utc).timestamp(): + current_phase = phase + break + if not current_phase: # Fallback if logic fails + current_phase = schedule['phases'][-1] + else: + # If no schedule, the current subscription state defines the current phase + current_phase = { + 'items': existing_subscription['items']['data'], # Use original items data + 'start_date': existing_subscription['current_period_start'], # Use sub start if no schedule + # Add other relevant fields if needed for create/modify + } + + # Prepare the current phase data for the update/create + # Ensure items is formatted correctly for the API + current_phase_items_for_api = [] + for item in current_phase.get('items', []): + price_data = item.get('price') + quantity = item.get('quantity') + price_id = None + + # Safely extract price ID whether it's an object or just the ID string + if isinstance(price_data, dict): + price_id = price_data.get('id') + elif isinstance(price_data, str): + price_id = price_data + + if price_id and quantity is not None: + current_phase_items_for_api.append({'price': price_id, 'quantity': quantity}) + else: + logger.warning(f"Skipping item in current phase due to missing price ID or quantity: {item}") + + if not current_phase_items_for_api: + raise ValueError("Could not determine valid items for the current phase.") + + current_phase_update_data = { + 'items': current_phase_items_for_api, + 'start_date': current_phase['start_date'], # Preserve original start date + 'end_date': current_period_end_ts, # End this phase at period end + 'proration_behavior': 'none' + # Include other necessary fields from current_phase if modifying? + # e.g., 'billing_cycle_anchor', 'collection_method'? Usually inherited. + } + + # Define the new (downgrade) phase + new_downgrade_phase_data = { + 'items': [{'price': request.price_id, 'quantity': 1}], + 'start_date': current_period_end_ts, # Start immediately after current phase ends + 'proration_behavior': 'none' + # iterations defaults to 1, meaning it runs for one billing cycle + # then schedule ends based on end_behavior + } + + # Update or Create Schedule + if schedule_id: + # Update existing schedule, replacing all future phases + # print(f"Updating existing schedule {schedule_id}") + logger.info(f"Updating existing schedule {schedule_id} for subscription {subscription_id}") + logger.debug(f"Current phase data: {current_phase_update_data}") + logger.debug(f"New phase data: {new_downgrade_phase_data}") + updated_schedule = stripe.SubscriptionSchedule.modify( + schedule_id, + phases=[current_phase_update_data, new_downgrade_phase_data], + end_behavior='release' + ) + logger.info(f"Successfully updated schedule {updated_schedule['id']}") + else: + # Create a new schedule using the defined phases + print(f"Creating new schedule for subscription {subscription_id}") + logger.info(f"Creating new schedule for subscription {subscription_id}") + # Deep debug logging - write subscription details to help diagnose issues + logger.debug(f"Subscription details: {subscription_id}, current_period_end_ts: {current_period_end_ts}") + logger.debug(f"Current price: {current_price_id}, New price: {request.price_id}") + + try: + updated_schedule = stripe.SubscriptionSchedule.create( + from_subscription=subscription_id, + phases=[ + { + 'start_date': current_phase['start_date'], + 'end_date': current_period_end_ts, + 'proration_behavior': 'none', + 'items': [ + { + 'price': current_price_id, + 'quantity': 1 + } + ] + }, + { + 'start_date': current_period_end_ts, + 'proration_behavior': 'none', + 'items': [ + { + 'price': request.price_id, + 'quantity': 1 + } + ] + } + ], + end_behavior='release' + ) + # Don't try to link the schedule - that's handled by from_subscription + logger.info(f"Created new schedule {updated_schedule['id']} from subscription {subscription_id}") + # print(f"Created new schedule {updated_schedule['id']} from subscription {subscription_id}") + + # Verify the schedule was created correctly + fetched_schedule = stripe.SubscriptionSchedule.retrieve(updated_schedule['id']) + logger.info(f"Schedule verification - Status: {fetched_schedule.get('status')}, Phase Count: {len(fetched_schedule.get('phases', []))}") + logger.debug(f"Schedule details: {fetched_schedule}") + except Exception as schedule_error: + logger.exception(f"Failed to create schedule: {str(schedule_error)}") + raise schedule_error # Re-raise to be caught by the outer try-except + + return { + "subscription_id": subscription_id, + "schedule_id": updated_schedule['id'], + "status": "scheduled", + "message": "Subscription downgrade scheduled", + "details": { + "is_upgrade": False, + "effective_date": "end_of_period", + "current_price": round(current_price['unit_amount'] / 100, 2) if current_price.get('unit_amount') else 0, + "new_price": round(new_price['unit_amount'] / 100, 2) if new_price.get('unit_amount') else 0, + "effective_at": datetime.fromtimestamp(current_period_end_ts, tz=timezone.utc).isoformat() + } + } + except Exception as e: + logger.exception(f"Error handling subscription schedule for sub {subscription_id}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error handling subscription schedule: {str(e)}") + except Exception as e: + logger.exception(f"Error updating subscription {existing_subscription.get('id') if existing_subscription else 'N/A'}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error updating subscription: {str(e)}") + else: + + session = stripe.checkout.Session.create( + customer=customer_id, + payment_method_types=['card'], + line_items=[{'price': request.price_id, 'quantity': 1}], + mode='subscription', + success_url=request.success_url, + cancel_url=request.cancel_url, + metadata={ + 'user_id': current_user_id, + 'product_id': product_id, + 'tolt_referral': request.tolt_referral + }, + allow_promotion_codes=True + ) + + # Update customer status to potentially active (will be confirmed by webhook) + # This ensures customer is marked as active once payment is completed + await client.schema('basejump').from_('billing_customers').update( + {'active': True} + ).eq('id', customer_id).execute() + logger.info(f"Updated customer {customer_id} active status to TRUE after creating checkout session") + + return {"session_id": session['id'], "url": session['url'], "status": "new"} + + except Exception as e: + logger.exception(f"Error creating checkout session: {str(e)}") + # Check if it's a Stripe error with more details + if hasattr(e, 'json_body') and e.json_body and 'error' in e.json_body: + error_detail = e.json_body['error'].get('message', str(e)) + else: + error_detail = str(e) + raise HTTPException(status_code=500, detail=f"Error creating checkout session: {error_detail}") + +@router.post("/create-portal-session") +async def create_portal_session( + request: CreatePortalSessionRequest, + current_user_id: str = Depends(get_current_user_id_from_jwt) +): + """Create a Stripe Customer Portal session for subscription management.""" + try: + # Get Supabase client + db = DBConnection() + client = await db.client + + # Get customer ID + customer_id = await get_stripe_customer_id(client, current_user_id) + if not customer_id: + raise HTTPException(status_code=404, detail="No billing customer found") + + # Ensure the portal configuration has subscription_update enabled + try: + # First, check if we have a configuration that already enables subscription update + configurations = stripe.billing_portal.Configuration.list(limit=100) + active_config = None + + # Look for a configuration with subscription_update enabled + for config in configurations.get('data', []): + features = config.get('features', {}) + subscription_update = features.get('subscription_update', {}) + if subscription_update.get('enabled', False): + active_config = config + logger.info(f"Found existing portal configuration with subscription_update enabled: {config['id']}") + break + + # If no config with subscription_update found, create one or update the active one + if not active_config: + # Find the active configuration or create a new one + if configurations.get('data', []): + default_config = configurations['data'][0] + logger.info(f"Updating default portal configuration: {default_config['id']} to enable subscription_update") + + active_config = stripe.billing_portal.Configuration.update( + default_config['id'], + features={ + 'subscription_update': { + 'enabled': True, + 'proration_behavior': 'create_prorations', + 'default_allowed_updates': ['price'] + }, + # Preserve other features that may already be enabled + 'customer_update': default_config.get('features', {}).get('customer_update', {'enabled': True, 'allowed_updates': ['email', 'address']}), + 'invoice_history': {'enabled': True}, + 'payment_method_update': {'enabled': True} + } + ) + else: + # Create a new configuration with subscription_update enabled + logger.info("Creating new portal configuration with subscription_update enabled") + active_config = stripe.billing_portal.Configuration.create( + business_profile={ + 'headline': 'Subscription Management', + 'privacy_policy_url': config.FRONTEND_URL + '/privacy', + 'terms_of_service_url': config.FRONTEND_URL + '/terms' + }, + features={ + 'subscription_update': { + 'enabled': True, + 'proration_behavior': 'create_prorations', + 'default_allowed_updates': ['price'] + }, + 'customer_update': { + 'enabled': True, + 'allowed_updates': ['email', 'address'] + }, + 'invoice_history': {'enabled': True}, + 'payment_method_update': {'enabled': True} + } + ) + + # Log the active configuration for debugging + logger.info(f"Using portal configuration: {active_config['id']} with subscription_update: {active_config.get('features', {}).get('subscription_update', {}).get('enabled', False)}") + + except Exception as config_error: + logger.warning(f"Error configuring portal: {config_error}. Continuing with default configuration.") + + # Create portal session using the proper configuration if available + portal_params = { + "customer": customer_id, + "return_url": request.return_url + } + + # Add configuration_id if we found or created one with subscription_update enabled + if active_config: + portal_params["configuration"] = active_config['id'] + + # Create the session + session = stripe.billing_portal.Session.create(**portal_params) + + return {"url": session.url} + + except Exception as e: + logger.error(f"Error creating portal session: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/subscription") +async def get_subscription( + current_user_id: str = Depends(get_current_user_id_from_jwt) +): + """Get the current subscription status for the current user, including scheduled changes.""" + try: + # Get subscription from Stripe (this helper already handles filtering/cleanup) + subscription = await get_user_subscription(current_user_id) + # print("Subscription data for status:", subscription) + + if not subscription: + # Default to free tier status if no active subscription for our product + free_tier_id = config.STRIPE_FREE_TIER_ID + free_tier_info = SUBSCRIPTION_TIERS.get(free_tier_id) + return SubscriptionStatus( + status="no_subscription", + plan_name=free_tier_info.get('name', 'free') if free_tier_info else 'free', + price_id=free_tier_id, + minutes_limit=free_tier_info.get('minutes') if free_tier_info else 0 + ) + + # Extract current plan details + current_item = subscription['items']['data'][0] + current_price_id = current_item['price']['id'] + current_tier_info = SUBSCRIPTION_TIERS.get(current_price_id) + if not current_tier_info: + # Fallback if somehow subscribed to an unknown price within our product + logger.warning(f"User {current_user_id} subscribed to unknown price {current_price_id}. Defaulting info.") + current_tier_info = {'name': 'unknown', 'minutes': 0} + + # Calculate current usage + db = DBConnection() + client = await db.client + current_usage = await calculate_monthly_usage(client, current_user_id) + + status_response = SubscriptionStatus( + status=subscription['status'], # 'active', 'trialing', etc. + plan_name=subscription['plan'].get('nickname') or current_tier_info['name'], + price_id=current_price_id, + current_period_end=datetime.fromtimestamp(current_item['current_period_end'], tz=timezone.utc), + cancel_at_period_end=subscription['cancel_at_period_end'], + trial_end=datetime.fromtimestamp(subscription['trial_end'], tz=timezone.utc) if subscription.get('trial_end') else None, + minutes_limit=current_tier_info['minutes'], + current_usage=round(current_usage, 2), + has_schedule=False # Default + ) + + # Check for an attached schedule (indicates pending downgrade) + schedule_id = subscription.get('schedule') + if schedule_id: + try: + schedule = stripe.SubscriptionSchedule.retrieve(schedule_id) + # Find the *next* phase after the current one + next_phase = None + current_phase_end = current_item['current_period_end'] + + for phase in schedule.get('phases', []): + # Check if this phase starts exactly when the current one ends + if phase.get('start_date') == current_phase_end: + next_phase = phase + break # Found the immediate next phase + + if next_phase: + scheduled_item = next_phase['items'][0] # Assuming single item + scheduled_price_id = scheduled_item['price'] # Price ID might be string here + scheduled_tier_info = SUBSCRIPTION_TIERS.get(scheduled_price_id) + + status_response.has_schedule = True + status_response.status = 'scheduled_downgrade' # Override status + status_response.scheduled_plan_name = scheduled_tier_info.get('name', 'unknown') if scheduled_tier_info else 'unknown' + status_response.scheduled_price_id = scheduled_price_id + status_response.scheduled_change_date = datetime.fromtimestamp(next_phase['start_date'], tz=timezone.utc) + + except Exception as schedule_error: + logger.error(f"Error retrieving or parsing schedule {schedule_id} for sub {subscription['id']}: {schedule_error}") + # Proceed without schedule info if retrieval fails + + return status_response + + except Exception as e: + logger.exception(f"Error getting subscription status for user {current_user_id}: {str(e)}") # Use logger.exception + raise HTTPException(status_code=500, detail="Error retrieving subscription status.") + +@router.get("/check-status") +async def check_status( + current_user_id: str = Depends(get_current_user_id_from_jwt) +): + """Check if the user can run agents based on their subscription and usage.""" + try: + # Get Supabase client + db = DBConnection() + client = await db.client + + can_run, message, subscription = await check_billing_status(client, current_user_id) + + return { + "can_run": can_run, + "message": message, + "subscription": subscription + } + + except Exception as e: + logger.error(f"Error checking billing status: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/webhook") +async def stripe_webhook(request: Request): + """Handle Stripe webhook events.""" + try: + # Get the webhook secret from config + webhook_secret = config.STRIPE_WEBHOOK_SECRET + + # Get the webhook payload + payload = await request.body() + sig_header = request.headers.get('stripe-signature') + + # Verify webhook signature + try: + event = stripe.Webhook.construct_event( + payload, sig_header, webhook_secret + ) + except ValueError as e: + raise HTTPException(status_code=400, detail="Invalid payload") + except stripe.error.SignatureVerificationError as e: + raise HTTPException(status_code=400, detail="Invalid signature") + + # Handle the event + if event.type in ['customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted']: + # Extract the subscription and customer information + subscription = event.data.object + customer_id = subscription.get('customer') + + if not customer_id: + logger.warning(f"No customer ID found in subscription event: {event.type}") + return {"status": "error", "message": "No customer ID found"} + + # Get database connection + db = DBConnection() + client = await db.client + + if event.type == 'customer.subscription.created' or event.type == 'customer.subscription.updated': + # Check if subscription is active + if subscription.get('status') in ['active', 'trialing']: + # Update customer's active status to true + await client.schema('basejump').from_('billing_customers').update( + {'active': True} + ).eq('id', customer_id).execute() + logger.info(f"Webhook: Updated customer {customer_id} active status to TRUE based on {event.type}") + else: + # Subscription is not active (e.g., past_due, canceled, etc.) + # Check if customer has any other active subscriptions before updating status + has_active = len(stripe.Subscription.list( + customer=customer_id, + status='active', + limit=1 + ).get('data', [])) > 0 + + if not has_active: + await client.schema('basejump').from_('billing_customers').update( + {'active': False} + ).eq('id', customer_id).execute() + logger.info(f"Webhook: Updated customer {customer_id} active status to FALSE based on {event.type}") + + elif event.type == 'customer.subscription.deleted': + # Check if customer has any other active subscriptions + has_active = len(stripe.Subscription.list( + customer=customer_id, + status='active', + limit=1 + ).get('data', [])) > 0 + + if not has_active: + # If no active subscriptions left, set active to false + await client.schema('basejump').from_('billing_customers').update( + {'active': False} + ).eq('id', customer_id).execute() + logger.info(f"Webhook: Updated customer {customer_id} active status to FALSE after subscription deletion") + + logger.info(f"Processed {event.type} event for customer {customer_id}") + + return {"status": "success"} + + except Exception as e: + logger.error(f"Error processing webhook: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/available-models") +async def get_available_models( + current_user_id: str = Depends(get_current_user_id_from_jwt) +): + """Get the list of models available to the user based on their subscription tier.""" + try: + # Get Supabase client + db = DBConnection() + client = await db.client + + # Check if we're in local development mode + if config.ENV_MODE == EnvMode.LOCAL: + logger.info("Running in local development mode - billing checks are disabled") + + # In local mode, return all models from MODEL_NAME_ALIASES + model_info = [] + for short_name, full_name in MODEL_NAME_ALIASES.items(): + # Skip entries where the key is a full name to avoid duplicates + # if short_name == full_name or '/' in short_name: + # continue + + model_info.append({ + "id": full_name, + "display_name": short_name, + "short_name": short_name, + "requires_subscription": False # Always false in local dev mode + }) + + return { + "models": model_info, + "subscription_tier": "Local Development", + "total_models": len(model_info) + } + + # For non-local mode, get list of allowed models for this user + allowed_models = await get_allowed_models_for_user(client, current_user_id) + free_tier_models = MODEL_ACCESS_TIERS.get('free', []) + + # Get subscription info for context + subscription = await get_user_subscription(current_user_id) + + # Determine tier name from subscription + tier_name = 'free' + if subscription: + price_id = None + if subscription.get('items') and subscription['items'].get('data') and len(subscription['items']['data']) > 0: + price_id = subscription['items']['data'][0]['price']['id'] + else: + price_id = subscription.get('price_id', config.STRIPE_FREE_TIER_ID) + + # Get tier info for this price_id + tier_info = SUBSCRIPTION_TIERS.get(price_id) + if tier_info: + tier_name = tier_info['name'] + + # Get all unique full model names from MODEL_NAME_ALIASES + all_models = set() + model_aliases = {} + + for short_name, full_name in MODEL_NAME_ALIASES.items(): + # Add all unique full model names + all_models.add(full_name) + + # Only include short names that don't match their full names for aliases + if short_name != full_name and not short_name.startswith("openai/") and not short_name.startswith("anthropic/") and not short_name.startswith("openrouter/") and not short_name.startswith("xai/"): + if full_name not in model_aliases: + model_aliases[full_name] = short_name + + # Create model info with display names for ALL models + model_info = [] + for model in all_models: + display_name = model_aliases.get(model, model.split('/')[-1] if '/' in model else model) + + # Check if model requires subscription (not in free tier) + requires_sub = model not in free_tier_models + + # Check if model is available with current subscription + is_available = model in allowed_models + + model_info.append({ + "id": model, + "display_name": display_name, + "short_name": model_aliases.get(model), + "requires_subscription": requires_sub, + "is_available": is_available + }) + + return { + "models": model_info, + "subscription_tier": tier_name, + "total_models": len(model_info) + } + + except Exception as e: + logger.error(f"Error getting available models: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error getting available models: {str(e)}") \ No newline at end of file diff --git a/app/services/docker/redis.conf b/app/services/docker/redis.conf new file mode 100644 index 000000000..b8b418006 --- /dev/null +++ b/app/services/docker/redis.conf @@ -0,0 +1 @@ +timeout 120 diff --git a/app/services/email.py b/app/services/email.py new file mode 100644 index 000000000..76dcb4740 --- /dev/null +++ b/app/services/email.py @@ -0,0 +1,192 @@ +import os +import logging +from typing import Optional +import mailtrap as mt +from utils.config import config + +logger = logging.getLogger(__name__) + +class EmailService: + def __init__(self): + self.api_token = os.getenv('MAILTRAP_API_TOKEN') + self.sender_email = os.getenv('MAILTRAP_SENDER_EMAIL', 'dom@kortix.ai') + self.sender_name = os.getenv('MAILTRAP_SENDER_NAME', 'Suna Team') + + if not self.api_token: + logger.warning("MAILTRAP_API_TOKEN not found in environment variables") + self.client = None + else: + self.client = mt.MailtrapClient(token=self.api_token) + + def send_welcome_email(self, user_email: str, user_name: Optional[str] = None) -> bool: + if not self.client: + logger.error("Cannot send email: MAILTRAP_API_TOKEN not configured") + return False + + if not user_name: + user_name = user_email.split('@')[0].title() + + subject = "🎉 Welcome to Suna — Let's Get Started " + html_content = self._get_welcome_email_template(user_name) + text_content = self._get_welcome_email_text(user_name) + + return self._send_email( + to_email=user_email, + to_name=user_name, + subject=subject, + html_content=html_content, + text_content=text_content + ) + + def _send_email( + self, + to_email: str, + to_name: str, + subject: str, + html_content: str, + text_content: str + ) -> bool: + try: + mail = mt.Mail( + sender=mt.Address(email=self.sender_email, name=self.sender_name), + to=[mt.Address(email=to_email, name=to_name)], + subject=subject, + text=text_content, + html=html_content, + category="welcome" + ) + + response = self.client.send(mail) + + logger.info(f"Welcome email sent to {to_email}. Response: {response}") + return True + + except Exception as e: + logger.error(f"Error sending email to {to_email}: {str(e)}") + return False + + def _get_welcome_email_template(self, user_name: str) -> str: + return f""" + + + + + Welcome to Kortix Suna + + + +
+
+ +
+

Welcome to Kortix Suna!

+ +

Hi {user_name},

+ +

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

+ +

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

+ +

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

+ +

🎁 Use code WELCOME15 at checkout.

+ +

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

+ +

For your business: if you want to automate manual and ordinary tasks for your company, book a call with us here

+ +

Thanks again, and welcome to the Suna community 🌞

+ +

— The Suna Team

+ + Go to the platform +
+ +""" + + def _get_welcome_email_text(self, user_name: str) -> str: + return f"""Hi {user_name}, + +Welcome to Suna — we're excited to have you on board! + +To get started, we'd like to get to know you better: fill out this short form! +https://docs.google.com/forms/d/e/1FAIpQLSef1EHuqmIh_iQz-kwhjnzSC3Ml-V_5wIySDpMoMU9W_j24JQ/viewform + +To celebrate your arrival, here's a 15% discount to try out the best version of Suna (1 month): +🎁 Use code WELCOME15 at checkout. + +Let us know if you need help getting started or have questions — we're always here, and join our Discord community: https://discord.com/invite/FjD644cfcs + +For your business: if you want to automate manual and ordinary tasks for your company, book a call with us here: https://cal.com/team/kortix/enterprise-demo + +Thanks again, and welcome to the Suna community 🌞 + +— The Suna Team + +Go to the platform: https://www.suna.so/ + +--- +© 2024 Suna. All rights reserved. +You received this email because you signed up for a Suna account.""" + +email_service = EmailService() diff --git a/app/services/email_api.py b/app/services/email_api.py new file mode 100644 index 000000000..9834c7baf --- /dev/null +++ b/app/services/email_api.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel, EmailStr +from typing import Optional +import asyncio +from services.email import email_service +from utils.logger import logger + +router = APIRouter() + +class SendWelcomeEmailRequest(BaseModel): + email: EmailStr + name: Optional[str] = None + +class EmailResponse(BaseModel): + success: bool + message: str + +@router.post("/send-welcome-email", response_model=EmailResponse) +async def send_welcome_email(request: SendWelcomeEmailRequest): + try: + logger.info(f"Sending welcome email to {request.email}") + success = email_service.send_welcome_email( + user_email=request.email, + user_name=request.name + ) + + if success: + return EmailResponse( + success=True, + message="Welcome email sent successfully" + ) + else: + return EmailResponse( + success=False, + message="Failed to send welcome email" + ) + + except Exception as e: + logger.error(f"Error sending welcome email to {request.email}: {str(e)}") + raise HTTPException( + status_code=500, + detail="Internal server error while sending email" + ) + +@router.post("/send-welcome-email-background", response_model=EmailResponse) +async def send_welcome_email_background(request: SendWelcomeEmailRequest): + try: + logger.info(f"Queuing welcome email for {request.email}") + + def send_email(): + return email_service.send_welcome_email( + user_email=request.email, + user_name=request.name + ) + + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(send_email) + + return EmailResponse( + success=True, + message="Welcome email queued for sending" + ) + + except Exception as e: + logger.error(f"Error queuing welcome email for {request.email}: {str(e)}") + raise HTTPException( + status_code=500, + detail="Internal server error while queuing email" + ) diff --git a/app/services/langfuse.py b/app/services/langfuse.py new file mode 100644 index 000000000..cf624bf3b --- /dev/null +++ b/app/services/langfuse.py @@ -0,0 +1,12 @@ +import os +from langfuse import Langfuse + +public_key = os.getenv("LANGFUSE_PUBLIC_KEY") +secret_key = os.getenv("LANGFUSE_SECRET_KEY") +host = os.getenv("LANGFUSE_HOST", "https://cloud.langfuse.com") + +enabled = False +if public_key and secret_key: + enabled = True + +langfuse = Langfuse(enabled=enabled) diff --git a/app/services/llm.py b/app/services/llm.py new file mode 100644 index 000000000..c74917045 --- /dev/null +++ b/app/services/llm.py @@ -0,0 +1,411 @@ +""" +LLM API interface for making calls to various language models. + +This module provides a unified interface for making API calls to different LLM providers +(OpenAI, Anthropic, Groq, etc.) using LiteLLM. It includes support for: +- Streaming responses +- Tool calls and function calling +- Retry logic with exponential backoff +- Model-specific configurations +- Comprehensive error handling and logging +""" + +from typing import Union, Dict, Any, Optional, AsyncGenerator, List +import os +import json +import asyncio +from openai import OpenAIError +import litellm +from utils.logger import logger +from utils.config import config + +# litellm.set_verbose=True +litellm.modify_params=True + +# Constants +MAX_RETRIES = 2 +RATE_LIMIT_DELAY = 30 +RETRY_DELAY = 0.1 + +class LLMError(Exception): + """Base exception for LLM-related errors.""" + pass + +class LLMRetryError(LLMError): + """Exception raised when retries are exhausted.""" + pass + +def setup_api_keys() -> None: + """Set up API keys from environment variables.""" + providers = ['OPENAI', 'ANTHROPIC', 'GROQ', 'OPENROUTER'] + for provider in providers: + key = getattr(config, f'{provider}_API_KEY') + if key: + logger.debug(f"API key set for provider: {provider}") + else: + logger.warning(f"No API key found for provider: {provider}") + + # Set up OpenRouter API base if not already set + if config.OPENROUTER_API_KEY and config.OPENROUTER_API_BASE: + os.environ['OPENROUTER_API_BASE'] = config.OPENROUTER_API_BASE + logger.debug(f"Set OPENROUTER_API_BASE to {config.OPENROUTER_API_BASE}") + + # Set up AWS Bedrock credentials + aws_access_key = config.AWS_ACCESS_KEY_ID + aws_secret_key = config.AWS_SECRET_ACCESS_KEY + aws_region = config.AWS_REGION_NAME + + if aws_access_key and aws_secret_key and aws_region: + logger.debug(f"AWS credentials set for Bedrock in region: {aws_region}") + # Configure LiteLLM to use AWS credentials + os.environ['AWS_ACCESS_KEY_ID'] = aws_access_key + os.environ['AWS_SECRET_ACCESS_KEY'] = aws_secret_key + os.environ['AWS_REGION_NAME'] = aws_region + else: + logger.warning(f"Missing AWS credentials for Bedrock integration - access_key: {bool(aws_access_key)}, secret_key: {bool(aws_secret_key)}, region: {aws_region}") + +async def handle_error(error: Exception, attempt: int, max_attempts: int) -> None: + """Handle API errors with appropriate delays and logging.""" + delay = RATE_LIMIT_DELAY if isinstance(error, litellm.exceptions.RateLimitError) else RETRY_DELAY + logger.warning(f"Error on attempt {attempt + 1}/{max_attempts}: {str(error)}") + logger.debug(f"Waiting {delay} seconds before retry...") + await asyncio.sleep(delay) + +def prepare_params( + messages: List[Dict[str, Any]], + model_name: str, + temperature: float = 0, + max_tokens: Optional[int] = None, + response_format: Optional[Any] = None, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: str = "auto", + api_key: Optional[str] = None, + api_base: Optional[str] = None, + stream: bool = False, + top_p: Optional[float] = None, + model_id: Optional[str] = None, + enable_thinking: Optional[bool] = False, + reasoning_effort: Optional[str] = 'low' +) -> Dict[str, Any]: + """Prepare parameters for the API call.""" + params = { + "model": model_name, + "messages": messages, + "temperature": temperature, + "response_format": response_format, + "top_p": top_p, + "stream": stream, + } + + if api_key: + params["api_key"] = api_key + if api_base: + params["api_base"] = api_base + if model_id: + params["model_id"] = model_id + + # Handle token limits + if max_tokens is not None: + # For Claude 3.7 in Bedrock, do not set max_tokens or max_tokens_to_sample + # as it causes errors with inference profiles + if model_name.startswith("bedrock/") and "claude-3-7" in model_name: + logger.debug(f"Skipping max_tokens for Claude 3.7 model: {model_name}") + # Do not add any max_tokens parameter for Claude 3.7 + else: + param_name = "max_completion_tokens" if 'o1' in model_name else "max_tokens" + params[param_name] = max_tokens + + # Add tools if provided + if tools: + params.update({ + "tools": tools, + "tool_choice": tool_choice + }) + logger.debug(f"Added {len(tools)} tools to API parameters") + + # # Add Claude-specific headers + if "claude" in model_name.lower() or "anthropic" in model_name.lower(): + params["extra_headers"] = { + # "anthropic-beta": "max-tokens-3-5-sonnet-2024-07-15" + "anthropic-beta": "output-128k-2025-02-19" + } + params["fallbacks"] = [{ + "model": "openrouter/anthropic/claude-sonnet-4", + "messages": messages, + }] + logger.debug("Added Claude-specific headers") + + # Add OpenRouter-specific parameters + if model_name.startswith("openrouter/"): + logger.debug(f"Preparing OpenRouter parameters for model: {model_name}") + + # Add optional site URL and app name from config + site_url = config.OR_SITE_URL + app_name = config.OR_APP_NAME + if site_url or app_name: + extra_headers = params.get("extra_headers", {}) + if site_url: + extra_headers["HTTP-Referer"] = site_url + if app_name: + extra_headers["X-Title"] = app_name + params["extra_headers"] = extra_headers + logger.debug(f"Added OpenRouter site URL and app name to headers") + + # Add Bedrock-specific parameters + if model_name.startswith("bedrock/"): + logger.debug(f"Preparing AWS Bedrock parameters for model: {model_name}") + + if not model_id and "anthropic.claude-3-7-sonnet" in model_name: + params["model_id"] = "arn:aws:bedrock:us-west-2:935064898258:inference-profile/us.anthropic.claude-3-7-sonnet-20250219-v1:0" + logger.debug(f"Auto-set model_id for Claude 3.7 Sonnet: {params['model_id']}") + + # Apply Anthropic prompt caching (minimal implementation) + # Check model name *after* potential modifications (like adding bedrock/ prefix) + effective_model_name = params.get("model", model_name) # Use model from params if set, else original + if "claude" in effective_model_name.lower() or "anthropic" in effective_model_name.lower(): + messages = params["messages"] # Direct reference, modification affects params + + # Ensure messages is a list + if not isinstance(messages, list): + return params # Return early if messages format is unexpected + + # 1. Process the first message if it's a system prompt with string content + if messages and messages[0].get("role") == "system": + content = messages[0].get("content") + if isinstance(content, str): + # Wrap the string content in the required list structure + messages[0]["content"] = [ + {"type": "text", "text": content, "cache_control": {"type": "ephemeral"}} + ] + elif isinstance(content, list): + # If content is already a list, check if the first text block needs cache_control + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + if "cache_control" not in item: + item["cache_control"] = {"type": "ephemeral"} + break # Apply to the first text block only for system prompt + + # 2. Find and process relevant user and assistant messages (limit to 4 max) + last_user_idx = -1 + second_last_user_idx = -1 + last_assistant_idx = -1 + + for i in range(len(messages) - 1, -1, -1): + role = messages[i].get("role") + if role == "user": + if last_user_idx == -1: + last_user_idx = i + elif second_last_user_idx == -1: + second_last_user_idx = i + elif role == "assistant": + if last_assistant_idx == -1: + last_assistant_idx = i + + # Stop searching if we've found all needed messages (system, last user, second last user, last assistant) + found_count = sum(idx != -1 for idx in [last_user_idx, second_last_user_idx, last_assistant_idx]) + if found_count >= 3: + break + + # Helper function to apply cache control + def apply_cache_control(message_idx: int, message_role: str): + if message_idx == -1: + return + + message = messages[message_idx] + content = message.get("content") + + if isinstance(content, str): + message["content"] = [ + {"type": "text", "text": content, "cache_control": {"type": "ephemeral"}} + ] + elif isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + if "cache_control" not in item: + item["cache_control"] = {"type": "ephemeral"} + + # Apply cache control to the identified messages (max 4: system, last user, second last user, last assistant) + # System message is always at index 0 if present + apply_cache_control(0, "system") + apply_cache_control(last_user_idx, "last user") + apply_cache_control(second_last_user_idx, "second last user") + apply_cache_control(last_assistant_idx, "last assistant") + + # Add reasoning_effort for Anthropic models if enabled + use_thinking = enable_thinking if enable_thinking is not None else False + is_anthropic = "anthropic" in effective_model_name.lower() or "claude" in effective_model_name.lower() + + if is_anthropic and use_thinking: + effort_level = reasoning_effort if reasoning_effort else 'low' + params["reasoning_effort"] = effort_level + params["temperature"] = 1.0 # Required by Anthropic when reasoning_effort is used + logger.info(f"Anthropic thinking enabled with reasoning_effort='{effort_level}'") + + return params + +async def make_llm_api_call( + messages: List[Dict[str, Any]], + model_name: str, + response_format: Optional[Any] = None, + temperature: float = 0, + max_tokens: Optional[int] = None, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: str = "auto", + api_key: Optional[str] = None, + api_base: Optional[str] = None, + stream: bool = False, + top_p: Optional[float] = None, + model_id: Optional[str] = None, + enable_thinking: Optional[bool] = False, + reasoning_effort: Optional[str] = 'low' +) -> Union[Dict[str, Any], AsyncGenerator]: + """ + Make an API call to a language model using LiteLLM. + + Args: + messages: List of message dictionaries for the conversation + model_name: Name of the model to use (e.g., "gpt-4", "claude-3", "openrouter/openai/gpt-4", "bedrock/anthropic.claude-3-sonnet-20240229-v1:0") + response_format: Desired format for the response + temperature: Sampling temperature (0-1) + max_tokens: Maximum tokens in the response + tools: List of tool definitions for function calling + tool_choice: How to select tools ("auto" or "none") + api_key: Override default API key + api_base: Override default API base URL + stream: Whether to stream the response + top_p: Top-p sampling parameter + model_id: Optional ARN for Bedrock inference profiles + enable_thinking: Whether to enable thinking + reasoning_effort: Level of reasoning effort + + Returns: + Union[Dict[str, Any], AsyncGenerator]: API response or stream + + Raises: + LLMRetryError: If API call fails after retries + LLMError: For other API-related errors + """ + # debug .json messages + logger.info(f"Making LLM API call to model: {model_name} (Thinking: {enable_thinking}, Effort: {reasoning_effort})") + logger.info(f"📡 API Call: Using model {model_name}") + params = prepare_params( + messages=messages, + model_name=model_name, + temperature=temperature, + max_tokens=max_tokens, + response_format=response_format, + tools=tools, + tool_choice=tool_choice, + api_key=api_key, + api_base=api_base, + stream=stream, + top_p=top_p, + model_id=model_id, + enable_thinking=enable_thinking, + reasoning_effort=reasoning_effort + ) + last_error = None + for attempt in range(MAX_RETRIES): + try: + logger.debug(f"Attempt {attempt + 1}/{MAX_RETRIES}") + # logger.debug(f"API request parameters: {json.dumps(params, indent=2)}") + + response = await litellm.acompletion(**params) + logger.debug(f"Successfully received API response from {model_name}") + logger.debug(f"Response: {response}") + return response + + except (litellm.exceptions.RateLimitError, OpenAIError, json.JSONDecodeError) as e: + last_error = e + await handle_error(e, attempt, MAX_RETRIES) + + except Exception as e: + logger.error(f"Unexpected error during API call: {str(e)}", exc_info=True) + raise LLMError(f"API call failed: {str(e)}") + + error_msg = f"Failed to make API call after {MAX_RETRIES} attempts" + if last_error: + error_msg += f". Last error: {str(last_error)}" + logger.error(error_msg, exc_info=True) + raise LLMRetryError(error_msg) + +# Initialize API keys on module import +setup_api_keys() + +# Test code for OpenRouter integration +async def test_openrouter(): + """Test the OpenRouter integration with a simple query.""" + test_messages = [ + {"role": "user", "content": "Hello, can you give me a quick test response?"} + ] + + try: + # Test with standard OpenRouter model + print("\n--- Testing standard OpenRouter model ---") + response = await make_llm_api_call( + model_name="openrouter/openai/gpt-4o-mini", + messages=test_messages, + temperature=0.7, + max_tokens=100 + ) + print(f"Response: {response.choices[0].message.content}") + + # Test with deepseek model + print("\n--- Testing deepseek model ---") + response = await make_llm_api_call( + model_name="openrouter/deepseek/deepseek-r1-distill-llama-70b", + messages=test_messages, + temperature=0.7, + max_tokens=100 + ) + print(f"Response: {response.choices[0].message.content}") + print(f"Model used: {response.model}") + + # Test with Mistral model + print("\n--- Testing Mistral model ---") + response = await make_llm_api_call( + model_name="openrouter/mistralai/mixtral-8x7b-instruct", + messages=test_messages, + temperature=0.7, + max_tokens=100 + ) + print(f"Response: {response.choices[0].message.content}") + print(f"Model used: {response.model}") + + return True + except Exception as e: + print(f"Error testing OpenRouter: {str(e)}") + return False + +async def test_bedrock(): + """Test the AWS Bedrock integration with a simple query.""" + test_messages = [ + {"role": "user", "content": "Hello, can you give me a quick test response?"} + ] + + try: + response = await make_llm_api_call( + model_name="bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0", + model_id="arn:aws:bedrock:us-west-2:935064898258:inference-profile/us.anthropic.claude-3-7-sonnet-20250219-v1:0", + messages=test_messages, + temperature=0.7, + # Claude 3.7 has issues with max_tokens, so omit it + # max_tokens=100 + ) + print(f"Response: {response.choices[0].message.content}") + print(f"Model used: {response.model}") + + return True + except Exception as e: + print(f"Error testing Bedrock: {str(e)}") + return False + +if __name__ == "__main__": + import asyncio + + test_success = asyncio.run(test_bedrock()) + + if test_success: + print("\n✅ integration test completed successfully!") + else: + print("\n❌ Bedrock integration test failed!") diff --git a/app/services/mcp_custom.py b/app/services/mcp_custom.py new file mode 100644 index 000000000..1a9c245e1 --- /dev/null +++ b/app/services/mcp_custom.py @@ -0,0 +1,129 @@ +import os +import sys +import json +import asyncio +import subprocess +from typing import Dict, Any +from concurrent.futures import ThreadPoolExecutor +from fastapi import HTTPException # type: ignore +from utils.logger import logger +from mcp import ClientSession +from mcp.client.sse import sse_client # type: ignore +from mcp.client.streamable_http import streamablehttp_client # type: ignore + +async def connect_streamable_http_server(url): + async with streamablehttp_client(url) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + tool_result = await session.list_tools() + print(f"Connected via HTTP ({len(tool_result.tools)} tools)") + + tools_info = [] + for tool in tool_result.tools: + tool_info = { + "name": tool.name, + "description": tool.description, + "inputSchema": tool.inputSchema + } + tools_info.append(tool_info) + + return tools_info + +async def discover_custom_tools(request_type: str, config: Dict[str, Any]): + logger.info(f"Received custom MCP discovery request: type={request_type}") + logger.debug(f"Request config: {config}") + + tools = [] + server_name = None + + if request_type == 'http': + if 'url' not in config: + raise HTTPException(status_code=400, detail="HTTP configuration must include 'url' field") + url = config['url'] + + try: + async with asyncio.timeout(15): + tools_info = await connect_streamable_http_server(url) + for tool_info in tools_info: + tools.append({ + "name": tool_info["name"], + "description": tool_info["description"], + "inputSchema": tool_info["inputSchema"] + }) + except asyncio.TimeoutError: + raise HTTPException(status_code=408, detail="Connection timeout - server took too long to respond") + except Exception as e: + logger.error(f"Error connecting to HTTP MCP server: {e}") + raise HTTPException(status_code=400, detail=f"Failed to connect to MCP server: {str(e)}") + + elif request_type == 'sse': + if 'url' not in config: + raise HTTPException(status_code=400, detail="SSE configuration must include 'url' field") + + url = config['url'] + headers = config.get('headers', {}) + + try: + async with asyncio.timeout(15): + try: + async with sse_client(url, headers=headers) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools_result = await session.list_tools() + tools_info = [] + for tool in tools_result.tools: + tool_info = { + "name": tool.name, + "description": tool.description, + "input_schema": tool.inputSchema + } + tools_info.append(tool_info) + + for tool_info in tools_info: + tools.append({ + "name": tool_info["name"], + "description": tool_info["description"], + "inputSchema": tool_info["input_schema"] + }) + except TypeError as e: + if "unexpected keyword argument" in str(e): + async with sse_client(url) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools_result = await session.list_tools() + tools_info = [] + for tool in tools_result.tools: + tool_info = { + "name": tool.name, + "description": tool.description, + "input_schema": tool.inputSchema + } + tools_info.append(tool_info) + + for tool_info in tools_info: + tools.append({ + "name": tool_info["name"], + "description": tool_info["description"], + "inputSchema": tool_info["input_schema"] + }) + else: + raise + except asyncio.TimeoutError: + raise HTTPException(status_code=408, detail="Connection timeout - server took too long to respond") + except Exception as e: + logger.error(f"Error connecting to SSE MCP server: {e}") + raise HTTPException(status_code=400, detail=f"Failed to connect to MCP server: {str(e)}") + else: + raise HTTPException(status_code=400, detail="Invalid server type. Must be 'http' or 'sse'") + + response_data = {"tools": tools, "count": len(tools)} + + if server_name: + response_data["serverName"] = server_name + + logger.info(f"Returning {len(tools)} tools for server {server_name}") + return response_data diff --git a/app/services/mcp_temp.py b/app/services/mcp_temp.py new file mode 100644 index 000000000..71d3c01c7 --- /dev/null +++ b/app/services/mcp_temp.py @@ -0,0 +1,299 @@ +import os +import sys +import json +import asyncio +import subprocess +from typing import Dict, Any +from concurrent.futures import ThreadPoolExecutor +from fastapi import HTTPException # type: ignore +from utils.logger import logger +from mcp import ClientSession +from mcp.client.sse import sse_client # type: ignore +from mcp.client.streamable_http import streamablehttp_client # type: ignore + +windows_executor = ThreadPoolExecutor(max_workers=4) + +# def run_mcp_stdio_sync(command, args, env_vars, timeout=30): +# try: +# env = os.environ.copy() +# env.update(env_vars) + +# full_command = [command] + args + +# process = subprocess.Popen( +# full_command, +# stdin=subprocess.PIPE, +# stdout=subprocess.PIPE, +# stderr=subprocess.PIPE, +# env=env, +# text=True, +# bufsize=0, +# creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0 +# ) + +# init_request = { +# "jsonrpc": "2.0", +# "id": 1, +# "method": "initialize", +# "params": { +# "protocolVersion": "2024-11-05", +# "capabilities": {}, +# "clientInfo": {"name": "mcp-client", "version": "1.0.0"} +# } +# } + +# process.stdin.write(json.dumps(init_request) + "\n") +# process.stdin.flush() + +# init_response_line = process.stdout.readline().strip() +# if not init_response_line: +# raise Exception("No response from MCP server during initialization") + +# init_response = json.loads(init_response_line) + +# init_notification = { +# "jsonrpc": "2.0", +# "method": "notifications/initialized" +# } +# process.stdin.write(json.dumps(init_notification) + "\n") +# process.stdin.flush() + +# tools_request = { +# "jsonrpc": "2.0", +# "id": 2, +# "method": "tools/list", +# "params": {} +# } + +# process.stdin.write(json.dumps(tools_request) + "\n") +# process.stdin.flush() + +# tools_response_line = process.stdout.readline().strip() +# if not tools_response_line: +# raise Exception("No response from MCP server for tools list") + +# tools_response = json.loads(tools_response_line) + +# tools_info = [] +# if "result" in tools_response and "tools" in tools_response["result"]: +# for tool in tools_response["result"]["tools"]: +# tool_info = { +# "name": tool["name"], +# "description": tool.get("description", ""), +# "input_schema": tool.get("inputSchema", {}) +# } +# tools_info.append(tool_info) + +# return { +# "status": "connected", +# "transport": "stdio", +# "tools": tools_info +# } + +# except subprocess.TimeoutExpired: +# return { +# "status": "error", +# "error": f"Process timeout after {timeout} seconds", +# "tools": [] +# } +# except json.JSONDecodeError as e: +# return { +# "status": "error", +# "error": f"Invalid JSON response: {str(e)}", +# "tools": [] +# } +# except Exception as e: +# return { +# "status": "error", +# "error": str(e), +# "tools": [] +# } +# finally: +# try: +# if 'process' in locals(): +# process.terminate() +# process.wait(timeout=5) +# except: +# pass + + +# async def connect_stdio_server_windows(server_name, server_config, all_tools, timeout): +# """Windows-compatible stdio connection using subprocess""" + +# logger.info(f"Connecting to {server_name} using Windows subprocess method") + +# command = server_config["command"] +# args = server_config.get("args", []) +# env_vars = server_config.get("env", {}) + +# loop = asyncio.get_event_loop() +# result = await loop.run_in_executor( +# windows_executor, +# run_mcp_stdio_sync, +# command, +# args, +# env_vars, +# timeout +# ) + +# all_tools[server_name] = result + +# if result["status"] == "connected": +# logger.info(f" {server_name}: Connected via Windows subprocess ({len(result['tools'])} tools)") +# else: +# logger.error(f" {server_name}: Error - {result['error']}") + + +# async def list_mcp_tools_mixed_windows(config, timeout=15): +# all_tools = {} + +# if "mcpServers" not in config: +# return all_tools + +# mcp_servers = config["mcpServers"] + +# for server_name, server_config in mcp_servers.items(): +# logger.info(f"Connecting to MCP server: {server_name}") +# if server_config.get("disabled", False): +# all_tools[server_name] = {"status": "disabled", "tools": []} +# logger.info(f" {server_name}: Disabled") +# continue + +# try: +# await connect_stdio_server_windows(server_name, server_config, all_tools, timeout) + +# except asyncio.TimeoutError: +# all_tools[server_name] = { +# "status": "error", +# "error": f"Connection timeout after {timeout} seconds", +# "tools": [] +# } +# logger.error(f" {server_name}: Timeout after {timeout} seconds") +# except Exception as e: +# error_msg = str(e) +# all_tools[server_name] = { +# "status": "error", +# "error": error_msg, +# "tools": [] +# } +# logger.error(f" {server_name}: Error - {error_msg}") +# import traceback +# logger.debug(f"Full traceback for {server_name}: {traceback.format_exc()}") + +# return all_tools + + +async def discover_custom_tools(request_type: str, config: Dict[str, Any]): + logger.info(f"Received custom MCP discovery request: type={request_type}") + logger.debug(f"Request config: {config}") + + tools = [] + server_name = None + + # if request_type == 'json': + # try: + # all_tools = await list_mcp_tools_mixed_windows(config, timeout=30) + # if "mcpServers" in config and config["mcpServers"]: + # server_name = list(config["mcpServers"].keys())[0] + + # if server_name in all_tools: + # server_info = all_tools[server_name] + # if server_info["status"] == "connected": + # tools = server_info["tools"] + # logger.info(f"Found {len(tools)} tools for server {server_name}") + # else: + # error_msg = server_info.get("error", "Unknown error") + # logger.error(f"Server {server_name} failed: {error_msg}") + # raise HTTPException( + # status_code=400, + # detail=f"Failed to connect to MCP server '{server_name}': {error_msg}" + # ) + # else: + # logger.error(f"Server {server_name} not found in results") + # raise HTTPException(status_code=400, detail=f"Server '{server_name}' not found in results") + # else: + # logger.error("No MCP servers configured") + # raise HTTPException(status_code=400, detail="No MCP servers configured") + + # except HTTPException: + # raise + # except Exception as e: + # logger.error(f"Error connecting to stdio MCP server: {e}") + # import traceback + # logger.error(f"Full traceback: {traceback.format_exc()}") + # raise HTTPException(status_code=400, detail=f"Failed to connect to MCP server: {str(e)}") + + # if request_type == 'http': + # if 'url' not in config: + # raise HTTPException(status_code=400, detail="HTTP configuration must include 'url' field") + # url = config['url'] + # await connect_streamable_http_server(url) + # tools = await connect_streamable_http_server(url) + + # elif request_type == 'sse': + # if 'url' not in config: + # raise HTTPException(status_code=400, detail="SSE configuration must include 'url' field") + + # url = config['url'] + # headers = config.get('headers', {}) + + # try: + # async with asyncio.timeout(15): + # try: + # async with sse_client(url, headers=headers) as (read, write): + # async with ClientSession(read, write) as session: + # await session.initialize() + # tools_result = await session.list_tools() + # tools_info = [] + # for tool in tools_result.tools: + # tool_info = { + # "name": tool.name, + # "description": tool.description, + # "input_schema": tool.inputSchema + # } + # tools_info.append(tool_info) + + # for tool_info in tools_info: + # tools.append({ + # "name": tool_info["name"], + # "description": tool_info["description"], + # "inputSchema": tool_info["input_schema"] + # }) + # except TypeError as e: + # if "unexpected keyword argument" in str(e): + # async with sse_client(url) as (read, write): + # async with ClientSession(read, write) as session: + # await session.initialize() + # tools_result = await session.list_tools() + # tools_info = [] + # for tool in tools_result.tools: + # tool_info = { + # "name": tool.name, + # "description": tool.description, + # "input_schema": tool.inputSchema + # } + # tools_info.append(tool_info) + + # for tool_info in tools_info: + # tools.append({ + # "name": tool_info["name"], + # "description": tool_info["description"], + # "inputSchema": tool_info["input_schema"] + # }) + # else: + # raise + # except asyncio.TimeoutError: + # raise HTTPException(status_code=408, detail="Connection timeout - server took too long to respond") + # except Exception as e: + # logger.error(f"Error connecting to SSE MCP server: {e}") + # raise HTTPException(status_code=400, detail=f"Failed to connect to MCP server: {str(e)}") + # else: + # raise HTTPException(status_code=400, detail="Invalid server type. Must be 'json' or 'sse'") + + # response_data = {"tools": tools, "count": len(tools)} + + # if server_name: + # response_data["serverName"] = server_name + + # logger.info(f"Returning {len(tools)} tools for server {server_name}") + # return response_data diff --git a/app/services/redis.py b/app/services/redis.py new file mode 100644 index 000000000..12598790c --- /dev/null +++ b/app/services/redis.py @@ -0,0 +1,153 @@ +import redis.asyncio as redis +import os +from dotenv import load_dotenv +import asyncio +from utils.logger import logger +from typing import List, Any +from utils.retry import retry + +# Redis client +client: redis.Redis | None = None +_initialized = False +_init_lock = asyncio.Lock() + +# Constants +REDIS_KEY_TTL = 3600 * 24 # 24 hour TTL as safety mechanism + + +def initialize(): + """Initialize Redis connection using environment variables.""" + global client + + # Load environment variables if not already loaded + load_dotenv() + + # Get Redis configuration + redis_host = os.getenv("REDIS_HOST", "redis") + redis_port = int(os.getenv("REDIS_PORT", 6379)) + redis_password = os.getenv("REDIS_PASSWORD", "") + # Convert string 'True'/'False' to boolean + redis_ssl_str = os.getenv("REDIS_SSL", "False") + redis_ssl = redis_ssl_str.lower() == "true" + + logger.info(f"Initializing Redis connection to {redis_host}:{redis_port}") + + # Create Redis client with basic configuration + client = redis.Redis( + host=redis_host, + port=redis_port, + password=redis_password, + ssl=redis_ssl, + decode_responses=True, + socket_timeout=5.0, + socket_connect_timeout=5.0, + retry_on_timeout=True, + health_check_interval=30, + ) + + return client + + +async def initialize_async(): + """Initialize Redis connection asynchronously.""" + global client, _initialized + + async with _init_lock: + if not _initialized: + logger.info("Initializing Redis connection") + initialize() + + try: + await client.ping() + logger.info("Successfully connected to Redis") + _initialized = True + except Exception as e: + logger.error(f"Failed to connect to Redis: {e}") + client = None + _initialized = False + raise + + return client + + +async def close(): + """Close Redis connection.""" + global client, _initialized + if client: + logger.info("Closing Redis connection") + await client.aclose() + client = None + _initialized = False + logger.info("Redis connection closed") + + +async def get_client(): + """Get the Redis client, initializing if necessary.""" + global client, _initialized + if client is None or not _initialized: + await retry(lambda: initialize_async()) + return client + + +# Basic Redis operations +async def set(key: str, value: str, ex: int = None, nx: bool = False): + """Set a Redis key.""" + redis_client = await get_client() + return await redis_client.set(key, value, ex=ex, nx=nx) + + +async def get(key: str, default: str = None): + """Get a Redis key.""" + redis_client = await get_client() + result = await redis_client.get(key) + return result if result is not None else default + + +async def delete(key: str): + """Delete a Redis key.""" + redis_client = await get_client() + return await redis_client.delete(key) + + +async def publish(channel: str, message: str): + """Publish a message to a Redis channel.""" + redis_client = await get_client() + return await redis_client.publish(channel, message) + + +async def create_pubsub(): + """Create a Redis pubsub object.""" + redis_client = await get_client() + return redis_client.pubsub() + + +# List operations +async def rpush(key: str, *values: Any): + """Append one or more values to a list.""" + redis_client = await get_client() + return await redis_client.rpush(key, *values) + + +async def lrange(key: str, start: int, end: int) -> List[str]: + """Get a range of elements from a list.""" + redis_client = await get_client() + return await redis_client.lrange(key, start, end) + + +async def llen(key: str) -> int: + """Get the length of a list.""" + redis_client = await get_client() + return await redis_client.llen(key) + + +# Key management +async def expire(key: str, time: int): + """Set a key's time to live in seconds.""" + redis_client = await get_client() + return await redis_client.expire(key, time) + + +async def keys(pattern: str) -> List[str]: + """Get keys matching a pattern.""" + redis_client = await get_client() + return await redis_client.keys(pattern) diff --git a/app/services/supabase.py b/app/services/supabase.py new file mode 100644 index 000000000..0a3f8558c --- /dev/null +++ b/app/services/supabase.py @@ -0,0 +1,113 @@ +""" +Centralized database connection management for AgentPress using Supabase. +""" + +from typing import Optional +from supabase import create_async_client, AsyncClient +from utils.logger import logger +from utils.config import config +import base64 +import uuid +from datetime import datetime + +class DBConnection: + """Singleton database connection manager using Supabase.""" + + _instance: Optional['DBConnection'] = None + _initialized = False + _client: Optional[AsyncClient] = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + """No initialization needed in __init__ as it's handled in __new__""" + pass + + async def initialize(self): + """Initialize the database connection.""" + if self._initialized: + return + + try: + supabase_url = config.SUPABASE_URL + # Use service role key preferentially for backend operations + supabase_key = config.SUPABASE_SERVICE_ROLE_KEY or config.SUPABASE_ANON_KEY + + if not supabase_url or not supabase_key: + logger.error("Missing required environment variables for Supabase connection") + raise RuntimeError("SUPABASE_URL and a key (SERVICE_ROLE_KEY or ANON_KEY) environment variables must be set.") + + logger.debug("Initializing Supabase connection") + self._client = await create_async_client(supabase_url, supabase_key) + self._initialized = True + key_type = "SERVICE_ROLE_KEY" if config.SUPABASE_SERVICE_ROLE_KEY else "ANON_KEY" + logger.debug(f"Database connection initialized with Supabase using {key_type}") + except Exception as e: + logger.error(f"Database initialization error: {e}") + raise RuntimeError(f"Failed to initialize database connection: {str(e)}") + + @classmethod + async def disconnect(cls): + """Disconnect from the database.""" + if cls._client: + logger.info("Disconnecting from Supabase database") + await cls._client.close() + cls._initialized = False + logger.info("Database disconnected successfully") + + @property + async def client(self) -> AsyncClient: + """Get the Supabase client instance.""" + if not self._initialized: + logger.debug("Supabase client not initialized, initializing now") + await self.initialize() + if not self._client: + logger.error("Database client is None after initialization") + raise RuntimeError("Database not initialized") + return self._client + + async def upload_base64_image(self, base64_data: str, bucket_name: str = "browser-screenshots") -> str: + """Upload a base64 encoded image to Supabase storage and return the URL. + + Args: + base64_data (str): Base64 encoded image data (with or without data URL prefix) + bucket_name (str): Name of the storage bucket to upload to + + Returns: + str: Public URL of the uploaded image + """ + try: + # Remove data URL prefix if present + if base64_data.startswith('data:'): + base64_data = base64_data.split(',')[1] + + # Decode base64 data + image_data = base64.b64decode(base64_data) + + # Generate unique filename + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + unique_id = str(uuid.uuid4())[:8] + filename = f"image_{timestamp}_{unique_id}.png" + + # Upload to Supabase storage + client = await self.client + storage_response = await client.storage.from_(bucket_name).upload( + filename, + image_data, + {"content-type": "image/png"} + ) + + # Get public URL + public_url = await client.storage.from_(bucket_name).get_public_url(filename) + + logger.debug(f"Successfully uploaded image to {public_url}") + return public_url + + except Exception as e: + logger.error(f"Error uploading base64 image: {e}") + raise RuntimeError(f"Failed to upload image: {str(e)}") + + diff --git a/app/services/transcription.py b/app/services/transcription.py new file mode 100644 index 000000000..2a40eec71 --- /dev/null +++ b/app/services/transcription.py @@ -0,0 +1,76 @@ +import os +import openai +import tempfile +from fastapi import APIRouter, UploadFile, File, HTTPException, Depends +from pydantic import BaseModel +from typing import Optional +from utils.logger import logger +from utils.auth_utils import get_current_user_id_from_jwt + +router = APIRouter(tags=["transcription"]) + +class TranscriptionResponse(BaseModel): + text: str + +@router.post("/transcription", response_model=TranscriptionResponse) +async def transcribe_audio( + audio_file: UploadFile = File(...), + user_id: str = Depends(get_current_user_id_from_jwt) +): + """Transcribe audio file to text using OpenAI Whisper.""" + try: + # Validate file type - OpenAI supports these formats + allowed_types = [ + 'audio/mp3', 'audio/mpeg', 'audio/mp4', 'audio/m4a', + 'audio/wav', 'audio/webm', 'audio/mpga' + ] + + logger.info(f"Received audio file: {audio_file.filename}, content_type: {audio_file.content_type}") + + if audio_file.content_type not in allowed_types: + raise HTTPException( + status_code=400, + detail=f"Unsupported file type: {audio_file.content_type}. Supported types: {', '.join(allowed_types)}" + ) + + # Check file size (25MB limit) + content = await audio_file.read() + if len(content) > 25 * 1024 * 1024: # 25MB + raise HTTPException(status_code=400, detail="File size exceeds 25MB limit") + + # Reset file pointer + await audio_file.seek(0) + + # Initialize OpenAI client + client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + + # Create a temporary file with the correct extension + file_extension = audio_file.filename.split('.')[-1] if audio_file.filename and '.' in audio_file.filename else 'webm' + + with tempfile.NamedTemporaryFile(delete=False, suffix=f'.{file_extension}') as temp_file: + temp_file.write(content) + temp_file_path = temp_file.name + + try: + # Transcribe audio using the temporary file + # OpenAI Whisper API has built-in limits: 25MB file size and handles duration limits internally + with open(temp_file_path, 'rb') as f: + transcription = client.audio.transcriptions.create( + model="gpt-4o-mini-transcribe", + file=f, + response_format="text" + ) + + logger.info(f"Successfully transcribed audio for user {user_id}") + return TranscriptionResponse(text=transcription) + + finally: + # Clean up temporary file + try: + os.unlink(temp_file_path) + except Exception as e: + logger.warning(f"Failed to delete temporary file {temp_file_path}: {e}") + + except Exception as e: + logger.error(f"Error transcribing audio for user {user_id}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Transcription failed: {str(e)}") \ No newline at end of file diff --git a/app/tool/base.py b/app/tool/base.py index ba4084db9..f9574c11a 100644 --- a/app/tool/base.py +++ b/app/tool/base.py @@ -1,16 +1,75 @@ from abc import ABC, abstractmethod -from typing import Any, Dict, Optional - from pydantic import BaseModel, Field +from typing import Any, Dict, Optional, Union +import inspect +import json +from utils.logger import logger + +# class BaseTool(ABC, BaseModel): +# name: str +# description: str +# parameters: Optional[dict] = None + +# class Config: +# arbitrary_types_allowed = True + +# async def __call__(self, **kwargs) -> Any: +# """Execute the tool with given parameters.""" +# return await self.execute(**kwargs) + +# @abstractmethod +# async def execute(self, **kwargs) -> Any: +# """Execute the tool with given parameters.""" + +# def to_param(self) -> Dict: +# """Convert tool to function call format.""" +# return { +# "type": "function", +# "function": { +# "name": self.name, +# "description": self.description, +# "parameters": self.parameters, +# }, +# } class BaseTool(ABC, BaseModel): + """Consolidated base class for all tools combining BaseModel and Tool functionality. + + Provides: + - Pydantic model validation + - Schema registration + - Standardized result handling + - Abstract execution interface + + Attributes: + name (str): Tool name + description (str): Tool description + parameters (dict): Tool parameters schema + _schemas (Dict[str, List[ToolSchema]]): Registered method schemas + """ + name: str description: str parameters: Optional[dict] = None + # _schemas: Dict[str, List[ToolSchema]] = {} class Config: arbitrary_types_allowed = True + underscore_attrs_are_private = False + + # def __init__(self, **data): + # """Initialize tool with model validation and schema registration.""" + # super().__init__(**data) + # logger.debug(f"Initializing tool class: {self.__class__.__name__}") + # self._register_schemas() + + # def _register_schemas(self): + # """Register schemas from all decorated methods.""" + # for name, method in inspect.getmembers(self, predicate=inspect.ismethod): + # if hasattr(method, 'tool_schemas'): + # self._schemas[name] = method.tool_schemas + # logger.debug(f"Registered schemas for method '{name}' in {self.__class__.__name__}") async def __call__(self, **kwargs) -> Any: """Execute the tool with given parameters.""" @@ -21,7 +80,11 @@ async def execute(self, **kwargs) -> Any: """Execute the tool with given parameters.""" def to_param(self) -> Dict: - """Convert tool to function call format.""" + """Convert tool to function call format. + + Returns: + Dictionary with tool metadata in OpenAI function calling format + """ return { "type": "function", "function": { @@ -31,6 +94,42 @@ def to_param(self) -> Dict: }, } + # def get_schemas(self) -> Dict[str, List[ToolSchema]]: + # """Get all registered tool schemas. + + # Returns: + # Dict mapping method names to their schema definitions + # """ + # return self._schemas + + def success_response(self, data: Union[Dict[str, Any], str]) -> ToolResult: + """Create a successful tool result. + + Args: + data: Result data (dictionary or string) + + Returns: + ToolResult with success=True and formatted output + """ + if isinstance(data, str): + text = data + else: + text = json.dumps(data, indent=2) + logger.debug(f"Created success response for {self.__class__.__name__}") + return ToolResult(output=text) + + def fail_response(self, msg: str) -> ToolResult: + """Create a failed tool result. + + Args: + msg: Error message describing the failure + + Returns: + ToolResult with success=False and error message + """ + logger.debug(f"Tool {self.__class__.__name__} returned failed result: {msg}") + return ToolResult(error=msg) + class ToolResult(BaseModel): """Represents the result of a tool execution.""" diff --git a/app/tool/data_providers_tool.py b/app/tool/data_providers_tool.py deleted file mode 100644 index f86ab2886..000000000 --- a/app/tool/data_providers_tool.py +++ /dev/null @@ -1,188 +0,0 @@ -import json -from typing import Union, Dict, Any - -from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema -from agent.tools.data_providers.LinkedinProvider import LinkedinProvider -from agent.tools.data_providers.YahooFinanceProvider import YahooFinanceProvider -from agent.tools.data_providers.AmazonProvider import AmazonProvider -from agent.tools.data_providers.ZillowProvider import ZillowProvider -from agent.tools.data_providers.TwitterProvider import TwitterProvider - -class DataProvidersTool(Tool): - """Tool for making requests to various data providers.""" - - def __init__(self): - super().__init__() - - self.register_data_providers = { - "linkedin": LinkedinProvider(), - "yahoo_finance": YahooFinanceProvider(), - "amazon": AmazonProvider(), - "zillow": ZillowProvider(), - "twitter": TwitterProvider() - } - - @openapi_schema({ - "type": "function", - "function": { - "name": "get_data_provider_endpoints", - "description": "Get available endpoints for a specific data provider", - "parameters": { - "type": "object", - "properties": { - "service_name": { - "type": "string", - "description": "The name of the data provider (e.g., 'linkedin', 'twitter', 'zillow', 'amazon', 'yahoo_finance')" - } - }, - "required": ["service_name"] - } - } - }) - @xml_schema( - tag_name="get-data-provider-endpoints", - mappings=[ - {"param_name": "service_name", "node_type": "attribute", "path": "."} - ], - example=''' - - - - - -linkedin - - - ''' - ) - async def get_data_provider_endpoints( - self, - service_name: str - ) -> ToolResult: - """ - Get available endpoints for a specific data provider. - - Parameters: - - service_name: The name of the data provider (e.g., 'linkedin') - """ - try: - if not service_name: - return self.fail_response("Data provider name is required.") - - if service_name not in self.register_data_providers: - return self.fail_response(f"Data provider '{service_name}' not found. Available data providers: {list(self.register_data_providers.keys())}") - - endpoints = self.register_data_providers[service_name].get_endpoints() - return self.success_response(endpoints) - - except Exception as e: - error_message = str(e) - simplified_message = f"Error getting data provider endpoints: {error_message[:200]}" - if len(error_message) > 200: - simplified_message += "..." - return self.fail_response(simplified_message) - - @openapi_schema({ - "type": "function", - "function": { - "name": "execute_data_provider_call", - "description": "Execute a call to a specific data provider endpoint", - "parameters": { - "type": "object", - "properties": { - "service_name": { - "type": "string", - "description": "The name of the API service (e.g., 'linkedin')" - }, - "route": { - "type": "string", - "description": "The key of the endpoint to call" - }, - "payload": { - "type": "object", - "description": "The payload to send with the API call" - } - }, - "required": ["service_name", "route"] - } - } - }) - @xml_schema( - tag_name="execute-data-provider-call", - mappings=[ - {"param_name": "service_name", "node_type": "attribute", "path": "service_name"}, - {"param_name": "route", "node_type": "attribute", "path": "route"}, - {"param_name": "payload", "node_type": "content", "path": "."} - ], - example=''' - - - - - - linkedin - person - {"link": "https://www.linkedin.com/in/johndoe/"} - - - ''' - ) - async def execute_data_provider_call( - self, - service_name: str, - route: str, - payload: Union[Dict[str, Any], str, None] = None - ) -> ToolResult: - """ - Execute a call to a specific data provider endpoint. - - Parameters: - - service_name: The name of the data provider (e.g., 'linkedin') - - route: The key of the endpoint to call - - payload: The payload to send with the data provider call (dict or JSON string) - """ - try: - # Handle payload - it can be either a dict or a JSON string - if isinstance(payload, str): - try: - payload = json.loads(payload) - except json.JSONDecodeError as e: - return self.fail_response(f"Invalid JSON in payload: {str(e)}") - elif payload is None: - payload = {} - # If payload is already a dict, use it as-is - - if not service_name: - return self.fail_response("service_name is required.") - - if not route: - return self.fail_response("route is required.") - - if service_name not in self.register_data_providers: - return self.fail_response(f"API '{service_name}' not found. Available APIs: {list(self.register_data_providers.keys())}") - - data_provider = self.register_data_providers[service_name] - if route == service_name: - return self.fail_response(f"route '{route}' is the same as service_name '{service_name}'. YOU FUCKING IDIOT!") - - if route not in data_provider.get_endpoints().keys(): - return self.fail_response(f"Endpoint '{route}' not found in {service_name} data provider.") - - - result = data_provider.call_endpoint(route, payload) - return self.success_response(result) - - except Exception as e: - error_message = str(e) - print(error_message) - simplified_message = f"Error executing data provider call: {error_message[:200]}" - if len(error_message) > 200: - simplified_message += "..." - return self.fail_response(simplified_message) diff --git a/app/tool/expand_msg_tool.py b/app/tool/expand_msg_tool.py deleted file mode 100644 index 9d653261a..000000000 --- a/app/tool/expand_msg_tool.py +++ /dev/null @@ -1,103 +0,0 @@ -from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema -from agentpress.thread_manager import ThreadManager -import json - -class ExpandMessageTool(Tool): - """Tool for expanding a previous message to the user.""" - - def __init__(self, thread_id: str, thread_manager: ThreadManager): - super().__init__() - self.thread_manager = thread_manager - self.thread_id = thread_id - - @openapi_schema({ - "type": "function", - "function": { - "name": "expand_message", - "description": "Expand a message from the previous conversation with the user. Use this tool to expand a message that was truncated in the earlier conversation.", - "parameters": { - "type": "object", - "properties": { - "message_id": { - "type": "string", - "description": "The ID of the message to expand. Must be a UUID." - } - }, - "required": ["message_id"] - } - } - }) - @xml_schema( - tag_name="expand-message", - mappings=[ - {"param_name": "message_id", "node_type": "attribute", "path": "."} - ], - example=''' - - - - ecde3a4c-c7dc-4776-ae5c-8209517c5576 - - - - - - - f47ac10b-58cc-4372-a567-0e02b2c3d479 - - - - - - - 550e8400-e29b-41d4-a716-446655440000 - - - ''' - ) - async def expand_message(self, message_id: str) -> ToolResult: - """Expand a message from the previous conversation with the user. - - Args: - message_id: The ID of the message to expand - - Returns: - ToolResult indicating the message was successfully expanded - """ - try: - client = await self.thread_manager.db.client - message = await client.table('messages').select('*').eq('message_id', message_id).eq('thread_id', self.thread_id).execute() - - if not message.data or len(message.data) == 0: - return self.fail_response(f"Message with ID {message_id} not found in thread {self.thread_id}") - - message_data = message.data[0] - message_content = message_data['content'] - final_content = message_content - if isinstance(message_content, dict) and 'content' in message_content: - final_content = message_content['content'] - elif isinstance(message_content, str): - try: - parsed_content = json.loads(message_content) - if isinstance(parsed_content, dict) and 'content' in parsed_content: - final_content = parsed_content['content'] - except json.JSONDecodeError: - pass - - return self.success_response({"status": "Message expanded successfully.", "message": final_content}) - except Exception as e: - return self.fail_response(f"Error expanding message: {str(e)}") - -if __name__ == "__main__": - import asyncio - - async def test_expand_message_tool(): - expand_message_tool = ExpandMessageTool() - - # Test expand message - expand_message_result = await expand_message_tool.expand_message( - message_id="004ab969-ef9a-4656-8aba-e392345227cd" - ) - print("Expand message result:", expand_message_result) - - asyncio.run(test_expand_message_tool()) \ No newline at end of file diff --git a/app/tool/mcp_tool_wrapper.py b/app/tool/mcp_tool_wrapper.py deleted file mode 100644 index 31f331235..000000000 --- a/app/tool/mcp_tool_wrapper.py +++ /dev/null @@ -1,715 +0,0 @@ -""" -MCP Tool Wrapper for AgentPress - -This module provides a generic tool wrapper that handles all MCP (Model Context Protocol) -server tool calls through dynamically generated individual function methods. -""" - -import json -from typing import Any, Dict, List, Optional -from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema, ToolSchema, SchemaType -from mcp_local.client import MCPManager -from utils.logger import logger -import inspect -from mcp import ClientSession -from mcp.client.sse import sse_client -from mcp.client.stdio import stdio_client -from mcp.client.streamable_http import streamablehttp_client -from mcp import StdioServerParameters -import asyncio - - -class MCPToolWrapper(Tool): - """ - A generic tool wrapper that dynamically creates individual methods for each MCP tool. - - This tool creates separate function calls for each MCP tool while routing them all - through the same underlying implementation. - """ - - def __init__(self, mcp_configs: Optional[List[Dict[str, Any]]] = None): - """ - Initialize the MCP tool wrapper. - - Args: - mcp_configs: List of MCP configurations from agent's configured_mcps - """ - # Don't call super().__init__() yet - we need to set up dynamic methods first - self.mcp_manager = MCPManager() - self.mcp_configs = mcp_configs or [] - self._initialized = False - self._dynamic_tools = {} - self._schemas: Dict[str, List[ToolSchema]] = {} - self._custom_tools = {} # Store custom MCP tools separately - - # Now initialize the parent class which will call _register_schemas - super().__init__() - - async def _ensure_initialized(self): - """Ensure MCP servers are initialized.""" - if not self._initialized: - # Initialize standard MCP servers from Smithery - standard_configs = [cfg for cfg in self.mcp_configs if not cfg.get('isCustom', False)] - custom_configs = [cfg for cfg in self.mcp_configs if cfg.get('isCustom', False)] - - # Initialize standard MCPs through MCPManager - if standard_configs: - for config in standard_configs: - try: - logger.info(f"Attempting to connect to MCP server: {config['qualifiedName']}") - await self.mcp_manager.connect_server(config) - logger.info(f"Successfully connected to MCP server: {config['qualifiedName']}") - except Exception as e: - logger.error(f"Failed to connect to MCP server {config['qualifiedName']}: {e}") - import traceback - logger.error(f"Full traceback: {traceback.format_exc()}") - - # Initialize custom MCPs directly - if custom_configs: - await self._initialize_custom_mcps(custom_configs) - - # Create dynamic tools for all connected servers - await self._create_dynamic_tools() - self._initialized = True - - async def _connect_sse_server(self, server_name, server_config, all_tools, timeout): - url = server_config["url"] - headers = server_config.get("headers", {}) - - async with asyncio.timeout(timeout): - try: - async with sse_client(url, headers=headers) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - tools_result = await session.list_tools() - tools_info = [] - for tool in tools_result.tools: - tool_info = { - "name": tool.name, - "description": tool.description, - "input_schema": tool.inputSchema - } - tools_info.append(tool_info) - - all_tools[server_name] = { - "status": "connected", - "transport": "sse", - "url": url, - "tools": tools_info - } - - logger.info(f" {server_name}: Connected via SSE ({len(tools_info)} tools)") - except TypeError as e: - if "unexpected keyword argument" in str(e): - async with sse_client(url) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - tools_result = await session.list_tools() - tools_info = [] - for tool in tools_result.tools: - tool_info = { - "name": tool.name, - "description": tool.description, - "input_schema": tool.inputSchema - } - tools_info.append(tool_info) - - all_tools[server_name] = { - "status": "connected", - "transport": "sse", - "url": url, - "tools": tools_info - } - logger.info(f" {server_name}: Connected via SSE ({len(tools_info)} tools)") - else: - raise - - async def _connect_streamable_http_server(self, url): - async with streamablehttp_client(url) as ( - read_stream, - write_stream, - _, - ): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - tool_result = await session.list_tools() - print(f"Connected via HTTP ({len(tool_result.tools)} tools)") - - tools_info = [] - for tool in tool_result.tools: - tool_info = { - "name": tool.name, - "description": tool.description, - "inputSchema": tool.inputSchema - } - tools_info.append(tool_info) - - return tools_info - - async def _connect_stdio_server(self, server_name, server_config, all_tools, timeout): - """Connect to a stdio-based MCP server.""" - server_params = StdioServerParameters( - command=server_config["command"], - args=server_config.get("args", []), - env=server_config.get("env", {}) - ) - - async with asyncio.timeout(timeout): - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - tools_result = await session.list_tools() - tools_info = [] - for tool in tools_result.tools: - tool_info = { - "name": tool.name, - "description": tool.description, - "input_schema": tool.inputSchema - } - tools_info.append(tool_info) - - all_tools[server_name] = { - "status": "connected", - "transport": "stdio", - "tools": tools_info - } - - logger.info(f" {server_name}: Connected via stdio ({len(tools_info)} tools)") - - async def _initialize_custom_mcps(self, custom_configs): - """Initialize custom MCP servers.""" - for config in custom_configs: - try: - logger.info(f"Initializing custom MCP: {config}") - custom_type = config.get('customType', 'sse') - server_config = config.get('config', {}) - enabled_tools = config.get('enabledTools', []) - server_name = config.get('name', 'Unknown') - - logger.info(f"Initializing custom MCP: {server_name} (type: {custom_type})") - - if custom_type == 'sse': - if 'url' not in server_config: - logger.error(f"Custom MCP {server_name}: Missing 'url' in config") - continue - - url = server_config['url'] - logger.info(f"Initializing custom MCP {url} with SSE type") - - try: - # Use the working connect_sse_server method - all_tools = {} - await self._connect_sse_server(server_name, server_config, all_tools, 15) - - # Process the results - if server_name in all_tools and all_tools[server_name].get('status') == 'connected': - tools_info = all_tools[server_name].get('tools', []) - tools_registered = 0 - - for tool_info in tools_info: - tool_name_from_server = tool_info['name'] - if not enabled_tools or tool_name_from_server in enabled_tools: - tool_name = f"custom_{server_name.replace(' ', '_').lower()}_{tool_name_from_server}" - self._custom_tools[tool_name] = { - 'name': tool_name, - 'description': tool_info['description'], - 'parameters': tool_info['input_schema'], - 'server': server_name, - 'original_name': tool_name_from_server, - 'is_custom': True, - 'custom_type': custom_type, - 'custom_config': server_config - } - tools_registered += 1 - logger.debug(f"Registered custom tool: {tool_name}") - - logger.info(f"Successfully initialized custom MCP {server_name} with {tools_registered} tools") - else: - logger.error(f"Failed to connect to custom MCP {server_name}") - - except Exception as e: - logger.error(f"Custom MCP {server_name}: Connection failed - {str(e)}") - continue - - elif custom_type == 'http': - if 'url' not in server_config: - logger.error(f"Custom MCP {server_name}: Missing 'url' in config") - continue - - url = server_config['url'] - logger.info(f"Initializing custom MCP {url} with HTTP type") - - try: - - tools_info = await self._connect_streamable_http_server(url) - tools_registered = 0 - - for tool_info in tools_info: - tool_name_from_server = tool_info['name'] - if not enabled_tools or tool_name_from_server in enabled_tools: - tool_name = f"custom_{server_name.replace(' ', '_').lower()}_{tool_name_from_server}" - self._custom_tools[tool_name] = { - 'name': tool_name, - 'description': tool_info['description'], - 'parameters': tool_info['inputSchema'], - 'server': server_name, - 'original_name': tool_name_from_server, - 'is_custom': True, - 'custom_type': custom_type, - 'custom_config': server_config - } - tools_registered += 1 - logger.debug(f"Registered custom tool: {tool_name}") - - logger.info(f"Successfully initialized custom MCP {server_name} with {tools_registered} tools") - - except Exception as e: - logger.error(f"Custom MCP {server_name}: Connection failed - {str(e)}") - continue - - elif custom_type == 'json': - if 'command' not in server_config: - logger.error(f"Custom MCP {server_name}: Missing 'command' in config") - continue - - logger.info(f"Initializing custom MCP {server_name} with JSON/stdio type") - - try: - # Use the stdio connection method - all_tools = {} - await self._connect_stdio_server(server_name, server_config, all_tools, 15) - - # Process the results - if server_name in all_tools and all_tools[server_name].get('status') == 'connected': - tools_info = all_tools[server_name].get('tools', []) - tools_registered = 0 - - for tool_info in tools_info: - tool_name_from_server = tool_info['name'] - if not enabled_tools or tool_name_from_server in enabled_tools: - tool_name = f"custom_{server_name.replace(' ', '_').lower()}_{tool_name_from_server}" - self._custom_tools[tool_name] = { - 'name': tool_name, - 'description': tool_info['description'], - 'parameters': tool_info['input_schema'], - 'server': server_name, - 'original_name': tool_name_from_server, - 'is_custom': True, - 'custom_type': custom_type, - 'custom_config': server_config - } - tools_registered += 1 - logger.debug(f"Registered custom tool: {tool_name}") - - logger.info(f"Successfully initialized custom MCP {server_name} with {tools_registered} tools") - else: - logger.error(f"Failed to connect to custom MCP {server_name}") - - except Exception as e: - logger.error(f"Custom MCP {server_name}: Connection failed - {str(e)}") - continue - - else: - logger.error(f"Custom MCP {server_name}: Unsupported type '{custom_type}', supported types are 'sse', 'http' and 'json'") - continue - - except Exception as e: - logger.error(f"Failed to initialize custom MCP {config.get('name', 'Unknown')}: {e}") - continue - - async def initialize_and_register_tools(self, tool_registry=None): - """Initialize MCP tools and optionally update the tool registry. - - This method should be called after the tool has been registered to dynamically - add the MCP tool schemas to the registry. - - Args: - tool_registry: Optional ToolRegistry instance to update with new schemas - """ - await self._ensure_initialized() - - if tool_registry and self._dynamic_tools: - logger.info(f"Updating tool registry with {len(self._dynamic_tools)} MCP tools") - for method_name, schemas in self._schemas.items(): - if method_name not in ['call_mcp_tool']: # Skip the fallback method - pass - - async def _create_dynamic_tools(self): - """Create dynamic tool methods for each available MCP tool.""" - try: - # Get standard MCP tools - available_tools = self.mcp_manager.get_all_tools_openapi() - logger.info(f"MCPManager returned {len(available_tools)} tools") - - for tool_info in available_tools: - tool_name = tool_info.get('name', '') - logger.info(f"Processing tool: {tool_name}") - if tool_name: - # Create a dynamic method for this tool with proper OpenAI schema - self._create_dynamic_method(tool_name, tool_info) - - # Get custom MCP tools - logger.info(f"Processing {len(self._custom_tools)} custom MCP tools") - for tool_name, tool_info in self._custom_tools.items(): - logger.info(f"Processing custom tool: {tool_name}") - # Convert custom tool info to the expected format - openapi_tool_info = { - "name": tool_name, - "description": tool_info['description'], - "parameters": tool_info['parameters'] - } - self._create_dynamic_method(tool_name, openapi_tool_info) - - logger.info(f"Created {len(self._dynamic_tools)} dynamic MCP tool methods") - - except Exception as e: - logger.error(f"Error creating dynamic MCP tools: {e}") - - def _create_dynamic_method(self, tool_name: str, tool_info: Dict[str, Any]): - """Create a dynamic method for a specific MCP tool with proper OpenAI schema.""" - if tool_name.startswith("custom_"): - if tool_name in self._custom_tools: - clean_tool_name = self._custom_tools[tool_name]['original_name'] - server_name = self._custom_tools[tool_name]['server'] - else: - parts = tool_name.split("_") - if len(parts) >= 3: - clean_tool_name = "_".join(parts[2:]) - server_name = parts[1] if len(parts) > 1 else "unknown" - else: - clean_tool_name = tool_name - server_name = "unknown" - else: - parts = tool_name.split("_", 2) - clean_tool_name = parts[2] if len(parts) > 2 else tool_name - server_name = parts[1] if len(parts) > 1 else "unknown" - - method_name = clean_tool_name.replace('-', '_') - - logger.info(f"Creating dynamic method for tool '{tool_name}': clean_tool_name='{clean_tool_name}', method_name='{method_name}', server='{server_name}'") - - original_full_name = tool_name - - # Create the dynamic method - async def dynamic_tool_method(**kwargs) -> ToolResult: - """Dynamically created method for MCP tool.""" - # Use the original full tool name for execution - return await self._execute_mcp_tool(original_full_name, kwargs) - - # Set the method name to match the tool name - dynamic_tool_method.__name__ = method_name - dynamic_tool_method.__qualname__ = f"{self.__class__.__name__}.{method_name}" - - # Build a more descriptive description - base_description = tool_info.get("description", f"MCP tool from {server_name}") - full_description = f"{base_description} (MCP Server: {server_name})" - - # Create the OpenAI schema for this tool - openapi_function_schema = { - "type": "function", - "function": { - "name": method_name, # Use the clean method name for function calling - "description": full_description, - "parameters": tool_info.get("parameters", { - "type": "object", - "properties": {}, - "required": [] - }) - } - } - - # Create a ToolSchema object - tool_schema = ToolSchema( - schema_type=SchemaType.OPENAPI, - schema=openapi_function_schema - ) - - # Add the schema to our schemas dict - self._schemas[method_name] = [tool_schema] - - # Also add the schema to the method itself (for compatibility) - dynamic_tool_method.tool_schemas = [tool_schema] - - # Store the method and its info - self._dynamic_tools[tool_name] = { - 'method': dynamic_tool_method, - 'method_name': method_name, - 'original_tool_name': tool_name, - 'clean_tool_name': clean_tool_name, - 'server_name': server_name, - 'info': tool_info, - 'schema': tool_schema - } - - # Add the method to this instance - setattr(self, method_name, dynamic_tool_method) - - logger.debug(f"Created dynamic method '{method_name}' for MCP tool '{tool_name}' from server '{server_name}'") - - def _register_schemas(self): - """Register schemas from all decorated methods and dynamic tools.""" - # First register static schemas from decorated methods - for name, method in inspect.getmembers(self, predicate=inspect.ismethod): - if hasattr(method, 'tool_schemas'): - self._schemas[name] = method.tool_schemas - logger.debug(f"Registered schemas for method '{name}' in {self.__class__.__name__}") - - # Note: Dynamic schemas will be added after async initialization - logger.debug(f"Initial registration complete for MCPToolWrapper") - - def get_schemas(self) -> Dict[str, List[ToolSchema]]: - """Get all registered tool schemas including dynamic ones.""" - # Return all schemas including dynamically added ones - return self._schemas - - def __getattr__(self, name: str): - """Handle calls to dynamically created MCP tool methods.""" - # Look for exact method name match first - for tool_data in self._dynamic_tools.values(): - if tool_data['method_name'] == name: - return tool_data['method'] - - # Try with underscore/hyphen conversion - name_with_hyphens = name.replace('_', '-') - for tool_name, tool_data in self._dynamic_tools.items(): - if tool_data['method_name'] == name or tool_name == name_with_hyphens: - return tool_data['method'] - - raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") - - async def get_available_tools(self) -> List[Dict[str, Any]]: - """Get all available MCP tools in OpenAPI format.""" - await self._ensure_initialized() - return self.mcp_manager.get_all_tools_openapi() - - async def _execute_mcp_tool(self, tool_name: str, arguments: Dict[str, Any]) -> ToolResult: - """Execute an MCP tool call.""" - await self._ensure_initialized() - logger.info(f"Executing MCP tool {tool_name} with arguments {arguments}") - try: - # Check if it's a custom MCP tool first - if tool_name in self._custom_tools: - tool_info = self._custom_tools[tool_name] - return await self._execute_custom_mcp_tool(tool_name, arguments, tool_info) - else: - # Use standard MCP manager for Smithery servers - result = await self.mcp_manager.execute_tool(tool_name, arguments) - - if isinstance(result, dict): - if result.get('isError', False): - return self.fail_response(result.get('content', 'Tool execution failed')) - else: - return self.success_response(result.get('content', result)) - else: - return self.success_response(result) - - except Exception as e: - logger.error(f"Error executing MCP tool {tool_name}: {str(e)}") - return self.fail_response(f"Error executing tool: {str(e)}") - - async def _execute_custom_mcp_tool(self, tool_name: str, arguments: Dict[str, Any], tool_info: Dict[str, Any]) -> ToolResult: - """Execute a custom MCP tool call.""" - try: - custom_type = tool_info['custom_type'] - custom_config = tool_info['custom_config'] - original_tool_name = tool_info['original_name'] - - if custom_type == 'sse': - # Execute SSE-based custom MCP using the same pattern as _connect_sse_server - url = custom_config['url'] - headers = custom_config.get('headers', {}) - - async with asyncio.timeout(30): # 30 second timeout for tool execution - try: - # Try with headers first (same pattern as _connect_sse_server) - async with sse_client(url, headers=headers) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - result = await session.call_tool(original_tool_name, arguments) - - # Handle the result properly - if hasattr(result, 'content'): - content = result.content - if isinstance(content, list): - # Extract text from content list - text_parts = [] - for item in content: - if hasattr(item, 'text'): - text_parts.append(item.text) - else: - text_parts.append(str(item)) - content_str = "\n".join(text_parts) - elif hasattr(content, 'text'): - content_str = content.text - else: - content_str = str(content) - - return self.success_response(content_str) - else: - return self.success_response(str(result)) - - except TypeError as e: - if "unexpected keyword argument" in str(e): - # Fallback: try without headers (exact pattern from _connect_sse_server) - async with sse_client(url) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - result = await session.call_tool(original_tool_name, arguments) - - # Handle the result properly - if hasattr(result, 'content'): - content = result.content - if isinstance(content, list): - # Extract text from content list - text_parts = [] - for item in content: - if hasattr(item, 'text'): - text_parts.append(item.text) - else: - text_parts.append(str(item)) - content_str = "\n".join(text_parts) - elif hasattr(content, 'text'): - content_str = content.text - else: - content_str = str(content) - - return self.success_response(content_str) - else: - return self.success_response(str(result)) - else: - raise - - elif custom_type == 'http': - # Execute HTTP-based custom MCP - url = custom_config['url'] - - async with asyncio.timeout(30): # 30 second timeout for tool execution - async with streamablehttp_client(url) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - result = await session.call_tool(original_tool_name, arguments) - - # Handle the result properly - if hasattr(result, 'content'): - content = result.content - if isinstance(content, list): - # Extract text from content list - text_parts = [] - for item in content: - if hasattr(item, 'text'): - text_parts.append(item.text) - else: - text_parts.append(str(item)) - content_str = "\n".join(text_parts) - elif hasattr(content, 'text'): - content_str = content.text - else: - content_str = str(content) - - return self.success_response(content_str) - else: - return self.success_response(str(result)) - - elif custom_type == 'json': - # Execute stdio-based custom MCP using the same pattern as _connect_stdio_server - server_params = StdioServerParameters( - command=custom_config["command"], - args=custom_config.get("args", []), - env=custom_config.get("env", {}) - ) - - async with asyncio.timeout(30): # 30 second timeout for tool execution - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - result = await session.call_tool(original_tool_name, arguments) - - # Handle the result properly - if hasattr(result, 'content'): - content = result.content - if isinstance(content, list): - # Extract text from content list - text_parts = [] - for item in content: - if hasattr(item, 'text'): - text_parts.append(item.text) - else: - text_parts.append(str(item)) - content_str = "\n".join(text_parts) - elif hasattr(content, 'text'): - content_str = content.text - else: - content_str = str(content) - - return self.success_response(content_str) - else: - return self.success_response(str(result)) - else: - return self.fail_response(f"Unsupported custom MCP type: {custom_type}") - - except asyncio.TimeoutError: - return self.fail_response(f"Tool execution timeout for {tool_name}") - except Exception as e: - logger.error(f"Error executing custom MCP tool {tool_name}: {str(e)}") - return self.fail_response(f"Error executing custom tool: {str(e)}") - - # Keep the original call_mcp_tool method as a fallback - @openapi_schema({ - "type": "function", - "function": { - "name": "call_mcp_tool", - "description": "Execute a tool from any connected MCP server. This is a fallback wrapper that forwards calls to MCP tools. The tool_name should be in the format 'mcp_{server}_{tool}' where {server} is the MCP server's qualified name and {tool} is the specific tool name.", - "parameters": { - "type": "object", - "properties": { - "tool_name": { - "type": "string", - "description": "The full MCP tool name in format 'mcp_{server}_{tool}', e.g., 'mcp_exa_web_search_exa'" - }, - "arguments": { - "type": "object", - "description": "The arguments to pass to the MCP tool, as a JSON object. The required arguments depend on the specific tool being called.", - "additionalProperties": True - } - }, - "required": ["tool_name", "arguments"] - } - } - }) - @xml_schema( - tag_name="call-mcp-tool", - mappings=[ - {"param_name": "tool_name", "node_type": "attribute", "path": "."}, - {"param_name": "arguments", "node_type": "content", "path": "."} - ], - example=''' - - - mcp_exa_web_search_exa - {"query": "latest developments in AI", "num_results": 10} - - - ''' - ) - async def call_mcp_tool(self, tool_name: str, arguments: Dict[str, Any]) -> ToolResult: - """ - Execute an MCP tool call (fallback method). - - Args: - tool_name: The full MCP tool name (e.g., "mcp_exa_web_search_exa") - arguments: The arguments to pass to the tool - - Returns: - ToolResult with the tool execution result - """ - return await self._execute_mcp_tool(tool_name, arguments) - - async def cleanup(self): - """Disconnect all MCP servers.""" - if self._initialized: - try: - await self.mcp_manager.disconnect_all() - except Exception as e: - logger.error(f"Error during MCP cleanup: {str(e)}") - finally: - self._initialized = False \ No newline at end of file diff --git a/app/tool/message_tool.py b/app/tool/message_tool.py deleted file mode 100644 index eef3ef599..000000000 --- a/app/tool/message_tool.py +++ /dev/null @@ -1,270 +0,0 @@ -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. - - This tool provides methods for asking questions, with support for - attachments and user takeover suggestions. - """ - - def __init__(self): - super().__init__() - - # Commented out as we are just doing this via prompt as there is no need to call it as a tool - - @openapi_schema({ - "type": "function", - "function": { - "name": "ask", - "description": "Ask user a question and wait for response. Use for: 1) Requesting clarification on ambiguous requirements, 2) Seeking confirmation before proceeding with high-impact changes, 3) Gathering additional information needed to complete a task, 4) Offering options and requesting user preference, 5) Validating assumptions when critical to task success. IMPORTANT: Use this tool only when user input is essential to proceed. Always provide clear context and options when applicable. Include relevant attachments when the question relates to specific files or resources.", - "parameters": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Question text to present to user - should be specific and clearly indicate what information you need. Include: 1) Clear question or request, 2) Context about why the input is needed, 3) Available options if applicable, 4) Impact of different choices, 5) Any relevant constraints or considerations." - }, - "attachments": { - "anyOf": [ - {"type": "string"}, - {"items": {"type": "string"}, "type": "array"} - ], - "description": "(Optional) List of files or URLs to attach to the question. Include when: 1) Question relates to specific files or configurations, 2) User needs to review content before answering, 3) Options or choices are documented in files, 4) Supporting evidence or context is needed. Always use relative paths to /workspace directory." - } - }, - "required": ["text"] - } - } - }) - @xml_schema( - tag_name="ask", - mappings=[ - {"param_name": "text", "node_type": "content", "path": "."}, - {"param_name": "attachments", "node_type": "attribute", "path": ".", "required": False} - ], - example=''' - - - I'm planning to bake the chocolate cake for your birthday party. The recipe mentions "rich frosting" but doesn't specify what type. Could you clarify your preferences? For example: -1. Would you prefer buttercream or cream cheese frosting? -2. Do you want any specific flavor added to the frosting (vanilla, coffee, etc.)? -3. Should I add any decorative toppings like sprinkles or fruit? -4. Do you have any dietary restrictions I should be aware of? - -This information will help me make sure the cake meets your expectations for the celebration. - recipes/chocolate_cake.txt,photos/cake_examples.jpg - - - ''' - ) - async def ask(self, text: str, attachments: Optional[Union[str, List[str]]] = None) -> ToolResult: - """Ask the user a question and wait for a response. - - Args: - text: The question to present to the user - attachments: Optional file paths or URLs to attach to the question - - Returns: - ToolResult indicating the question was successfully sent - """ - 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)}") - - @openapi_schema({ - "type": "function", - "function": { - "name": "web_browser_takeover", - "description": "Request user takeover of browser interaction. Use this tool when: 1) The page requires complex human interaction that automated tools cannot handle, 2) Authentication or verification steps require human input, 3) The page has anti-bot measures that prevent automated access, 4) Complex form filling or navigation is needed, 5) The page requires human verification (CAPTCHA, etc.). IMPORTANT: This tool should be used as a last resort after web-search and crawl-webpage have failed, and when direct browser tools are insufficient. Always provide clear context about why takeover is needed and what actions the user should take.", - "parameters": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Instructions for the user about what actions to take in the browser. Include: 1) Clear explanation of why takeover is needed, 2) Specific steps the user should take, 3) What information to look for or extract, 4) How to indicate when they're done, 5) Any important context about the current page state." - }, - "attachments": { - "anyOf": [ - {"type": "string"}, - {"items": {"type": "string"}, "type": "array"} - ], - "description": "(Optional) List of files or URLs to attach to the takeover request. Include when: 1) Screenshots or visual references are needed, 2) Previous search results or crawled content is relevant, 3) Supporting documentation is required. Always use relative paths to /workspace directory." - } - }, - "required": ["text"] - } - } - }) - @xml_schema( - tag_name="web-browser-takeover", - mappings=[ - {"param_name": "text", "node_type": "content", "path": "."}, - {"param_name": "attachments", "node_type": "attribute", "path": ".", "required": False} - ], - example=''' - - - I've encountered a CAPTCHA verification on the page. Please: -1. Solve the CAPTCHA puzzle -2. Let me know once you've completed it -3. I'll then continue with the automated process - -If you encounter any issues or need to take additional steps, please let me know. - - - ''' - ) - async def web_browser_takeover(self, text: str, attachments: Optional[Union[str, List[str]]] = None) -> ToolResult: - """Request user takeover of browser interaction. - - Args: - text: Instructions for the user about what actions to take - attachments: Optional file paths or URLs to attach to the request - - Returns: - ToolResult indicating the takeover request was successfully sent - """ - try: - # Convert single attachment to list for consistent handling - if attachments and isinstance(attachments, str): - attachments = [attachments] - - return self.success_response({"status": "Awaiting user browser takeover..."}) - except Exception as e: - return self.fail_response(f"Error requesting browser takeover: {str(e)}") - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "inform", -# "description": "Inform the user about progress, completion of a major step, or important context. Use this tool: 1) To provide updates between major sections of work, 2) After accomplishing significant milestones, 3) When transitioning to a new phase of work, 4) To confirm actions were completed successfully, 5) To provide context about upcoming steps. IMPORTANT: Use FREQUENTLY throughout execution to provide UI context to the user. The user CANNOT respond to this tool - they can only respond to the 'ask' tool. Use this tool to keep the user informed without requiring their input.", -# "parameters": { -# "type": "object", -# "properties": { -# "text": { -# "type": "string", -# "description": "Information to present to the user. Include: 1) Clear statement of what has been accomplished or what is happening, 2) Relevant context or impact, 3) Brief indication of next steps if applicable." -# }, -# "attachments": { -# "anyOf": [ -# {"type": "string"}, -# {"items": {"type": "string"}, "type": "array"} -# ], -# "description": "(Optional) List of files or URLs to attach to the information. Include when: 1) Information relates to specific files or resources, 2) Showing intermediate results or outputs, 3) Providing supporting documentation. Always use relative paths to /workspace directory." -# } -# }, -# "required": ["text"] -# } -# } -# }) -# @xml_schema( -# tag_name="inform", -# mappings=[ -# {"param_name": "text", "node_type": "content", "path": "."}, -# {"param_name": "attachments", "node_type": "attribute", "path": ".", "required": False} -# ], -# example=''' - -# Inform the user about progress, completion of a major step, or important context. Use this tool: 1) To provide updates between major sections of work, 2) After accomplishing significant milestones, 3) When transitioning to a new phase of work, 4) To confirm actions were completed successfully, 5) To provide context about upcoming steps. IMPORTANT: Use FREQUENTLY throughout execution to provide UI context to the user. The user CANNOT respond to this tool - they can only respond to the 'ask' tool. Use this tool to keep the user informed without requiring their input." - -# -# -# -# -# -# -# -# -# - -# -# I've completed the data analysis of the sales figures. Key findings include: -# - Q4 sales were 28% higher than Q3 -# - Product line A showed the strongest performance -# - Three regions missed their targets - -# I'll now proceed with creating the executive summary report based on these findings. -# -# ''' -# ) -# async def inform(self, text: str, attachments: Optional[Union[str, List[str]]] = None) -> ToolResult: -# """Inform the user about progress or important updates without requiring a response. - -# Args: -# text: The information to present to the user -# attachments: Optional file paths or URLs to attach - -# Returns: -# ToolResult indicating the information was successfully sent -# """ -# try: -# # Convert single attachment to list for consistent handling -# if attachments and isinstance(attachments, str): -# attachments = [attachments] - -# return self.success_response({"status": "Information sent"}) -# except Exception as e: -# return self.fail_response(f"Error informing user: {str(e)}") - - @openapi_schema({ - "type": "function", - "function": { - "name": "complete", - "description": "A special tool to indicate you have completed all tasks and are about to enter complete state. Use ONLY when: 1) All tasks in todo.md are marked complete [x], 2) The user's original request has been fully addressed, 3) There are no pending actions or follow-ups required, 4) You've delivered all final outputs and results to the user. IMPORTANT: This is the ONLY way to properly terminate execution. Never use this tool unless ALL tasks are complete and verified. Always ensure you've provided all necessary outputs and references before using this tool.", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - } - }) - @xml_schema( - tag_name="complete", - mappings=[], - example=''' - - - - - ''' - ) - async def complete(self) -> ToolResult: - """Indicate that the agent has completed all tasks and is entering complete state. - - Returns: - ToolResult indicating successful transition to complete state - """ - try: - return self.success_response({"status": "complete"}) - except Exception as e: - return self.fail_response(f"Error entering complete state: {str(e)}") - - -if __name__ == "__main__": - import asyncio - - async def test_message_tool(): - message_tool = MessageTool() - - # Test question - ask_result = await message_tool.ask( - text="Would you like to proceed with the next phase?", - attachments="summary.pdf" - ) - print("Question result:", ask_result) - - # Test inform - inform_result = await message_tool.inform( - text="Completed analysis of data. Processing results now.", - attachments="analysis.pdf" - ) - print("Inform result:", inform_result) - - asyncio.run(test_message_tool()) diff --git a/app/tool/sb_browser_tool.py b/app/tool/sb_browser_tool.py index 74bcfe601..7ebfe86fb 100644 --- a/app/tool/sb_browser_tool.py +++ b/app/tool/sb_browser_tool.py @@ -6,14 +6,14 @@ from agentpress.tool import ToolResult, openapi_schema, xml_schema from agentpress.thread_manager import ThreadManager -from sandbox.tool_base import SandboxToolsBase +from daytona.tool_base import SandboxToolsBase from utils.logger import logger from utils.s3_upload_utils import upload_base64_image class SandboxBrowserTool(SandboxToolsBase): """Tool for executing tasks in a Daytona sandbox with browser-use capabilities.""" - + def __init__(self, project_id: str, thread_id: str, thread_manager: ThreadManager): super().__init__(project_id, thread_manager) self.thread_id = thread_id @@ -21,11 +21,11 @@ def __init__(self, project_id: str, thread_id: str, thread_manager: ThreadManage def _validate_base64_image(self, base64_string: str, max_size_mb: int = 10) -> tuple[bool, str]: """ Comprehensive validation of base64 image data. - + Args: base64_string (str): The base64 encoded image data max_size_mb (int): Maximum allowed image size in megabytes - + Returns: tuple[bool, str]: (is_valid, error_message) """ @@ -33,94 +33,94 @@ def _validate_base64_image(self, base64_string: str, max_size_mb: int = 10) -> t # Check if data exists and has reasonable length if not base64_string or len(base64_string) < 10: return False, "Base64 string is empty or too short" - + # Remove data URL prefix if present (data:image/jpeg;base64,...) if base64_string.startswith('data:'): try: base64_string = base64_string.split(',', 1)[1] except (IndexError, ValueError): return False, "Invalid data URL format" - + # Check if string contains only valid base64 characters # Base64 alphabet: A-Z, a-z, 0-9, +, /, = (padding) import re if not re.match(r'^[A-Za-z0-9+/]*={0,2}$', base64_string): return False, "Invalid base64 characters detected" - + # Check if base64 string length is valid (must be multiple of 4) if len(base64_string) % 4 != 0: return False, "Invalid base64 string length" - + # Attempt to decode base64 try: image_data = base64.b64decode(base64_string, validate=True) except Exception as e: return False, f"Base64 decoding failed: {str(e)}" - + # Check decoded data size if len(image_data) == 0: return False, "Decoded image data is empty" - + # Check if decoded data size exceeds limit max_size_bytes = max_size_mb * 1024 * 1024 if len(image_data) > max_size_bytes: return False, f"Image size ({len(image_data)} bytes) exceeds limit ({max_size_bytes} bytes)" - + # Validate that decoded data is actually a valid image using PIL try: image_stream = io.BytesIO(image_data) with Image.open(image_stream) as img: # Verify the image by attempting to load it img.verify() - + # Check if image format is supported supported_formats = {'JPEG', 'PNG', 'GIF', 'BMP', 'WEBP', 'TIFF'} if img.format not in supported_formats: return False, f"Unsupported image format: {img.format}" - + # Re-open for dimension checks (verify() closes the image) image_stream.seek(0) with Image.open(image_stream) as img_check: width, height = img_check.size - + # Check reasonable dimension limits max_dimension = 8192 # 8K resolution limit if width > max_dimension or height > max_dimension: return False, f"Image dimensions ({width}x{height}) exceed limit ({max_dimension}x{max_dimension})" - + # Check minimum dimensions if width < 1 or height < 1: return False, f"Invalid image dimensions: {width}x{height}" - + logger.debug(f"Valid image detected: {img.format}, {width}x{height}, {len(image_data)} bytes") - + except Exception as e: return False, f"Invalid image data: {str(e)}" - + return True, "Valid image" - + except Exception as e: logger.error(f"Unexpected error during base64 image validation: {e}") return False, f"Validation error: {str(e)}" async def _execute_browser_action(self, endpoint: str, params: dict = None, method: str = "POST") -> ToolResult: """Execute a browser automation action through the API - + Args: endpoint (str): The API endpoint to call params (dict, optional): Parameters to send. Defaults to None. method (str, optional): HTTP method to use. Defaults to "POST". - + Returns: ToolResult: Result of the execution """ try: # Ensure sandbox is initialized await self._ensure_sandbox() - + # Build the curl command url = f"http://localhost:8003/api/automation/{endpoint}" - + if method == "GET" and params: query_params = "&".join([f"{k}={v}" for k, v in params.items()]) url = f"{url}?{query_params}" @@ -130,19 +130,19 @@ async def _execute_browser_action(self, endpoint: str, params: dict = None, meth if params: json_data = json.dumps(params) curl_cmd += f" -d '{json_data}'" - + logger.debug("\033[95mExecuting curl command:\033[0m") logger.debug(f"{curl_cmd}") - + response = self.sandbox.process.exec(curl_cmd, timeout=30) - + if response.exit_code == 0: try: result = json.loads(response.result) if not "content" in result: result["content"] = "" - + if not "role" in result: result["role"] = "assistant" @@ -153,7 +153,7 @@ async def _execute_browser_action(self, endpoint: str, params: dict = None, meth # Comprehensive validation of the base64 image data screenshot_data = result["screenshot_base64"] is_valid, validation_message = self._validate_base64_image(screenshot_data) - + if is_valid: logger.debug(f"Screenshot validation passed: {validation_message}") image_url = await upload_base64_image(screenshot_data) @@ -162,10 +162,10 @@ async def _execute_browser_action(self, endpoint: str, params: dict = None, meth else: logger.warning(f"Screenshot validation failed: {validation_message}") result["image_validation_error"] = validation_message - + # Remove base64 data from result to keep it clean del result["screenshot_base64"] - + except Exception as e: logger.error(f"Failed to process screenshot: {e}") result["image_upload_error"] = str(e) @@ -251,10 +251,10 @@ async def _execute_browser_action(self, endpoint: str, params: dict = None, meth ) async def browser_navigate_to(self, url: str) -> ToolResult: """Navigate to a specific url - + Args: url (str): The url to navigate to - + Returns: dict: Result of the execution """ @@ -290,10 +290,10 @@ async def browser_navigate_to(self, url: str) -> ToolResult: # ) # async def browser_search_google(self, query: str) -> ToolResult: # """Search Google with the provided query - + # Args: # query (str): The search query to use - + # Returns: # dict: Result of the execution # """ @@ -323,7 +323,7 @@ async def browser_navigate_to(self, url: str) -> ToolResult: ) async def browser_go_back(self) -> ToolResult: """Navigate back in browser history - + Returns: dict: Result of the execution """ @@ -361,10 +361,10 @@ async def browser_go_back(self) -> ToolResult: ) async def browser_wait(self, seconds: int = 3) -> ToolResult: """Wait for the specified number of seconds - + Args: seconds (int, optional): Number of seconds to wait. Defaults to 3. - + Returns: dict: Result of the execution """ @@ -403,10 +403,10 @@ async def browser_wait(self, seconds: int = 3) -> ToolResult: ) async def browser_click_element(self, index: int) -> ToolResult: """Click on an element by index - + Args: index (int): The index of the element to click - + Returns: dict: Result of the execution """ @@ -451,11 +451,11 @@ async def browser_click_element(self, index: int) -> ToolResult: ) async def browser_input_text(self, index: int, text: str) -> ToolResult: """Input text into an element - + Args: index (int): The index of the element to input text into text (str): The text to input - + Returns: dict: Result of the execution """ @@ -494,10 +494,10 @@ async def browser_input_text(self, index: int, text: str) -> ToolResult: ) async def browser_send_keys(self, keys: str) -> ToolResult: """Send keyboard keys - + Args: keys (str): The keys to send (e.g., 'Enter', 'Escape', 'Control+a') - + Returns: dict: Result of the execution """ @@ -536,10 +536,10 @@ async def browser_send_keys(self, keys: str) -> ToolResult: ) async def browser_switch_tab(self, page_id: int) -> ToolResult: """Switch to a different browser tab - + Args: page_id (int): The ID of the tab to switch to - + Returns: dict: Result of the execution """ @@ -576,10 +576,10 @@ async def browser_switch_tab(self, page_id: int) -> ToolResult: # ) # async def browser_open_tab(self, url: str) -> ToolResult: # """Open a new browser tab with the specified URL - + # Args: # url (str): The URL to open in the new tab - + # Returns: # dict: Result of the execution # """ @@ -618,10 +618,10 @@ async def browser_switch_tab(self, page_id: int) -> ToolResult: ) async def browser_close_tab(self, page_id: int) -> ToolResult: """Close a browser tab - + Args: page_id (int): The ID of the tab to close - + Returns: dict: Result of the execution """ @@ -658,23 +658,23 @@ async def browser_close_tab(self, page_id: int) -> ToolResult: # ) # async def browser_extract_content(self, goal: str) -> ToolResult: # """Extract content from the current page based on the provided goal - + # Args: # goal (str): The extraction goal - + # Returns: # dict: Result of the execution # """ # logger.debug(f"\033[95mExtracting content with goal: {goal}\033[0m") # result = await self._execute_browser_action("extract_content", {"goal": goal}) - + # # Format content for better readability # if result.get("success"): # logger.debug(f"\033[92mContent extraction successful\033[0m") # content = result.data.get("content", "") # url = result.data.get("url", "") # title = result.data.get("title", "") - + # if content: # content_preview = content[:200] + "..." if len(content) > 200 else content # logger.debug(f"\033[95mExtracted content from {title} ({url}):\033[0m") @@ -684,7 +684,7 @@ async def browser_close_tab(self, page_id: int) -> ToolResult: # logger.debug(f"\033[93mNo content extracted from {url}\033[0m") # else: # logger.debug(f"\033[91mFailed to extract content: {result.data.get('error', 'Unknown error')}\033[0m") - + # return result @openapi_schema({ @@ -718,10 +718,10 @@ async def browser_close_tab(self, page_id: int) -> ToolResult: ) async def browser_scroll_down(self, amount: int = None) -> ToolResult: """Scroll down the page - + Args: amount (int, optional): Pixel amount to scroll. If None, scrolls one page. - + Returns: dict: Result of the execution """ @@ -731,7 +731,7 @@ async def browser_scroll_down(self, amount: int = None) -> ToolResult: logger.debug(f"\033[95mScrolling down by {amount} pixels\033[0m") else: logger.debug(f"\033[95mScrolling down one page\033[0m") - + return await self._execute_browser_action("scroll_down", params) @openapi_schema({ @@ -765,10 +765,10 @@ async def browser_scroll_down(self, amount: int = None) -> ToolResult: ) async def browser_scroll_up(self, amount: int = None) -> ToolResult: """Scroll up the page - + Args: amount (int, optional): Pixel amount to scroll. If None, scrolls one page. - + Returns: dict: Result of the execution """ @@ -778,7 +778,7 @@ async def browser_scroll_up(self, amount: int = None) -> ToolResult: logger.debug(f"\033[95mScrolling up by {amount} pixels\033[0m") else: logger.debug(f"\033[95mScrolling up one page\033[0m") - + return await self._execute_browser_action("scroll_up", params) @openapi_schema({ @@ -813,10 +813,10 @@ async def browser_scroll_up(self, amount: int = None) -> ToolResult: ) async def browser_scroll_to_text(self, text: str) -> ToolResult: """Scroll to specific text on the page - + Args: text (str): The text to scroll to - + Returns: dict: Result of the execution """ @@ -855,10 +855,10 @@ async def browser_scroll_to_text(self, text: str) -> ToolResult: ) async def browser_get_dropdown_options(self, index: int) -> ToolResult: """Get all options from a dropdown element - + Args: index (int): The index of the dropdown element - + Returns: dict: Result of the execution with the dropdown options """ @@ -903,11 +903,11 @@ async def browser_get_dropdown_options(self, index: int) -> ToolResult: ) async def browser_select_dropdown_option(self, index: int, text: str) -> ToolResult: """Select an option from a dropdown by text - + Args: index (int): The index of the dropdown element text (str): The text of the option to select - + Returns: dict: Result of the execution """ @@ -969,11 +969,11 @@ async def browser_select_dropdown_option(self, index: int, text: str) -> ToolRes
''' ) - async def browser_drag_drop(self, element_source: str = None, element_target: str = None, + async def browser_drag_drop(self, element_source: str = None, element_target: str = None, coord_source_x: int = None, coord_source_y: int = None, coord_target_x: int = None, coord_target_y: int = None) -> ToolResult: """Perform drag and drop operation between elements or coordinates - + Args: element_source (str, optional): The source element selector element_target (str, optional): The target element selector @@ -981,12 +981,12 @@ async def browser_drag_drop(self, element_source: str = None, element_target: st coord_source_y (int, optional): The source Y coordinate coord_target_x (int, optional): The target X coordinate coord_target_y (int, optional): The target Y coordinate - + Returns: dict: Result of the execution """ params = {} - + if element_source and element_target: params["element_source"] = element_source params["element_target"] = element_target @@ -999,7 +999,7 @@ async def browser_drag_drop(self, element_source: str = None, element_target: st logger.debug(f"\033[95mDragging from coordinates ({coord_source_x}, {coord_source_y}) to ({coord_target_x}, {coord_target_y})\033[0m") else: return self.fail_response("Must provide either element selectors or coordinates for drag and drop") - + return await self._execute_browser_action("drag_drop", params) @openapi_schema({ @@ -1040,13 +1040,13 @@ async def browser_drag_drop(self, element_source: str = None, element_target: st ) async def browser_click_coordinates(self, x: int, y: int) -> ToolResult: """Click at specific X,Y coordinates on the page - + Args: x (int): The X coordinate to click y (int): The Y coordinate to click - + Returns: dict: Result of the execution """ logger.debug(f"\033[95mClicking at coordinates: ({x}, {y})\033[0m") - return await self._execute_browser_action("click_coordinates", {"x": x, "y": y}) \ No newline at end of file + return await self._execute_browser_action("click_coordinates", {"x": x, "y": y}) diff --git a/app/tool/sb_deploy_tool.py b/app/tool/sb_deploy_tool.py index 5c52f394d..7be0929fa 100644 --- a/app/tool/sb_deploy_tool.py +++ b/app/tool/sb_deploy_tool.py @@ -1,7 +1,7 @@ import os from dotenv import load_dotenv from agentpress.tool import ToolResult, openapi_schema, xml_schema -from sandbox.tool_base import SandboxToolsBase +from daytona.tool_base import SandboxToolsBase from utils.files_utils import clean_path from agentpress.thread_manager import ThreadManager @@ -48,11 +48,11 @@ def clean_path(self, path: str) -> str: {"param_name": "directory_path", "node_type": "attribute", "path": "directory_path"} ], example=''' - @@ -68,11 +68,11 @@ async def deploy(self, name: str, directory_path: str) -> ToolResult: """ Deploy a static website (HTML+CSS+JS) from the sandbox to Cloudflare Pages. Only use this tool when permanent deployment to a production environment is needed. - + Args: name: Name for the deployment, will be used in the URL as {name}.kortix.cloud directory_path: Path to the directory to deploy, relative to /workspace - + Returns: ToolResult containing: - Success: Deployment information including URL @@ -81,10 +81,10 @@ async def deploy(self, name: str, directory_path: str) -> ToolResult: try: # Ensure sandbox is initialized await self._ensure_sandbox() - + directory_path = self.clean_path(directory_path) full_path = f"{self.workspace_path}/{directory_path}" - + # Verify the directory exists try: dir_info = self.sandbox.fs.get_file_info(full_path) @@ -92,26 +92,26 @@ async def deploy(self, name: str, directory_path: str) -> ToolResult: return self.fail_response(f"'{directory_path}' is not a directory") except Exception as e: return self.fail_response(f"Directory '{directory_path}' does not exist: {str(e)}") - + # Deploy to Cloudflare Pages directly from the container try: # Get Cloudflare API token from environment if not self.cloudflare_api_token: return self.fail_response("CLOUDFLARE_API_TOKEN environment variable not set") - + # Single command that creates the project if it doesn't exist and then deploys project_name = f"{self.sandbox_id}-{name}" - deploy_cmd = f'''cd {self.workspace_path} && export CLOUDFLARE_API_TOKEN={self.cloudflare_api_token} && - (npx wrangler pages deploy {full_path} --project-name {project_name} || - (npx wrangler pages project create {project_name} --production-branch production && + deploy_cmd = f'''cd {self.workspace_path} && export CLOUDFLARE_API_TOKEN={self.cloudflare_api_token} && + (npx wrangler pages deploy {full_path} --project-name {project_name} || + (npx wrangler pages project create {project_name} --production-branch production && 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(f"/bin/sh -c \"{deploy_cmd}\"", timeout=300) - + print(f"Deployment command output: {response.result}") - + if response.exit_code == 0: return self.success_response({ "message": f"Website deployed successfully", @@ -127,21 +127,21 @@ async def deploy(self, name: str, directory_path: str) -> ToolResult: if __name__ == "__main__": import asyncio import sys - + async def test_deploy(): # Replace these with actual values for testing sandbox_id = "sandbox-ccb30b35" password = "test-password" - + # Initialize the deploy tool deploy_tool = SandboxDeployTool(sandbox_id, password) - + # Test deployment - replace with actual directory path and site name result = await deploy_tool.deploy( name="test-site-1x", directory_path="website" # Directory containing static site files ) print(f"Deployment result: {result}") - + asyncio.run(test_deploy()) diff --git a/app/tool/sb_expose_tool.py b/app/tool/sb_expose_tool.py index ff833b32b..dc64a6f2b 100644 --- a/app/tool/sb_expose_tool.py +++ b/app/tool/sb_expose_tool.py @@ -1,5 +1,5 @@ from agentpress.tool import ToolResult, openapi_schema, xml_schema -from sandbox.tool_base import SandboxToolsBase +from daytona.tool_base import SandboxToolsBase from agentpress.thread_manager import ThreadManager import asyncio import time @@ -13,25 +13,25 @@ def __init__(self, project_id: str, thread_manager: ThreadManager): async def _wait_for_sandbox_services(self, timeout: int = 30) -> bool: """Wait for sandbox services to be fully started before exposing ports.""" start_time = time.time() - + while time.time() - start_time < timeout: try: # Check if supervisord is running and managing services result = self.sandbox.process.exec("supervisorctl status", timeout=10) - + if result.exit_code == 0: # Check if key services are running status_output = result.output if "http_server" in status_output and "RUNNING" in status_output: return True - + # If services aren't ready, wait a bit await asyncio.sleep(2) - + except Exception as e: # If we can't check status, wait a bit and try again await asyncio.sleep(2) - + return False @openapi_schema({ @@ -85,10 +85,10 @@ async def expose_port(self, port: int) -> ToolResult: try: # Ensure sandbox is initialized await self._ensure_sandbox() - + # Convert port to integer if it's a string port = int(port) - + # Validate port number if not 1 <= port <= 65535: return self.fail_response(f"Invalid port number: {port}. Must be between 1 and 65535.") @@ -110,16 +110,16 @@ async def expose_port(self, port: int) -> ToolResult: # Get the preview link for the specified port preview_link = self.sandbox.get_preview_link(port) - + # Extract the actual URL from the preview link object url = preview_link.url if hasattr(preview_link, 'url') else str(preview_link) - + return self.success_response({ "url": url, "port": port, "message": f"Successfully exposed port {port} to the public. Users can now access this service at: {url}" }) - + except ValueError: return self.fail_response(f"Invalid port number: {port}. Must be a valid integer between 1 and 65535.") except Exception as e: diff --git a/app/tool/sb_files_tool.py b/app/tool/sb_files_tool.py index 34f334969..b16235b02 100644 --- a/app/tool/sb_files_tool.py +++ b/app/tool/sb_files_tool.py @@ -1,5 +1,5 @@ from agentpress.tool import ToolResult, openapi_schema, xml_schema -from sandbox.tool_base import SandboxToolsBase +from daytona.tool_base import SandboxToolsBase from utils.files_utils import should_exclude_file, clean_path from agentpress.thread_manager import ThreadManager from utils.logger import logger @@ -35,11 +35,11 @@ async def get_workspace_state(self) -> dict: try: # Ensure sandbox is initialized await self._ensure_sandbox() - + files = self.sandbox.fs.list_files(self.workspace_path) for file_info in files: rel_path = file_info.name - + # Skip excluded files and directories if self._should_exclude_file(rel_path) or file_info.is_dir: continue @@ -59,7 +59,7 @@ async def get_workspace_state(self) -> dict: print(f"Skipping binary file: {rel_path}") return files_state - + except Exception as e: print(f"Error getting workspace state: {str(e)}") return {} @@ -111,7 +111,7 @@ async def get_workspace_state(self) -> dict: # This is the file content def main(): print("Hello, World!") - + if __name__ == "__main__": main() @@ -123,23 +123,23 @@ async def create_file(self, file_path: str, file_contents: str, permissions: str try: # Ensure sandbox is initialized await self._ensure_sandbox() - + file_path = self.clean_path(file_path) full_path = f"{self.workspace_path}/{file_path}" if self._file_exists(full_path): return self.fail_response(f"File '{file_path}' already exists. Use update_file to modify existing files.") - + # Create parent directories if needed parent_dir = '/'.join(full_path.split('/')[:-1]) if parent_dir: self.sandbox.fs.create_folder(parent_dir, "755") - + # Write the file content self.sandbox.fs.upload_file(file_contents.encode(), full_path) self.sandbox.fs.set_file_permissions(full_path, permissions) - + message = f"File '{file_path}' created successfully." - + # Check if index.html was created and add 8080 server info (only in root workspace) if file_path.lower() == 'index.html': try: @@ -149,7 +149,7 @@ async def create_file(self, file_path: str, file_contents: str, permissions: str message += "\n[Note: Use the provided HTTP server URL above instead of starting a new server]" except Exception as e: logger.warning(f"Failed to get website URL for index.html: {str(e)}") - + return self.success_response(message) except Exception as e: return self.fail_response(f"Error creating file: {str(e)}") @@ -200,41 +200,41 @@ async def str_replace(self, file_path: str, old_str: str, new_str: str) -> ToolR try: # Ensure sandbox is initialized await self._ensure_sandbox() - + file_path = self.clean_path(file_path) full_path = f"{self.workspace_path}/{file_path}" if not self._file_exists(full_path): return self.fail_response(f"File '{file_path}' does not exist") - + content = self.sandbox.fs.download_file(full_path).decode() old_str = old_str.expandtabs() new_str = new_str.expandtabs() - + occurrences = content.count(old_str) if occurrences == 0: return self.fail_response(f"String '{old_str}' not found in file") if occurrences > 1: lines = [i+1 for i, line in enumerate(content.split('\n')) if old_str in line] return self.fail_response(f"Multiple occurrences found in lines {lines}. Please ensure string is unique") - + # Perform replacement new_content = content.replace(old_str, new_str) self.sandbox.fs.upload_file(new_content.encode(), full_path) - + # Show snippet around the edit replacement_line = content.split(old_str)[0].count('\n') start_line = max(0, replacement_line - self.SNIPPET_LINES) end_line = replacement_line + self.SNIPPET_LINES + new_str.count('\n') snippet = '\n'.join(new_content.split('\n')[start_line:end_line + 1]) - + # Get preview URL if it's an HTML file # preview_url = self._get_preview_url(file_path) message = f"Replacement successful." # if preview_url: # message += f"\n\nYou can preview this HTML file at: {preview_url}" - + return self.success_response(message) - + except Exception as e: return self.fail_response(f"Error replacing string: {str(e)}") @@ -288,17 +288,17 @@ async def full_file_rewrite(self, file_path: str, file_contents: str, permission try: # Ensure sandbox is initialized await self._ensure_sandbox() - + file_path = self.clean_path(file_path) full_path = f"{self.workspace_path}/{file_path}" if not self._file_exists(full_path): return self.fail_response(f"File '{file_path}' does not exist. Use create_file to create a new file.") - + self.sandbox.fs.upload_file(file_contents.encode(), full_path) self.sandbox.fs.set_file_permissions(full_path, permissions) - + message = f"File '{file_path}' completely rewritten successfully." - + # Check if index.html was rewritten and add 8080 server info (only in root workspace) if file_path.lower() == 'index.html': try: @@ -308,7 +308,7 @@ async def full_file_rewrite(self, file_path: str, file_contents: str, permission message += "\n[Note: Use the provided HTTP server URL above instead of starting a new server]" except Exception as e: logger.warning(f"Failed to get website URL for index.html: {str(e)}") - + return self.success_response(message) except Exception as e: return self.fail_response(f"Error rewriting file: {str(e)}") @@ -347,12 +347,12 @@ async def delete_file(self, file_path: str) -> ToolResult: try: # Ensure sandbox is initialized await self._ensure_sandbox() - + file_path = self.clean_path(file_path) full_path = f"{self.workspace_path}/{file_path}" if not self._file_exists(full_path): return self.fail_response(f"File '{file_path}' does not exist") - + self.sandbox.fs.delete_file(full_path) return self.success_response(f"File '{file_path}' deleted successfully.") except Exception as e: @@ -412,12 +412,12 @@ async def delete_file(self, file_path: str) -> ToolResult: # ) # async def read_file(self, file_path: str, start_line: int = 1, end_line: Optional[int] = None) -> ToolResult: # """Read file content with optional line range specification. - + # Args: # file_path: Path to the file relative to /workspace # start_line: Starting line number (1-based), defaults to 1 # end_line: Ending line number (inclusive), defaults to None (end of file) - + # Returns: # ToolResult containing: # - Success: File content and metadata @@ -426,27 +426,27 @@ async def delete_file(self, file_path: str) -> ToolResult: # try: # file_path = self.clean_path(file_path) # full_path = f"{self.workspace_path}/{file_path}" - + # if not self._file_exists(full_path): # return self.fail_response(f"File '{file_path}' does not exist") - + # # Download and decode file content # content = self.sandbox.fs.download_file(full_path).decode() - + # # Split content into lines # lines = content.split('\n') # total_lines = len(lines) - + # # Handle line range if specified # if start_line > 1 or end_line is not None: # # Convert to 0-based indices # start_idx = max(0, start_line - 1) # end_idx = end_line if end_line is not None else total_lines # end_idx = min(end_idx, total_lines) # Ensure we don't exceed file length - + # # Extract the requested lines # content = '\n'.join(lines[start_idx:end_idx]) - + # return self.success_response({ # "content": content, # "file_path": file_path, @@ -454,7 +454,7 @@ async def delete_file(self, file_path: str) -> ToolResult: # "end_line": end_line if end_line is not None else total_lines, # "total_lines": total_lines # }) - + # except UnicodeDecodeError: # return self.fail_response(f"File '{file_path}' appears to be binary and cannot be read as text") # except Exception as e: diff --git a/app/tool/sb_shell_tool.py b/app/tool/sb_shell_tool.py index b165472ea..74acc6cb8 100644 --- a/app/tool/sb_shell_tool.py +++ b/app/tool/sb_shell_tool.py @@ -2,11 +2,11 @@ import time from uuid import uuid4 from agentpress.tool import ToolResult, openapi_schema, xml_schema -from sandbox.tool_base import SandboxToolsBase +from daytona.tool_base import SandboxToolsBase from agentpress.thread_manager import ThreadManager class SandboxShellTool(SandboxToolsBase): - """Tool for executing tasks in a Daytona sandbox with browser-use capabilities. + """Tool for executing tasks in a Daytona sandbox with browser-use capabilities. Uses sessions for maintaining state between commands and provides comprehensive process management.""" def __init__(self, project_id: str, thread_manager: ThreadManager): @@ -108,8 +108,8 @@ async def _cleanup_session(self, session_name: str): ''' ) async def execute_command( - self, - command: str, + self, + command: str, folder: Optional[str] = None, session_name: Optional[str] = None, blocking: bool = False, @@ -118,61 +118,61 @@ async def execute_command( try: # Ensure sandbox is initialized await self._ensure_sandbox() - + # Set up working directory cwd = self.workspace_path if folder: folder = folder.strip('/') cwd = f"{self.workspace_path}/{folder}" - + # Generate a session name if not provided if not session_name: session_name = f"session_{str(uuid4())[:8]}" - + # Check if tmux session already exists check_session = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") session_exists = "not_exists" not in check_session.get("output", "") - + if not session_exists: # Create a new tmux session await self._execute_raw_command(f"tmux new-session -d -s {session_name}") - + # Ensure we're in the correct directory and send command to tmux full_command = f"cd {cwd} && {command}" wrapped_command = full_command.replace('"', '\\"') # Escape double quotes - + # Send command to tmux session await self._execute_raw_command(f'tmux send-keys -t {session_name} "{wrapped_command}" Enter') - + if blocking: # For blocking execution, wait and capture output start_time = time.time() while (time.time() - start_time) < timeout: # Wait a bit before checking time.sleep(2) - + # Check if session still exists (command might have exited) check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'ended'") if "ended" in check_result.get("output", ""): break - + # Get current output and check for common completion indicators output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") current_output = output_result.get("output", "") - + # Check for prompt indicators that suggest command completion last_lines = current_output.split('\n')[-3:] completion_indicators = ['$', '#', '>', 'Done', 'Completed', 'Finished', '✓'] if any(indicator in line for indicator in completion_indicators for line in last_lines): break - + # Capture final output output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") final_output = output_result.get("output", "") - + # Kill the session after capture await self._execute_raw_command(f"tmux kill-session -t {session_name}") - + return self.success_response({ "output": final_output, "session_name": session_name, @@ -187,7 +187,7 @@ async def execute_command( "message": f"Command sent to tmux session '{session_name}'. Use check_command_output to view results.", "completed": False }) - + except Exception as e: # Attempt to clean up session in case of error if session_name: @@ -201,7 +201,7 @@ async def _execute_raw_command(self, command: str) -> Dict[str, Any]: """Execute a raw command directly in the sandbox.""" # Ensure session exists for raw commands session_id = await self._ensure_session("raw_commands") - + # Execute command in session from sandbox.sandbox import SessionExecuteRequest req = SessionExecuteRequest( @@ -209,18 +209,18 @@ async def _execute_raw_command(self, command: str) -> Dict[str, Any]: var_async=False, cwd=self.workspace_path ) - + response = self.sandbox.process.execute_session_command( session_id=session_id, req=req, timeout=30 # Short timeout for utility commands ) - + logs = self.sandbox.process.get_session_command_logs( session_id=session_id, command_id=response.cmd_id ) - + return { "output": logs, "exit_code": response.exit_code @@ -260,7 +260,7 @@ async def _execute_raw_command(self, command: str) -> Dict[str, Any]: dev_server
- + @@ -278,29 +278,29 @@ async def check_command_output( try: # Ensure sandbox is initialized await self._ensure_sandbox() - + # Check if session exists check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") if "not_exists" in check_result.get("output", ""): return self.fail_response(f"Tmux session '{session_name}' does not exist.") - + # Get output from tmux pane output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") output = output_result.get("output", "") - + # Kill session if requested if kill_session: await self._execute_raw_command(f"tmux kill-session -t {session_name}") termination_status = "Session terminated." else: termination_status = "Session still running." - + return self.success_response({ "output": output, "session_name": session_name, "status": termination_status }) - + except Exception as e: return self.fail_response(f"Error checking command output: {str(e)}") @@ -341,19 +341,19 @@ async def terminate_command( try: # Ensure sandbox is initialized await self._ensure_sandbox() - + # Check if session exists check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") if "not_exists" in check_result.get("output", ""): return self.fail_response(f"Tmux session '{session_name}' does not exist.") - + # Kill the session await self._execute_raw_command(f"tmux kill-session -t {session_name}") - + return self.success_response({ "message": f"Tmux session '{session_name}' terminated successfully." }) - + except Exception as e: return self.fail_response(f"Error terminating command: {str(e)}") @@ -382,17 +382,17 @@ async def list_commands(self) -> ToolResult: try: # Ensure sandbox is initialized await self._ensure_sandbox() - + # List all tmux sessions result = await self._execute_raw_command("tmux list-sessions 2>/dev/null || echo 'No sessions'") output = result.get("output", "") - + if "No sessions" in output or not output.strip(): return self.success_response({ "message": "No active tmux sessions found.", "sessions": [] }) - + # Parse session list sessions = [] for line in output.split('\n'): @@ -401,12 +401,12 @@ async def list_commands(self) -> ToolResult: if parts: session_name = parts[0].strip() sessions.append(session_name) - + return self.success_response({ "message": f"Found {len(sessions)} active sessions.", "sessions": sessions }) - + except Exception as e: return self.fail_response(f"Error listing commands: {str(e)}") @@ -414,10 +414,10 @@ async def cleanup(self): """Clean up all sessions.""" for session_name in list(self._sessions.keys()): await self._cleanup_session(session_name) - + # Also clean up any tmux sessions try: await self._ensure_sandbox() await self._execute_raw_command("tmux kill-server 2>/dev/null || true") except: - pass \ No newline at end of file + pass diff --git a/app/tool/sb_vision_tool.py b/app/tool/sb_vision_tool.py index 6459b2d64..2cd356e94 100644 --- a/app/tool/sb_vision_tool.py +++ b/app/tool/sb_vision_tool.py @@ -6,7 +6,7 @@ from PIL import Image from agentpress.tool import ToolResult, openapi_schema, xml_schema -from sandbox.tool_base import SandboxToolsBase +from daytona.tool_base import SandboxToolsBase from agentpress.thread_manager import ThreadManager import json @@ -38,19 +38,19 @@ def __init__(self, project_id: str, thread_id: str, thread_manager: ThreadManage def compress_image(self, image_bytes: bytes, mime_type: str, file_path: str) -> Tuple[bytes, str]: """Compress an image to reduce its size while maintaining reasonable quality. - + Args: image_bytes: Original image bytes mime_type: MIME type of the image file_path: Path to the image file (for logging) - + Returns: Tuple of (compressed_bytes, new_mime_type) """ try: # Open image from bytes img = Image.open(BytesIO(image_bytes)) - + # Convert RGBA to RGB if necessary (for JPEG) if img.mode in ('RGBA', 'LA', 'P'): # Create a white background @@ -59,7 +59,7 @@ def compress_image(self, image_bytes: bytes, mime_type: str, file_path: str) -> img = img.convert('RGBA') background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) img = background - + # Calculate new dimensions while maintaining aspect ratio width, height = img.size if width > DEFAULT_MAX_WIDTH or height > DEFAULT_MAX_HEIGHT: @@ -68,10 +68,10 @@ def compress_image(self, image_bytes: bytes, mime_type: str, file_path: str) -> new_height = int(height * ratio) img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) print(f"[SeeImage] Resized image from {width}x{height} to {new_width}x{new_height}") - + # Save to bytes with compression output = BytesIO() - + # Determine output format based on original mime type if mime_type == 'image/gif': # Keep GIFs as GIFs to preserve animation @@ -85,17 +85,17 @@ def compress_image(self, image_bytes: bytes, mime_type: str, file_path: str) -> # Convert everything else to JPEG for better compression img.save(output, format='JPEG', quality=DEFAULT_JPEG_QUALITY, optimize=True) output_mime = 'image/jpeg' - + compressed_bytes = output.getvalue() - + # Log compression results original_size = len(image_bytes) compressed_size = len(compressed_bytes) compression_ratio = (1 - compressed_size / original_size) * 100 print(f"[SeeImage] Compressed '{file_path}' from {original_size / 1024:.1f}KB to {compressed_size / 1024:.1f}KB ({compression_ratio:.1f}% reduction)") - + return compressed_bytes, output_mime - + except Exception as e: print(f"[SeeImage] Failed to compress image: {str(e)}. Using original.") return image_bytes, mime_type @@ -173,7 +173,7 @@ async def see_image(self, file_path: str) -> ToolResult: # Compress the image compressed_bytes, compressed_mime_type = self.compress_image(image_bytes, mime_type, cleaned_path) - + # Check if compressed image is still too large if len(compressed_bytes) > MAX_COMPRESSED_SIZE: return self.fail_response(f"Image file '{cleaned_path}' is still too large after compression ({len(compressed_bytes) / (1024*1024):.2f}MB). Maximum compressed size is {MAX_COMPRESSED_SIZE / (1024*1024)}MB.") @@ -203,4 +203,4 @@ async def see_image(self, file_path: str) -> ToolResult: return self.success_response(f"Successfully loaded and compressed the image '{cleaned_path}' (reduced from {file_info.size / 1024:.1f}KB to {len(compressed_bytes) / 1024:.1f}KB).") except Exception as e: - return self.fail_response(f"An unexpected error occurred while trying to see the image: {str(e)}") \ No newline at end of file + return self.fail_response(f"An unexpected error occurred while trying to see the image: {str(e)}") diff --git a/app/tool/update_agent_tool.py b/app/tool/update_agent_tool.py deleted file mode 100644 index 5113e56bd..000000000 --- a/app/tool/update_agent_tool.py +++ /dev/null @@ -1,889 +0,0 @@ -import json -import httpx -from typing import Optional, Dict, Any, List -from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema -from agentpress.thread_manager import ThreadManager - -class UpdateAgentTool(Tool): - """Tool for updating agent configuration. - - This tool is used by the agent builder to update agent properties - based on user requirements. - """ - - def __init__(self, thread_manager: ThreadManager, db_connection, agent_id: str): - super().__init__() - self.thread_manager = thread_manager - self.db = db_connection - self.agent_id = agent_id - # Smithery API configuration - self.smithery_api_base_url = "https://registry.smithery.ai" - import os - self.smithery_api_key = os.getenv("SMITHERY_API_KEY") - - @openapi_schema({ - "type": "function", - "function": { - "name": "update_agent", - "description": "Update the agent's configuration including name, description, system prompt, tools, and MCP servers. Call this whenever the user wants to modify any aspect of the agent.", - "parameters": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The name of the agent. Should be descriptive and indicate the agent's purpose." - }, - "description": { - "type": "string", - "description": "A brief description of what the agent does and its capabilities." - }, - "system_prompt": { - "type": "string", - "description": "The system instructions that define the agent's behavior, expertise, and approach. This should be comprehensive and well-structured." - }, - "agentpress_tools": { - "type": "object", - "description": "Configuration for AgentPress tools. Each key is a tool name, and the value is an object with 'enabled' (boolean) and 'description' (string) properties.", - "additionalProperties": { - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "description": {"type": "string"} - } - } - }, - "configured_mcps": { - "type": "array", - "description": "List of configured MCP servers for external integrations.", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "qualifiedName": {"type": "string"}, - "config": {"type": "object"}, - "enabledTools": { - "type": "array", - "items": {"type": "string"} - } - } - } - }, - "avatar": { - "type": "string", - "description": "Emoji to use as the agent's avatar." - }, - "avatar_color": { - "type": "string", - "description": "Hex color code for the agent's avatar background." - } - }, - "required": [] - } - } - }) - @xml_schema( - tag_name="update-agent", - mappings=[ - {"param_name": "name", "node_type": "attribute", "path": ".", "required": False}, - {"param_name": "description", "node_type": "element", "path": "description", "required": False}, - {"param_name": "system_prompt", "node_type": "element", "path": "system_prompt", "required": False}, - {"param_name": "agentpress_tools", "node_type": "element", "path": "agentpress_tools", "required": False}, - {"param_name": "configured_mcps", "node_type": "element", "path": "configured_mcps", "required": False}, - {"param_name": "avatar", "node_type": "attribute", "path": ".", "required": False}, - {"param_name": "avatar_color", "node_type": "attribute", "path": ".", "required": False} - ], - example=''' - - - Research Assistant - An AI assistant specialized in conducting research and providing comprehensive analysis - You are a research assistant with expertise in gathering, analyzing, and synthesizing information. Your approach is thorough and methodical... - {"web_search": {"enabled": true, "description": "Search the web for information"}, "sb_files": {"enabled": true, "description": "Read and write files"}} - 🔬 - #4F46E5 - - - ''' - ) - async def update_agent( - self, - name: Optional[str] = None, - description: Optional[str] = None, - system_prompt: Optional[str] = None, - agentpress_tools: Optional[Dict[str, Dict[str, Any]]] = None, - configured_mcps: Optional[list] = None, - avatar: Optional[str] = None, - avatar_color: Optional[str] = None - ) -> ToolResult: - """Update agent configuration with provided fields. - - Args: - name: Agent name - description: Agent description - system_prompt: System instructions for the agent - agentpress_tools: AgentPress tools configuration - configured_mcps: MCP servers configuration - avatar: Emoji avatar - avatar_color: Avatar background color - - Returns: - ToolResult with updated agent data or error - """ - try: - client = await self.db.client - - update_data = {} - if name is not None: - update_data["name"] = name - if description is not None: - update_data["description"] = description - if system_prompt is not None: - update_data["system_prompt"] = system_prompt - if agentpress_tools is not None: - formatted_tools = {} - for tool_name, tool_config in agentpress_tools.items(): - if isinstance(tool_config, dict): - formatted_tools[tool_name] = { - "enabled": tool_config.get("enabled", False), - "description": tool_config.get("description", "") - } - update_data["agentpress_tools"] = formatted_tools - if configured_mcps is not None: - if isinstance(configured_mcps, str): - configured_mcps = json.loads(configured_mcps) - update_data["configured_mcps"] = configured_mcps - if avatar is not None: - update_data["avatar"] = avatar - if avatar_color is not None: - update_data["avatar_color"] = avatar_color - - if not update_data: - return self.fail_response("No fields provided to update") - - result = await client.table('agents').update(update_data).eq('agent_id', self.agent_id).execute() - - if not result.data: - return self.fail_response("Failed to update agent") - - return self.success_response({ - "message": "Agent updated successfully", - "updated_fields": list(update_data.keys()), - "agent": result.data[0] - }) - - except Exception as e: - return self.fail_response(f"Error updating agent: {str(e)}") - - @openapi_schema({ - "type": "function", - "function": { - "name": "get_current_agent_config", - "description": "Get the current configuration of the agent being edited. Use this to check what's already configured before making updates.", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - } - }) - @xml_schema( - tag_name="get-current-agent-config", - mappings=[], - example=''' - - - - - ''' - ) - async def get_current_agent_config(self) -> ToolResult: - """Get the current agent configuration. - - Returns: - ToolResult with current agent configuration - """ - try: - client = await self.db.client - - result = await client.table('agents').select('*').eq('agent_id', self.agent_id).execute() - - if not result.data: - return self.fail_response("Agent not found") - - agent = result.data[0] - - config_summary = { - "agent_id": agent["agent_id"], - "name": agent.get("name", "Untitled Agent"), - "description": agent.get("description", "No description set"), - "system_prompt": agent.get("system_prompt", "No system prompt set"), - "avatar": agent.get("avatar", "🤖"), - "avatar_color": agent.get("avatar_color", "#6B7280"), - "agentpress_tools": agent.get("agentpress_tools", {}), - "configured_mcps": agent.get("configured_mcps", []), - "created_at": agent.get("created_at"), - "updated_at": agent.get("updated_at") - } - - tools_count = len([t for t, cfg in config_summary["agentpress_tools"].items() if cfg.get("enabled")]) - mcps_count = len(config_summary["configured_mcps"]) - - return self.success_response({ - "summary": f"Agent '{config_summary['name']}' has {tools_count} tools enabled and {mcps_count} MCP servers configured.", - "configuration": config_summary - }) - - except Exception as e: - return self.fail_response(f"Error getting agent configuration: {str(e)}") - - @openapi_schema({ - "type": "function", - "function": { - "name": "search_mcp_servers", - "description": "Search for MCP servers from the Smithery registry based on user requirements. Use this when the user wants to add MCP tools to their agent.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search query for finding relevant MCP servers (e.g., 'linear', 'github', 'database', 'search')" - }, - "category": { - "type": "string", - "description": "Optional category filter", - "enum": ["AI & Search", "Development & Version Control", "Project Management", "Communication & Collaboration", "Data & Analytics", "Cloud & Infrastructure", "File Storage", "Marketing & Sales", "Customer Support", "Finance", "Automation & Productivity", "Utilities"] - }, - "limit": { - "type": "integer", - "description": "Maximum number of servers to return (default: 10)", - "default": 10 - } - }, - "required": ["query"] - } - } - }) - @xml_schema( - tag_name="search-mcp-servers", - mappings=[ - {"param_name": "query", "node_type": "attribute", "path": "."}, - {"param_name": "category", "node_type": "attribute", "path": "."}, - {"param_name": "limit", "node_type": "attribute", "path": "."} - ], - example=''' - - - linear - 5 - - - ''' - ) - async def search_mcp_servers( - self, - query: str, - category: Optional[str] = None, - limit: int = 10 - ) -> ToolResult: - """Search for MCP servers based on user requirements. - - Args: - query: Search query for finding relevant MCP servers - category: Optional category filter - limit: Maximum number of servers to return - - Returns: - ToolResult with matching MCP servers - """ - try: - async with httpx.AsyncClient() as client: - headers = { - "Accept": "application/json", - "User-Agent": "Suna-MCP-Integration/1.0" - } - - if self.smithery_api_key: - headers["Authorization"] = f"Bearer {self.smithery_api_key}" - - params = { - "q": query, - "page": 1, - "pageSize": min(limit * 2, 50) # Get more results to filter - } - - response = await client.get( - f"{self.smithery_api_base_url}/servers", - headers=headers, - params=params, - timeout=30.0 - ) - - response.raise_for_status() - data = response.json() - servers = data.get("servers", []) - - # Filter by category if specified - if category: - filtered_servers = [] - for server in servers: - server_category = self._categorize_server(server) - if server_category == category: - filtered_servers.append(server) - servers = filtered_servers - - # Sort by useCount and limit results - servers = sorted(servers, key=lambda x: x.get("useCount", 0), reverse=True)[:limit] - - # Format results for user-friendly display - formatted_servers = [] - for server in servers: - formatted_servers.append({ - "name": server.get("displayName", server.get("qualifiedName", "Unknown")), - "qualifiedName": server.get("qualifiedName"), - "description": server.get("description", "No description available"), - "useCount": server.get("useCount", 0), - "category": self._categorize_server(server), - "homepage": server.get("homepage", ""), - "isDeployed": server.get("isDeployed", False) - }) - - if not formatted_servers: - return ToolResult( - success=False, - output=json.dumps([], ensure_ascii=False) - ) - - return ToolResult( - success=True, - output=json.dumps(formatted_servers, ensure_ascii=False) - ) - - except Exception as e: - return self.fail_response(f"Error searching MCP servers: {str(e)}") - - @openapi_schema({ - "type": "function", - "function": { - "name": "get_mcp_server_tools", - "description": "Get detailed information about a specific MCP server including its available tools. Use this after the user selects a server they want to connect to.", - "parameters": { - "type": "object", - "properties": { - "qualified_name": { - "type": "string", - "description": "The qualified name of the MCP server (e.g., 'exa', '@smithery-ai/github')" - } - }, - "required": ["qualified_name"] - } - } - }) - @xml_schema( - tag_name="get-mcp-server-tools", - mappings=[ - {"param_name": "qualified_name", "node_type": "attribute", "path": ".", "required": True} - ], - example=''' - - - exa - - - ''' - ) - async def get_mcp_server_tools(self, qualified_name: str) -> ToolResult: - """Get detailed information about a specific MCP server and its tools. - - Args: - qualified_name: The qualified name of the MCP server - - Returns: - ToolResult with server details and available tools - """ - try: - # First get server metadata from registry - async with httpx.AsyncClient() as client: - headers = { - "Accept": "application/json", - "User-Agent": "Suna-MCP-Integration/1.0" - } - - if self.smithery_api_key: - headers["Authorization"] = f"Bearer {self.smithery_api_key}" - - # URL encode the qualified name if it contains special characters - from urllib.parse import quote - if '@' in qualified_name or '/' in qualified_name: - encoded_name = quote(qualified_name, safe='') - else: - encoded_name = qualified_name - - url = f"{self.smithery_api_base_url}/servers/{encoded_name}" - - response = await client.get( - url, - headers=headers, - timeout=30.0 - ) - - response.raise_for_status() - server_data = response.json() - - # Now connect to the MCP server to get actual tools using ClientSession - try: - # Import MCP components - from mcp import ClientSession - from mcp.client.streamable_http import streamablehttp_client - import base64 - import os - - # Check if Smithery API key is available - smithery_api_key = os.getenv("SMITHERY_API_KEY") - if not smithery_api_key: - raise ValueError("SMITHERY_API_KEY environment variable is not set") - - # Create server URL with empty config for testing - config_json = json.dumps({}) - config_b64 = base64.b64encode(config_json.encode()).decode() - server_url = f"https://server.smithery.ai/{qualified_name}/mcp?config={config_b64}&api_key={smithery_api_key}" - - # Connect and get tools - async with streamablehttp_client(server_url) as (read_stream, write_stream, _): - async with ClientSession(read_stream, write_stream) as session: - # Initialize the connection - await session.initialize() - - # List available tools - tools_result = await session.list_tools() - tools = tools_result.tools if hasattr(tools_result, 'tools') else tools_result - - # Format tools for user-friendly display - formatted_tools = [] - for tool in tools: - tool_info = { - "name": tool.name, - "description": getattr(tool, 'description', 'No description available'), - } - - # Extract parameters from inputSchema if available - if hasattr(tool, 'inputSchema') and tool.inputSchema: - schema = tool.inputSchema - if isinstance(schema, dict): - tool_info["parameters"] = schema.get("properties", {}) - tool_info["required_params"] = schema.get("required", []) - else: - tool_info["parameters"] = {} - tool_info["required_params"] = [] - else: - tool_info["parameters"] = {} - tool_info["required_params"] = [] - - formatted_tools.append(tool_info) - - # Extract configuration requirements from server metadata - config_requirements = [] - security = server_data.get("security", {}) - if security: - for key, value in security.items(): - if isinstance(value, dict): - config_requirements.append({ - "name": key, - "description": value.get("description", f"Configuration for {key}"), - "required": value.get("required", False), - "type": value.get("type", "string") - }) - - server_info = { - "name": server_data.get("displayName", qualified_name), - "qualifiedName": qualified_name, - "description": server_data.get("description", "No description available"), - "homepage": server_data.get("homepage", ""), - "iconUrl": server_data.get("iconUrl", ""), - "isDeployed": server_data.get("isDeployed", False), - "tools": formatted_tools, - "config_requirements": config_requirements, - "total_tools": len(formatted_tools) - } - - return self.success_response({ - "message": f"Found {len(formatted_tools)} tools for {server_info['name']}", - "server": server_info - }) - - except Exception as mcp_error: - # If MCP connection fails, fall back to registry data - tools = server_data.get("tools", []) - formatted_tools = [] - for tool in tools: - formatted_tools.append({ - "name": tool.get("name", "Unknown"), - "description": tool.get("description", "No description available"), - "parameters": tool.get("inputSchema", {}).get("properties", {}), - "required_params": tool.get("inputSchema", {}).get("required", []) - }) - - config_requirements = [] - security = server_data.get("security", {}) - if security: - for key, value in security.items(): - if isinstance(value, dict): - config_requirements.append({ - "name": key, - "description": value.get("description", f"Configuration for {key}"), - "required": value.get("required", False), - "type": value.get("type", "string") - }) - - server_info = { - "name": server_data.get("displayName", qualified_name), - "qualifiedName": qualified_name, - "description": server_data.get("description", "No description available"), - "homepage": server_data.get("homepage", ""), - "iconUrl": server_data.get("iconUrl", ""), - "isDeployed": server_data.get("isDeployed", False), - "tools": formatted_tools, - "config_requirements": config_requirements, - "total_tools": len(formatted_tools), - "note": "Tools listed from registry metadata (MCP connection failed - may need configuration)" - } - - return self.success_response({ - "message": f"Found {len(formatted_tools)} tools for {server_info['name']} (from registry)", - "server": server_info - }) - - except Exception as e: - return self.fail_response(f"Error getting MCP server tools: {str(e)}") - - @openapi_schema({ - "type": "function", - "function": { - "name": "configure_mcp_server", - "description": "Configure and add an MCP server to the agent with selected tools. Use this after the user has chosen which tools they want from a server.", - "parameters": { - "type": "object", - "properties": { - "qualified_name": { - "type": "string", - "description": "The qualified name of the MCP server" - }, - "display_name": { - "type": "string", - "description": "Display name for the server" - }, - "enabled_tools": { - "type": "array", - "description": "List of tool names to enable for this server", - "items": {"type": "string"} - }, - "config": { - "type": "object", - "description": "Configuration object with API keys and other settings", - "additionalProperties": True - } - }, - "required": ["qualified_name", "display_name", "enabled_tools"] - } - } - }) - @xml_schema( - tag_name="configure-mcp-server", - mappings=[ - {"param_name": "qualified_name", "node_type": "attribute", "path": ".", "required": True}, - {"param_name": "display_name", "node_type": "attribute", "path": ".", "required": True}, - {"param_name": "enabled_tools", "node_type": "element", "path": "enabled_tools", "required": True}, - {"param_name": "config", "node_type": "element", "path": "config", "required": False} - ], - example=''' - - - exa - Exa Search - ["search", "find_similar"] - {"exaApiKey": "user-api-key"} - - - ''' - ) - async def configure_mcp_server( - self, - qualified_name: str, - display_name: str, - enabled_tools: List[str], - config: Optional[Dict[str, Any]] = None - ) -> ToolResult: - """Configure and add an MCP server to the agent. - - Args: - qualified_name: The qualified name of the MCP server - display_name: Display name for the server - enabled_tools: List of tool names to enable - config: Configuration object with API keys and settings - - Returns: - ToolResult with configuration status - """ - try: - client = await self.db.client - - # Get current agent configuration - result = await client.table('agents').select('configured_mcps').eq('agent_id', self.agent_id).execute() - - if not result.data: - return self.fail_response("Agent not found") - - current_mcps = result.data[0].get('configured_mcps', []) - - # Check if server is already configured - existing_server_index = None - for i, mcp in enumerate(current_mcps): - if mcp.get('qualifiedName') == qualified_name: - existing_server_index = i - break - - # Create new MCP configuration - new_mcp_config = { - "name": display_name, - "qualifiedName": qualified_name, - "config": config or {}, - "enabledTools": enabled_tools - } - - # Update or add the configuration - if existing_server_index is not None: - current_mcps[existing_server_index] = new_mcp_config - action = "updated" - else: - current_mcps.append(new_mcp_config) - action = "added" - - # Save to database - update_result = await client.table('agents').update({ - 'configured_mcps': current_mcps - }).eq('agent_id', self.agent_id).execute() - - if not update_result.data: - return self.fail_response("Failed to save MCP configuration") - - return self.success_response({ - "message": f"Successfully {action} MCP server '{display_name}' with {len(enabled_tools)} tools", - "server": new_mcp_config, - "total_mcp_servers": len(current_mcps), - "action": action - }) - - except Exception as e: - return self.fail_response(f"Error configuring MCP server: {str(e)}") - - @openapi_schema({ - "type": "function", - "function": { - "name": "get_popular_mcp_servers", - "description": "Get a list of popular and recommended MCP servers organized by category. Use this to show users popular options when they want to add MCP tools.", - "parameters": { - "type": "object", - "properties": { - "category": { - "type": "string", - "description": "Optional category filter to show only servers from a specific category", - "enum": ["AI & Search", "Development & Version Control", "Project Management", "Communication & Collaboration", "Data & Analytics", "Cloud & Infrastructure", "File Storage", "Marketing & Sales", "Customer Support", "Finance", "Automation & Productivity", "Utilities"] - } - }, - "required": [] - } - } - }) - @xml_schema( - tag_name="get-popular-mcp-servers", - mappings=[ - {"param_name": "category", "node_type": "attribute", "path": ".", "required": False} - ], - example=''' - - - AI & Search - - - ''' - ) - async def get_popular_mcp_servers(self, category: Optional[str] = None) -> ToolResult: - """Get popular MCP servers organized by category. - - Args: - category: Optional category filter - - Returns: - ToolResult with popular MCP servers - """ - try: - async with httpx.AsyncClient() as client: - headers = { - "Accept": "application/json", - "User-Agent": "Suna-MCP-Integration/1.0" - } - - if self.smithery_api_key: - headers["Authorization"] = f"Bearer {self.smithery_api_key}" - - response = await client.get( - f"{self.smithery_api_base_url}/servers", - headers=headers, - params={"page": 1, "pageSize": 50}, - timeout=30.0 - ) - - response.raise_for_status() - data = response.json() - servers = data.get("servers", []) - - # Categorize servers - categorized = {} - for server in servers: - server_category = self._categorize_server(server) - if category and server_category != category: - continue - - if server_category not in categorized: - categorized[server_category] = [] - - categorized[server_category].append({ - "name": server.get("displayName", server.get("qualifiedName", "Unknown")), - "qualifiedName": server.get("qualifiedName"), - "description": server.get("description", "No description available"), - "useCount": server.get("useCount", 0), - "homepage": server.get("homepage", ""), - "isDeployed": server.get("isDeployed", False) - }) - - # Sort categories and servers within each category - for cat in categorized: - categorized[cat] = sorted(categorized[cat], key=lambda x: x["useCount"], reverse=True)[:5] - - return self.success_response({ - "message": f"Found popular MCP servers" + (f" in category '{category}'" if category else ""), - "categorized_servers": categorized, - "total_categories": len(categorized) - }) - - except Exception as e: - return self.fail_response(f"Error getting popular MCP servers: {str(e)}") - - def _categorize_server(self, server: Dict[str, Any]) -> str: - """Categorize a server based on its qualified name and description.""" - qualified_name = server.get("qualifiedName", "").lower() - description = server.get("description", "").lower() - - # Category mappings - category_mappings = { - "AI & Search": ["exa", "perplexity", "openai", "anthropic", "duckduckgo", "brave", "google", "search"], - "Development & Version Control": ["github", "gitlab", "bitbucket", "git"], - "Project Management": ["linear", "jira", "asana", "notion", "trello", "monday", "clickup"], - "Communication & Collaboration": ["slack", "discord", "teams", "zoom", "telegram"], - "Data & Analytics": ["postgres", "mysql", "mongodb", "bigquery", "snowflake", "sqlite", "redis", "database"], - "Cloud & Infrastructure": ["aws", "gcp", "azure", "vercel", "netlify", "cloudflare", "docker"], - "File Storage": ["gdrive", "google-drive", "dropbox", "box", "onedrive", "s3", "drive"], - "Marketing & Sales": ["hubspot", "salesforce", "mailchimp", "sendgrid"], - "Customer Support": ["zendesk", "intercom", "freshdesk", "helpscout"], - "Finance": ["stripe", "quickbooks", "xero", "plaid"], - "Automation & Productivity": ["playwright", "puppeteer", "selenium", "desktop-commander", "sequential-thinking", "automation"], - "Utilities": ["filesystem", "memory", "fetch", "time", "weather", "currency", "file"] - } - - # Check qualified name and description for category keywords - for category, keywords in category_mappings.items(): - for keyword in keywords: - if keyword in qualified_name or keyword in description: - return category - - return "Other" - - @openapi_schema({ - "type": "function", - "function": { - "name": "test_mcp_server_connection", - "description": "Test connectivity to an MCP server with provided configuration. Use this to validate that a server can be connected to before adding it to the agent.", - "parameters": { - "type": "object", - "properties": { - "qualified_name": { - "type": "string", - "description": "The qualified name of the MCP server" - }, - "config": { - "type": "object", - "description": "Configuration object with API keys and other settings", - "additionalProperties": True - } - }, - "required": ["qualified_name"] - } - } - }) - @xml_schema( - tag_name="test-mcp-server-connection", - mappings=[ - {"param_name": "qualified_name", "node_type": "attribute", "path": ".", "required": True}, - {"param_name": "config", "node_type": "element", "path": "config", "required": False} - ], - example=''' - - - exa - {"exaApiKey": "user-api-key"} - - - ''' - ) - async def test_mcp_server_connection( - self, - qualified_name: str, - config: Optional[Dict[str, Any]] = None - ) -> ToolResult: - """Test connectivity to an MCP server with provided configuration. - - Args: - qualified_name: The qualified name of the MCP server - config: Configuration object with API keys and settings - - Returns: - ToolResult with connection test results - """ - try: - # Import MCP components - from mcp import ClientSession - from mcp.client.streamable_http import streamablehttp_client - import base64 - import os - - # Check if Smithery API key is available - smithery_api_key = os.getenv("SMITHERY_API_KEY") - if not smithery_api_key: - return self.fail_response("SMITHERY_API_KEY environment variable is not set") - - # Create server URL with provided config - config_json = json.dumps(config or {}) - config_b64 = base64.b64encode(config_json.encode()).decode() - server_url = f"https://server.smithery.ai/{qualified_name}/mcp?config={config_b64}&api_key={smithery_api_key}" - - # Test connection - async with streamablehttp_client(server_url) as (read_stream, write_stream, _): - async with ClientSession(read_stream, write_stream) as session: - # Initialize the connection - await session.initialize() - - # List available tools to verify connection - tools_result = await session.list_tools() - tools = tools_result.tools if hasattr(tools_result, 'tools') else tools_result - - tool_names = [tool.name for tool in tools] - - return self.success_response({ - "message": f"Successfully connected to {qualified_name}", - "qualified_name": qualified_name, - "connection_status": "success", - "available_tools": tool_names, - "total_tools": len(tool_names) - }) - - except Exception as e: - return self.fail_response(f"Failed to connect to {qualified_name}: {str(e)}") \ No newline at end of file diff --git a/app/utils/files_utils.py b/app/utils/files_utils.py index 508b1bb9f..fb3f5676c 100644 --- a/app/utils/files_utils.py +++ b/app/utils/files_utils.py @@ -40,10 +40,10 @@ def should_exclude_file(rel_path: str) -> bool: """Check if a file should be excluded based on path, name, or extension - + Args: rel_path: Relative path of the file to check - + Returns: True if the file should be excluded, False otherwise """ @@ -62,30 +62,30 @@ def should_exclude_file(rel_path: str) -> bool: if ext.lower() in EXCLUDED_EXT: return True - return False + return False def clean_path(path: str, workspace_path: str = "/workspace") -> str: """Clean and normalize a path to be relative to the workspace - + Args: path: The path to clean workspace_path: The base workspace path to remove (default: "/workspace") - + Returns: The cleaned path, relative to the workspace """ # Remove any leading slash path = path.lstrip('/') - + # Remove workspace prefix if present if path.startswith(workspace_path.lstrip('/')): path = path[len(workspace_path.lstrip('/')):] - + # Remove workspace/ prefix if present if path.startswith('workspace/'): path = path[9:] - + # Remove any remaining leading slash path = path.lstrip('/') - - return path \ No newline at end of file + + return path From d7e3c14b6041b275040422a9b914067bb3683282 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Sun, 6 Jul 2025 15:13:41 +0800 Subject: [PATCH 05/33] debug computer use tool --- app/agent/manus.py | 9 + app/agentpress/context_manager.py | 114 +++--- app/agentpress/response_processor.py | 374 +++++++++--------- app/agentpress/thread_manager.py | 78 ++-- app/agentpress/tool.py | 46 +-- app/agentpress/tool_registry.py | 50 +-- app/config.py | 19 + app/daytona/requirements.txt | 3 + app/daytona/sandbox.py | 57 +-- app/daytona/tool_base.py | 129 +++--- app/tool/base.py | 84 ++-- app/tool/computer_use_tool.py | 48 ++- app/tool/data_providers/ActiveJobsProvider.py | 57 --- app/tool/data_providers/AmazonProvider.py | 191 --------- app/tool/data_providers/LinkedinProvider.py | 250 ------------ .../data_providers/RapidDataProviderBase.py | 61 --- app/tool/data_providers/TwitterProvider.py | 240 ----------- .../data_providers/YahooFinanceProvider.py | 190 --------- app/tool/data_providers/ZillowProvider.py | 187 --------- tests/daytona/test_computer_use_tool.py | 22 ++ tests/daytona/test_daytona.py | 81 ++++ 21 files changed, 637 insertions(+), 1653 deletions(-) create mode 100644 app/daytona/requirements.txt delete mode 100644 app/tool/data_providers/ActiveJobsProvider.py delete mode 100644 app/tool/data_providers/AmazonProvider.py delete mode 100644 app/tool/data_providers/LinkedinProvider.py delete mode 100644 app/tool/data_providers/RapidDataProviderBase.py delete mode 100644 app/tool/data_providers/TwitterProvider.py delete mode 100644 app/tool/data_providers/YahooFinanceProvider.py delete mode 100644 app/tool/data_providers/ZillowProvider.py create mode 100644 tests/daytona/test_computer_use_tool.py create mode 100644 tests/daytona/test_daytona.py diff --git a/app/agent/manus.py b/app/agent/manus.py index df40edbba..c8ba9cd3a 100644 --- a/app/agent/manus.py +++ b/app/agent/manus.py @@ -14,6 +14,8 @@ from app.tool.python_execute import PythonExecute from app.tool.str_replace_editor import StrReplaceEditor +from app.tool.computer_use_tool import ComputerUseTool +from app.daytona.sandbox import create_sandbox class Manus(ToolCallAgent): """A versatile general-purpose agent with support for both local and MCP tools.""" @@ -61,9 +63,16 @@ async def create(cls, **kwargs) -> "Manus": """Factory method to create and properly initialize a Manus instance.""" instance = cls(**kwargs) await instance.initialize_mcp_servers() + instance.initialize_sandbox_tools() instance._initialized = True return instance + def initialize_sandbox_tools(self,password="123456") -> None: + sandbox = create_sandbox(password=password) + computer_tool = ComputerUseTool.create_with_sandbox(sandbox) + sandbox_tools=[computer_tool] + self.available_tools.add_tools(*sandbox_tools) + async def initialize_mcp_servers(self) -> None: """Initialize connections to configured MCP servers.""" for server_id, server_config in config.mcp_config.servers.items(): diff --git a/app/agentpress/context_manager.py b/app/agentpress/context_manager.py index 11405f40b..39398dee4 100644 --- a/app/agentpress/context_manager.py +++ b/app/agentpress/context_manager.py @@ -9,9 +9,9 @@ from typing import List, Dict, Any, Optional from litellm import token_counter, completion_cost -from services.supabase import DBConnection -from services.llm import make_llm_api_call -from utils.logger import logger +from app.services.supabase import DBConnection +from app.services.llm import make_llm_api_call +from app.utils.logger import logger # Constants for token management DEFAULT_TOKEN_THRESHOLD = 120000 # 80k tokens threshold for summarization @@ -20,62 +20,62 @@ class ContextManager: """Manages thread context including token counting and summarization.""" - + def __init__(self, token_threshold: int = DEFAULT_TOKEN_THRESHOLD): """Initialize the ContextManager. - + Args: token_threshold: Token count threshold to trigger summarization """ self.db = DBConnection() self.token_threshold = token_threshold - + async def get_thread_token_count(self, thread_id: str) -> int: """Get the current token count for a thread using LiteLLM. - + Args: thread_id: ID of the thread to analyze - + Returns: The total token count for relevant messages in the thread """ logger.debug(f"Getting token count for thread {thread_id}") - + try: # Get messages for the thread messages = await self.get_messages_for_summarization(thread_id) - + if not messages: logger.debug(f"No messages found for thread {thread_id}") return 0 - + # Use litellm's token_counter for accurate model-specific counting # This is much more accurate than the SQL-based estimation token_count = token_counter(model="gpt-4", messages=messages) - + logger.info(f"Thread {thread_id} has {token_count} tokens (calculated with litellm)") return token_count - + except Exception as e: logger.error(f"Error getting token count: {str(e)}") return 0 - + async def get_messages_for_summarization(self, thread_id: str) -> List[Dict[str, Any]]: """Get all LLM messages from the thread that need to be summarized. - + This gets messages after the most recent summary or all messages if - no summary exists. Unlike get_llm_messages, this includes ALL messages + no summary exists. Unlike get_llm_messages, this includes ALL messages since the last summary, even if we're generating a new summary. - + Args: thread_id: ID of the thread to get messages from - + Returns: List of message objects to summarize """ logger.debug(f"Getting messages for summarization for thread {thread_id}") client = await self.db.client - + try: # Find the most recent summary message summary_result = await client.table('messages').select('created_at') \ @@ -85,12 +85,12 @@ async def get_messages_for_summarization(self, thread_id: str) -> List[Dict[str, .order('created_at', desc=True) \ .limit(1) \ .execute() - + # Get messages after the most recent summary or all messages if no summary if summary_result.data and len(summary_result.data) > 0: last_summary_time = summary_result.data[0]['created_at'] logger.debug(f"Found last summary at {last_summary_time}") - + # Get all messages after the summary, but NOT including the summary itself messages_result = await client.table('messages').select('*') \ .eq('thread_id', thread_id) \ @@ -106,7 +106,7 @@ async def get_messages_for_summarization(self, thread_id: str) -> List[Dict[str, .eq('is_llm_message', True) \ .order('created_at') \ .execute() - + # Parse the message content if needed messages = [] for msg in messages_result.data: @@ -114,7 +114,7 @@ async def get_messages_for_summarization(self, thread_id: str) -> List[Dict[str, if msg.get('type') == 'summary': logger.debug(f"Skipping summary message from {msg.get('created_at')}") continue - + # Parse content if it's a string content = msg['content'] if isinstance(content, str): @@ -122,45 +122,45 @@ async def get_messages_for_summarization(self, thread_id: str) -> List[Dict[str, content = json.loads(content) except json.JSONDecodeError: pass # Keep as string if not valid JSON - + # Ensure we have the proper format for the LLM if 'role' not in content and 'type' in msg: # Convert message type to role if needed role = msg['type'] if role == 'assistant' or role == 'user' or role == 'system' or role == 'tool': content = {'role': role, 'content': content} - + messages.append(content) - + logger.info(f"Got {len(messages)} messages to summarize for thread {thread_id}") return messages - + except Exception as e: logger.error(f"Error getting messages for summarization: {str(e)}", exc_info=True) return [] - + async def create_summary( - self, - thread_id: str, - messages: List[Dict[str, Any]], + self, + thread_id: str, + messages: List[Dict[str, Any]], model: str = "gpt-4o-mini" ) -> Optional[Dict[str, Any]]: """Generate a summary of conversation messages. - + Args: thread_id: ID of the thread to summarize messages: Messages to summarize model: LLM model to use for summarization - + Returns: Summary message object or None if summarization failed """ if not messages: logger.warning("No messages to summarize") return None - + logger.info(f"Creating summary for thread {thread_id} with {len(messages)} messages") - + # Create system message with summarization instructions system_message = { "role": "system", @@ -185,7 +185,7 @@ async def create_summary( =============================================================== """ } - + try: # Call LLM to generate summary response = await make_llm_api_call( @@ -195,10 +195,10 @@ async def create_summary( max_tokens=SUMMARY_TARGET_TOKENS, stream=False ) - + if response and hasattr(response, 'choices') and response.choices: summary_content = response.choices[0].message.content - + # Track token usage try: token_count = token_counter(model=model, messages=[{"role": "user", "content": summary_content}]) @@ -206,7 +206,7 @@ async def create_summary( logger.info(f"Summary generated with {token_count} tokens at cost ${cost:.6f}") except Exception as e: logger.error(f"Error calculating token usage: {str(e)}") - + # Format the summary message with clear beginning and end markers formatted_summary = f""" ======== CONVERSATION HISTORY SUMMARY ======== @@ -217,66 +217,66 @@ async def create_summary( The above is a summary of the conversation history. The conversation continues below. """ - + # Format the summary message summary_message = { "role": "user", "content": formatted_summary } - + return summary_message else: logger.error("Failed to generate summary: Invalid response") return None - + except Exception as e: logger.error(f"Error creating summary: {str(e)}", exc_info=True) return None - + async def check_and_summarize_if_needed( - self, - thread_id: str, - add_message_callback, + self, + thread_id: str, + add_message_callback, model: str = "gpt-4o-mini", force: bool = False ) -> bool: """Check if thread needs summarization and summarize if so. - + Args: thread_id: ID of the thread to check add_message_callback: Callback to add the summary message to the thread model: LLM model to use for summarization force: Whether to force summarization regardless of token count - + Returns: True if summarization was performed, False otherwise """ try: # Get token count using LiteLLM (accurate model-specific counting) token_count = await self.get_thread_token_count(thread_id) - + # If token count is below threshold and not forcing, no summarization needed if token_count < self.token_threshold and not force: logger.debug(f"Thread {thread_id} has {token_count} tokens, below threshold {self.token_threshold}") return False - + # Log reason for summarization if force: logger.info(f"Forced summarization of thread {thread_id} with {token_count} tokens") else: logger.info(f"Thread {thread_id} exceeds token threshold ({token_count} >= {self.token_threshold}), summarizing...") - + # Get messages to summarize messages = await self.get_messages_for_summarization(thread_id) - + # If there are too few messages, don't summarize if len(messages) < 3: logger.info(f"Thread {thread_id} has too few messages ({len(messages)}) to summarize") return False - + # Create summary summary = await self.create_summary(thread_id, messages, model) - + if summary: # Add summary message to thread await add_message_callback( @@ -286,13 +286,13 @@ async def check_and_summarize_if_needed( is_llm_message=True, metadata={"token_count": token_count} ) - + logger.info(f"Successfully added summary to thread {thread_id}") return True else: logger.error(f"Failed to create summary for thread {thread_id}") return False - + except Exception as e: logger.error(f"Error in check_and_summarize_if_needed: {str(e)}", exc_info=True) - return False \ No newline at end of file + return False diff --git a/app/agentpress/response_processor.py b/app/agentpress/response_processor.py index 68cd6bdc1..e54f97331 100644 --- a/app/agentpress/response_processor.py +++ b/app/agentpress/response_processor.py @@ -15,14 +15,14 @@ from datetime import datetime, timezone from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple, Union, Callable, Literal from dataclasses import dataclass -from utils.logger import logger -from agentpress.tool import ToolResult -from agentpress.tool_registry import ToolRegistry -from agentpress.xml_tool_parser import XMLToolParser +from app.utils.logger import logger +from app.agentpress.tool import ToolResult +from app.agentpress.tool_registry import ToolRegistry +from app.agentpress.xml_tool_parser import XMLToolParser from langfuse.client import StatefulTraceClient -from services.langfuse import langfuse -from agentpress.utils.json_helpers import ( - ensure_dict, ensure_list, safe_json_parse, +from app.services.langfuse import langfuse +from app.agentpress.utils.json_helpers import ( + ensure_dict, ensure_list, safe_json_parse, to_json_string, format_for_yield ) from litellm import token_counter @@ -49,10 +49,10 @@ class ToolExecutionContext: class ProcessorConfig: """ Configuration for response processing and tool execution. - + This class controls how the LLM's responses are processed, including how tool calls are detected, executed, and their results handled. - + Attributes: xml_tool_calling: Enable XML-based tool call detection (...) native_tool_calling: Enable OpenAI-style function calling format @@ -63,7 +63,7 @@ class ProcessorConfig: max_xml_tool_calls: Maximum number of XML tool calls to process (0 = no limit) """ - xml_tool_calling: bool = True + xml_tool_calling: bool = True native_tool_calling: bool = False execute_tools: bool = True @@ -71,24 +71,24 @@ class ProcessorConfig: tool_execution_strategy: ToolExecutionStrategy = "sequential" xml_adding_strategy: XmlAddingStrategy = "assistant_message" max_xml_tool_calls: int = 0 # 0 means no limit - + def __post_init__(self): """Validate configuration after initialization.""" if self.xml_tool_calling is False and self.native_tool_calling is False and self.execute_tools: raise ValueError("At least one tool calling format (XML or native) must be enabled if execute_tools is True") - + if self.xml_adding_strategy not in ["user_message", "assistant_message", "inline_edit"]: raise ValueError("xml_adding_strategy must be 'user_message', 'assistant_message', or 'inline_edit'") - + if self.max_xml_tool_calls < 0: raise ValueError("max_xml_tool_calls must be a non-negative integer (0 = no limit)") class ResponseProcessor: """Processes LLM responses, extracting and executing tool calls.""" - + def __init__(self, tool_registry: ToolRegistry, add_message_callback: Callable, trace: Optional[StatefulTraceClient] = None, is_agent_builder: bool = False, target_agent_id: Optional[str] = None, agent_config: Optional[dict] = None): """Initialize the ResponseProcessor. - + Args: tool_registry: Registry of available tools add_message_callback: Callback function to add messages to the thread. @@ -108,7 +108,7 @@ def __init__(self, tool_registry: ToolRegistry, add_message_callback: Callable, async def _yield_message(self, message_obj: Optional[Dict[str, Any]]) -> Dict[str, Any]: """Helper to yield a message with proper formatting. - + Ensures that content and metadata are JSON strings for client compatibility. """ if message_obj: @@ -125,11 +125,11 @@ async def _add_message_with_agent_info( """Helper to add a message with agent version information if available.""" agent_id = None agent_version_id = None - + if self.agent_config: agent_id = self.agent_config.get('agent_id') agent_version_id = self.agent_config.get('current_version_id') - + return await self.add_message( thread_id=thread_id, type=type, @@ -149,14 +149,14 @@ async def process_streaming_response( config: ProcessorConfig = ProcessorConfig(), ) -> AsyncGenerator[Dict[str, Any], None]: """Process a streaming LLM response, handling tool calls and execution. - + Args: llm_response: Streaming response from the LLM thread_id: ID of the conversation thread prompt_messages: List of messages sent to the LLM (the prompt) llm_model: The name of the LLM model used config: Configuration for parsing and execution - + Yields: Complete message objects matching the DB schema, except for content chunks. """ @@ -198,14 +198,14 @@ async def process_streaming_response( # --- Save and Yield Start Events --- start_content = {"status_type": "thread_run_start", "thread_run_id": thread_run_id} start_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=start_content, + thread_id=thread_id, type="status", content=start_content, is_llm_message=False, metadata={"thread_run_id": thread_run_id} ) if start_msg_obj: yield format_for_yield(start_msg_obj) assist_start_content = {"status_type": "assistant_response_start"} assist_start_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=assist_start_content, + thread_id=thread_id, type="status", content=assist_start_content, is_llm_message=False, metadata={"thread_run_id": thread_run_id} ) if assist_start_msg_obj: yield format_for_yield(assist_start_msg_obj) @@ -219,7 +219,7 @@ async def process_streaming_response( 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 @@ -240,7 +240,7 @@ async def process_streaming_response( if hasattr(chunk, 'choices') and chunk.choices: delta = chunk.choices[0].delta if hasattr(chunk.choices[0], 'delta') else None - + # Check for and log Anthropic thinking content if delta and hasattr(delta, 'reasoning_content') and delta.reasoning_content: if not has_printed_thinking_prefix: @@ -379,12 +379,12 @@ async def process_streaming_response( # 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( @@ -425,7 +425,7 @@ async def process_streaming_response( tool_idx = execution.get("tool_index", -1) context = execution["context"] tool_name = context.function_name - + # Check if status was already yielded during stream run if tool_idx in yielded_tool_indices: logger.debug(f"Status for tool index {tool_idx} already yielded.") @@ -435,12 +435,12 @@ async def process_streaming_response( result = execution["task"].result() context.result = result tool_results_buffer.append((execution["tool_call"], result, tool_idx, context)) - + if tool_name in ['ask', 'complete']: logger.info(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.") self.trace.event(name="terminating_tool_completed_during_streaming", level="DEFAULT", status_message=(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.")) agent_should_terminate = True - + else: # Should not happen with asyncio.wait logger.warning(f"Task for tool index {tool_idx} not done after wait.") self.trace.event(name="task_for_tool_index_not_done_after_wait", level="WARNING", status_message=(f"Task for tool index {tool_idx} not done after wait.")) @@ -459,13 +459,13 @@ async def process_streaming_response( result = execution["task"].result() context.result = result tool_results_buffer.append((execution["tool_call"], result, tool_idx, context)) - + # Check if this is a terminating tool if tool_name in ['ask', 'complete']: logger.info(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.") self.trace.event(name="terminating_tool_completed_during_streaming", level="DEFAULT", status_message=(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.")) agent_should_terminate = True - + # Save and Yield tool completed/failed status completed_msg_obj = await self._yield_and_save_tool_completed( context, None, thread_id, thread_run_id @@ -486,7 +486,7 @@ async def process_streaming_response( if finish_reason == "xml_tool_limit_reached": finish_content = {"status_type": "finish", "finish_reason": "xml_tool_limit_reached"} finish_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=finish_content, + thread_id=thread_id, type="status", content=finish_content, is_llm_message=False, metadata={"thread_run_id": thread_run_id} ) if finish_msg_obj: yield format_for_yield(finish_msg_obj) @@ -539,7 +539,7 @@ async def process_streaming_response( # Save and yield an error status err_content = {"role": "system", "status_type": "error", "message": "Failed to save final assistant message"} err_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=err_content, + thread_id=thread_id, type="status", content=err_content, is_llm_message=False, metadata={"thread_run_id": thread_run_id} ) if err_msg_obj: yield format_for_yield(err_msg_obj) @@ -673,7 +673,7 @@ async def process_streaming_response( if finish_reason and finish_reason != "xml_tool_limit_reached": finish_content = {"status_type": "finish", "finish_reason": finish_reason} finish_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=finish_content, + thread_id=thread_id, type="status", content=finish_content, is_llm_message=False, metadata={"thread_run_id": thread_run_id} ) if finish_msg_obj: yield format_for_yield(finish_msg_obj) @@ -682,18 +682,18 @@ async def process_streaming_response( if agent_should_terminate: logger.info("Agent termination requested after executing ask/complete tool. Stopping further processing.") self.trace.event(name="agent_termination_requested", level="DEFAULT", status_message="Agent termination requested after executing ask/complete tool. Stopping further processing.") - + # Set finish reason to indicate termination finish_reason = "agent_terminated" - + # Save and yield termination status finish_content = {"status_type": "finish", "finish_reason": "agent_terminated"} finish_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=finish_content, + thread_id=thread_id, type="status", content=finish_content, is_llm_message=False, metadata={"thread_run_id": thread_run_id} ) if finish_msg_obj: yield format_for_yield(finish_msg_obj) - + # Save assistant_response_end BEFORE terminating if last_assistant_message_object: try: @@ -708,7 +708,7 @@ async def process_streaming_response( streaming_metadata["usage"]["completion_tokens"] > 0 or streaming_metadata["usage"]["total_tokens"] > 0 ) - + assistant_end_content = { "choices": [ { @@ -726,11 +726,11 @@ async def process_streaming_response( "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", @@ -742,7 +742,7 @@ async def process_streaming_response( 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 @@ -760,7 +760,7 @@ async def process_streaming_response( streaming_metadata["usage"]["completion_tokens"] > 0 or streaming_metadata["usage"]["total_tokens"] > 0 ) - + assistant_end_content = { "choices": [ { @@ -778,11 +778,11 @@ async def process_streaming_response( "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", @@ -801,11 +801,11 @@ async def process_streaming_response( # Save and yield error status message err_content = {"role": "system", "status_type": "error", "message": str(e)} err_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=err_content, + thread_id=thread_id, type="status", content=err_content, is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} ) if err_msg_obj: yield format_for_yield(err_msg_obj) # Yield the saved error message - + # Re-raise the same exception (not a new one) to ensure proper error propagation logger.critical(f"Re-raising error to stop further processing: {str(e)}") self.trace.event(name="re_raising_error_to_stop_further_processing", level="ERROR", status_message=(f"Re-raising error to stop further processing: {str(e)}")) @@ -816,7 +816,7 @@ async def process_streaming_response( try: end_content = {"status_type": "thread_run_end"} end_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=end_content, + thread_id=thread_id, type="status", content=end_content, is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} ) if end_msg_obj: yield format_for_yield(end_msg_obj) @@ -833,14 +833,14 @@ async def process_non_streaming_response( config: ProcessorConfig = ProcessorConfig(), ) -> AsyncGenerator[Dict[str, Any], None]: """Process a non-streaming LLM response, handling tool calls and execution. - + Args: llm_response: Response from the LLM thread_id: ID of the conversation thread prompt_messages: List of messages sent to the LLM (the prompt) llm_model: The name of the LLM model used config: Configuration for parsing and execution - + Yields: Complete message objects matching the DB schema. """ @@ -918,7 +918,7 @@ async def process_non_streaming_response( self.trace.event(name="failed_to_save_non_streaming_assistant_message_for_thread", level="ERROR", status_message=(f"Failed to save non-streaming assistant message for thread {thread_id}")) err_content = {"role": "system", "status_type": "error", "message": "Failed to save assistant message"} err_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=err_content, + thread_id=thread_id, type="status", content=err_content, is_llm_message=False, metadata={"thread_run_id": thread_run_id} ) if err_msg_obj: yield format_for_yield(err_msg_obj) @@ -973,7 +973,7 @@ async def process_non_streaming_response( if finish_reason: finish_content = {"status_type": "finish", "finish_reason": finish_reason} finish_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=finish_content, + thread_id=thread_id, type="status", content=finish_content, is_llm_message=False, metadata={"thread_run_id": thread_run_id} ) if finish_msg_obj: yield format_for_yield(finish_msg_obj) @@ -1000,11 +1000,11 @@ async def process_non_streaming_response( # Save and yield error status err_content = {"role": "system", "status_type": "error", "message": str(e)} err_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=err_content, + thread_id=thread_id, type="status", content=err_content, is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} ) if err_msg_obj: yield format_for_yield(err_msg_obj) - + # Re-raise the same exception (not a new one) to ensure proper error propagation logger.critical(f"Re-raising error to stop further processing: {str(e)}") self.trace.event(name="re_raising_error_to_stop_further_processing", level="CRITICAL", status_message=(f"Re-raising error to stop further processing: {str(e)}")) @@ -1014,7 +1014,7 @@ async def process_non_streaming_response( # Save and Yield the final thread_run_end status end_content = {"status_type": "thread_run_end"} end_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=end_content, + thread_id=thread_id, type="status", content=end_content, is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} ) if end_msg_obj: yield format_for_yield(end_msg_obj) @@ -1024,30 +1024,30 @@ def _extract_tag_content(self, xml_chunk: str, tag_name: str) -> Tuple[Optional[ """Extract content between opening and closing tags, handling nested tags.""" start_tag = f'<{tag_name}' end_tag = f'' - + try: # Find start tag position start_pos = xml_chunk.find(start_tag) if start_pos == -1: return None, xml_chunk - + # Find end of opening tag tag_end = xml_chunk.find('>', start_pos) if tag_end == -1: return None, xml_chunk - + # Find matching closing tag content_start = tag_end + 1 nesting_level = 1 pos = content_start - + while nesting_level > 0 and pos < len(xml_chunk): next_start = xml_chunk.find(start_tag, pos) next_end = xml_chunk.find(end_tag, pos) - + if next_end == -1: return None, xml_chunk - + if next_start != -1 and next_start < next_end: nesting_level += 1 pos = next_start + len(start_tag) @@ -1059,9 +1059,9 @@ def _extract_tag_content(self, xml_chunk: str, tag_name: str) -> Tuple[Optional[ return content, remaining else: pos = next_end + len(end_tag) - + return None, xml_chunk - + except Exception as e: logger.error(f"Error extracting tag content: {e}") self.trace.event(name="error_extracting_tag_content", level="ERROR", status_message=(f"Error extracting tag content: {e}")) @@ -1076,7 +1076,7 @@ def _extract_attribute(self, opening_tag: str, attr_name: str) -> Optional[str]: fr"{attr_name}='([^']*)'", # Single quotes fr'{attr_name}=([^\s/>;]+)' # No quotes - fixed escape sequence ] - + for pattern in patterns: match = re.search(pattern, opening_tag) if match: @@ -1086,9 +1086,9 @@ def _extract_attribute(self, opening_tag: str, attr_name: str) -> Optional[str]: value = value.replace('<', '<').replace('>', '>') value = value.replace('&', '&') return value - + return None - + except Exception as e: logger.error(f"Error extracting attribute: {e}") self.trace.event(name="error_extracting_attribute", level="ERROR", status_message=(f"Error extracting attribute: {e}")) @@ -1098,31 +1098,31 @@ def _extract_xml_chunks(self, content: str) -> List[str]: """Extract complete XML chunks using start and end pattern matching.""" chunks = [] pos = 0 - + try: # First, look for new format blocks start_pattern = '' end_pattern = '' - + while pos < len(content): # Find the next function_calls block start_pos = content.find(start_pattern, pos) if start_pos == -1: break - + # Find the matching end tag end_pos = content.find(end_pattern, start_pos) if end_pos == -1: break - + # Extract the complete block including tags chunk_end = end_pos + len(end_pattern) chunk = content[start_pos:chunk_end] chunks.append(chunk) - + # Move position past this chunk pos = chunk_end - + # If no new format found, fall back to old format for backwards compatibility if not chunks: pos = 0 @@ -1130,33 +1130,33 @@ def _extract_xml_chunks(self, content: str) -> List[str]: # Find the next tool tag next_tag_start = -1 current_tag = None - + # Find the earliest occurrence of any registered tag for tag_name in self.tool_registry.xml_tools.keys(): start_pattern = f'<{tag_name}' tag_pos = content.find(start_pattern, pos) - + if tag_pos != -1 and (next_tag_start == -1 or tag_pos < next_tag_start): next_tag_start = tag_pos current_tag = tag_name - + if next_tag_start == -1 or not current_tag: break - + # Find the matching end tag end_pattern = f'' tag_stack = [] chunk_start = next_tag_start current_pos = next_tag_start - + while current_pos < len(content): # Look for next start or end tag of the same type next_start = content.find(f'<{current_tag}', current_pos + 1) next_end = content.find(end_pattern, current_pos) - + if next_end == -1: # No closing tag found break - + if next_start != -1 and next_start < next_end: # Found nested start tag tag_stack.append(next_start) @@ -1173,22 +1173,22 @@ def _extract_xml_chunks(self, content: str) -> List[str]: # Pop nested tag tag_stack.pop() current_pos = next_end + 1 - + if current_pos >= len(content): # Reached end without finding closing tag break - + pos = max(pos + 1, current_pos) - + except Exception as e: logger.error(f"Error extracting XML chunks: {e}") logger.error(f"Content was: {content}") self.trace.event(name="error_extracting_xml_chunks", level="ERROR", status_message=(f"Error extracting XML chunks: {e}"), metadata={"content": content}) - + return chunks def _parse_xml_tool_call(self, xml_chunk: str) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]: """Parse XML chunk into tool call format and return parsing details. - + Returns: Tuple of (tool_call, parsing_details) or None if parsing fails. - tool_call: Dict with 'function_name', 'xml_tag_name', 'arguments' @@ -1199,28 +1199,28 @@ def _parse_xml_tool_call(self, xml_chunk: str) -> Optional[Tuple[Dict[str, Any], if '' in xml_chunk and ']+)', xml_chunk) @@ -1228,26 +1228,26 @@ def _parse_xml_tool_call(self, xml_chunk: str) -> Optional[Tuple[Dict[str, Any], logger.error(f"No tag found in XML chunk: {xml_chunk}") self.trace.event(name="no_tag_found_in_xml_chunk", level="ERROR", status_message=(f"No tag found in XML chunk: {xml_chunk}")) return None - + # This is the XML tag as it appears in the text (e.g., "create-file") xml_tag_name = tag_match.group(1) logger.info(f"Found XML tag: {xml_tag_name}") self.trace.event(name="found_xml_tag", level="DEFAULT", status_message=(f"Found XML tag: {xml_tag_name}")) - + # Get tool info and schema from registry tool_info = self.tool_registry.get_xml_tool(xml_tag_name) if not tool_info or not tool_info['schema'].xml_schema: logger.error(f"No tool or schema found for tag: {xml_tag_name}") self.trace.event(name="no_tool_or_schema_found_for_tag", level="ERROR", status_message=(f"No tool or schema found for tag: {xml_tag_name}")) return None - + # This is the actual function name to call (e.g., "create_file") function_name = tool_info['method'] - + schema = tool_info['schema'].xml_schema params = {} remaining_chunk = xml_chunk - + # --- Store detailed parsing info --- parsing_details = { "attributes": {}, @@ -1257,7 +1257,7 @@ def _parse_xml_tool_call(self, xml_chunk: str) -> Optional[Tuple[Dict[str, Any], "raw_chunk": xml_chunk # Store the original chunk for reference } # --- - + # Process each mapping for mapping in schema.mappings: try: @@ -1269,7 +1269,7 @@ def _parse_xml_tool_call(self, xml_chunk: str) -> Optional[Tuple[Dict[str, Any], params[mapping.param_name] = value parsing_details["attributes"][mapping.param_name] = value # Store raw attribute # logger.info(f"Found attribute {mapping.param_name}: {value}") - + elif mapping.node_type == "element": # Extract element content content, remaining_chunk = self._extract_tag_content(remaining_chunk, mapping.path) @@ -1277,7 +1277,7 @@ def _parse_xml_tool_call(self, xml_chunk: str) -> Optional[Tuple[Dict[str, Any], params[mapping.param_name] = content.strip() parsing_details["elements"][mapping.param_name] = content.strip() # Store raw element content # logger.info(f"Found element {mapping.param_name}: {content.strip()}") - + elif mapping.node_type == "text": # Extract text content content, _ = self._extract_tag_content(remaining_chunk, xml_tag_name) @@ -1285,7 +1285,7 @@ def _parse_xml_tool_call(self, xml_chunk: str) -> Optional[Tuple[Dict[str, Any], params[mapping.param_name] = content.strip() parsing_details["text_content"] = content.strip() # Store raw text content # logger.info(f"Found text content for {mapping.param_name}: {content.strip()}") - + elif mapping.node_type == "content": # Extract root content content, _ = self._extract_tag_content(remaining_chunk, xml_tag_name) @@ -1293,7 +1293,7 @@ def _parse_xml_tool_call(self, xml_chunk: str) -> Optional[Tuple[Dict[str, Any], params[mapping.param_name] = content.strip() parsing_details["root_content"] = content.strip() # Store raw root content # logger.info(f"Found root content for {mapping.param_name}") - + except Exception as e: logger.error(f"Error processing mapping {mapping}: {e}") self.trace.event(name="error_processing_mapping", level="ERROR", status_message=(f"Error processing mapping {mapping}: {e}")) @@ -1305,10 +1305,10 @@ def _parse_xml_tool_call(self, xml_chunk: str) -> Optional[Tuple[Dict[str, Any], "xml_tag_name": xml_tag_name, # The original XML tag (e.g., create-file) "arguments": params # The extracted parameters } - + # logger.debug(f"Parsed old format tool call: {tool_call["function_name"]}") return tool_call, parsing_details # Return both dicts - + except Exception as e: logger.error(f"Error parsing XML chunk: {e}") logger.error(f"XML chunk was: {xml_chunk}") @@ -1317,15 +1317,15 @@ def _parse_xml_tool_call(self, xml_chunk: str) -> Optional[Tuple[Dict[str, Any], def _parse_xml_tool_calls(self, content: str) -> List[Dict[str, Any]]: """Parse XML tool calls from content string. - + Returns: List of dictionaries, each containing {'tool_call': ..., 'parsing_details': ...} """ parsed_data = [] - + try: xml_chunks = self._extract_xml_chunks(content) - + for xml_chunk in xml_chunks: result = self._parse_xml_tool_call(xml_chunk) if result: @@ -1334,40 +1334,40 @@ def _parse_xml_tool_calls(self, content: str) -> List[Dict[str, Any]]: "tool_call": tool_call, "parsing_details": parsing_details }) - + except Exception as e: logger.error(f"Error parsing XML tool calls: {e}", exc_info=True) self.trace.event(name="error_parsing_xml_tool_calls", level="ERROR", status_message=(f"Error parsing XML tool calls: {e}"), metadata={"content": content}) - + return parsed_data # Tool execution methods async def _execute_tool(self, tool_call: Dict[str, Any]) -> ToolResult: """Execute a single tool call and return the result.""" - span = self.trace.span(name=f"execute_tool.{tool_call['function_name']}", input=tool_call["arguments"]) + span = self.trace.span(name=f"execute_tool.{tool_call['function_name']}", input=tool_call["arguments"]) try: function_name = tool_call["function_name"] arguments = tool_call["arguments"] logger.info(f"Executing tool: {function_name} with arguments: {arguments}") self.trace.event(name="executing_tool", level="DEFAULT", status_message=(f"Executing tool: {function_name} with arguments: {arguments}")) - + if isinstance(arguments, str): try: arguments = safe_json_parse(arguments) except json.JSONDecodeError: arguments = {"text": arguments} - + # Get available functions from tool registry available_functions = self.tool_registry.get_available_functions() - + # Look up the function by name tool_fn = available_functions.get(function_name) if not tool_fn: logger.error(f"Tool function '{function_name}' not found in registry") span.end(status_message="tool_not_found", level="ERROR") return ToolResult(success=False, output=f"Tool function '{function_name}' not found") - + logger.debug(f"Found tool function for '{function_name}', executing...") result = await tool_fn(**arguments) logger.info(f"Tool execution complete: {function_name} -> {result}") @@ -1379,27 +1379,27 @@ async def _execute_tool(self, tool_call: Dict[str, Any]) -> ToolResult: return ToolResult(success=False, output=f"Error executing tool: {str(e)}") async def _execute_tools( - self, - tool_calls: List[Dict[str, Any]], + self, + tool_calls: List[Dict[str, Any]], execution_strategy: ToolExecutionStrategy = "sequential" ) -> List[Tuple[Dict[str, Any], ToolResult]]: """Execute tool calls with the specified strategy. - + This is the main entry point for tool execution. It dispatches to the appropriate execution method based on the provided strategy. - + Args: tool_calls: List of tool calls to execute execution_strategy: Strategy for executing tools: - "sequential": Execute tools one after another, waiting for each to complete - - "parallel": Execute all tools simultaneously for better performance - + - "parallel": Execute all tools simultaneously for better performance + Returns: List of tuples containing the original tool call and its result """ logger.info(f"Executing {len(tool_calls)} tools with strategy: {execution_strategy}") self.trace.event(name="executing_tools_with_strategy", level="DEFAULT", status_message=(f"Executing {len(tool_calls)} tools with strategy: {execution_strategy}")) - + if execution_strategy == "sequential": return await self._execute_tools_sequentially(tool_calls) elif execution_strategy == "parallel": @@ -1410,88 +1410,88 @@ async def _execute_tools( async def _execute_tools_sequentially(self, tool_calls: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], ToolResult]]: """Execute tool calls sequentially and return results. - + This method executes tool calls one after another, waiting for each tool to complete before starting the next one. This is useful when tools have dependencies on each other. - + Args: tool_calls: List of tool calls to execute - + Returns: List of tuples containing the original tool call and its result """ if not tool_calls: return [] - + try: tool_names = [t.get('function_name', 'unknown') for t in tool_calls] logger.info(f"Executing {len(tool_calls)} tools sequentially: {tool_names}") self.trace.event(name="executing_tools_sequentially", level="DEFAULT", status_message=(f"Executing {len(tool_calls)} tools sequentially: {tool_names}")) - + results = [] for index, tool_call in enumerate(tool_calls): tool_name = tool_call.get('function_name', 'unknown') logger.debug(f"Executing tool {index+1}/{len(tool_calls)}: {tool_name}") - + try: result = await self._execute_tool(tool_call) results.append((tool_call, result)) logger.debug(f"Completed tool {tool_name} with success={result.success}") - + # Check if this is a terminating tool (ask or complete) if tool_name in ['ask', 'complete']: logger.info(f"Terminating tool '{tool_name}' executed. Stopping further tool execution.") self.trace.event(name="terminating_tool_executed", level="DEFAULT", status_message=(f"Terminating tool '{tool_name}' executed. Stopping further tool execution.")) break # Stop executing remaining tools - + except Exception as e: logger.error(f"Error executing tool {tool_name}: {str(e)}") self.trace.event(name="error_executing_tool", level="ERROR", status_message=(f"Error executing tool {tool_name}: {str(e)}")) error_result = ToolResult(success=False, output=f"Error executing tool: {str(e)}") results.append((tool_call, error_result)) - + logger.info(f"Sequential execution completed for {len(results)} tools (out of {len(tool_calls)} total)") self.trace.event(name="sequential_execution_completed", level="DEFAULT", status_message=(f"Sequential execution completed for {len(results)} tools (out of {len(tool_calls)} total)")) return results - + except Exception as e: logger.error(f"Error in sequential tool execution: {str(e)}", exc_info=True) # Return partial results plus error results for remaining tools completed_tool_names = [r[0].get('function_name', 'unknown') for r in results] if 'results' in locals() else [] remaining_tools = [t for t in tool_calls if t.get('function_name', 'unknown') not in completed_tool_names] - + # Add error results for remaining tools - error_results = [(tool, ToolResult(success=False, output=f"Execution error: {str(e)}")) + error_results = [(tool, ToolResult(success=False, output=f"Execution error: {str(e)}")) for tool in remaining_tools] - + return (results if 'results' in locals() else []) + error_results async def _execute_tools_in_parallel(self, tool_calls: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], ToolResult]]: """Execute tool calls in parallel and return results. - + This method executes all tool calls simultaneously using asyncio.gather, which can significantly improve performance when executing multiple independent tools. - + Args: tool_calls: List of tool calls to execute - + Returns: List of tuples containing the original tool call and its result """ if not tool_calls: return [] - + try: tool_names = [t.get('function_name', 'unknown') for t in tool_calls] logger.info(f"Executing {len(tool_calls)} tools in parallel: {tool_names}") self.trace.event(name="executing_tools_in_parallel", level="DEFAULT", status_message=(f"Executing {len(tool_calls)} tools in parallel: {tool_names}")) - + # Create tasks for all tool calls tasks = [self._execute_tool(tool_call) for tool_call in tool_calls] - + # Execute all tasks concurrently with error handling results = await asyncio.gather(*tasks, return_exceptions=True) - + # Process results and handle any exceptions processed_results = [] for i, (tool_call, result) in enumerate(zip(tool_calls, results)): @@ -1503,34 +1503,34 @@ async def _execute_tools_in_parallel(self, tool_calls: List[Dict[str, Any]]) -> processed_results.append((tool_call, error_result)) else: processed_results.append((tool_call, result)) - + logger.info(f"Parallel execution completed for {len(tool_calls)} tools") self.trace.event(name="parallel_execution_completed", level="DEFAULT", status_message=(f"Parallel execution completed for {len(tool_calls)} tools")) return processed_results - + except Exception as e: logger.error(f"Error in parallel tool execution: {str(e)}", exc_info=True) self.trace.event(name="error_in_parallel_tool_execution", level="ERROR", status_message=(f"Error in parallel tool execution: {str(e)}")) # Return error results for all tools if the gather itself fails - return [(tool_call, ToolResult(success=False, output=f"Execution error: {str(e)}")) + return [(tool_call, ToolResult(success=False, output=f"Execution error: {str(e)}")) for tool_call in tool_calls] async def _add_tool_result( - self, - thread_id: str, - tool_call: Dict[str, Any], + self, + thread_id: str, + tool_call: Dict[str, Any], result: ToolResult, strategy: Union[XmlAddingStrategy, str] = "assistant_message", assistant_message_id: Optional[str] = None, parsing_details: Optional[Dict[str, Any]] = None ) -> Optional[Dict[str, Any]]: # Return the full message object """Add a tool result to the conversation thread based on the specified format. - + This method formats tool results and adds them to the conversation history, - making them visible to the LLM in subsequent interactions. Results can be + making them visible to the LLM in subsequent interactions. Results can be added either as native tool messages (OpenAI format) or as XML-wrapped content with a specified role (user or assistant). - + Args: thread_id: ID of the conversation thread tool_call: The original tool call that produced this result @@ -1542,26 +1542,26 @@ async def _add_tool_result( """ try: message_obj = None # Initialize message_obj - + # Create metadata with assistant_message_id if provided metadata = {} if assistant_message_id: metadata["assistant_message_id"] = assistant_message_id logger.info(f"Linking tool result to assistant message: {assistant_message_id}") self.trace.event(name="linking_tool_result_to_assistant_message", level="DEFAULT", status_message=(f"Linking tool result to assistant message: {assistant_message_id}")) - + # --- Add parsing details to metadata if available --- if parsing_details: metadata["parsing_details"] = parsing_details logger.info("Adding parsing_details to tool result metadata") self.trace.event(name="adding_parsing_details_to_tool_result_metadata", level="DEFAULT", status_message=(f"Adding parsing_details to tool result metadata"), metadata={"parsing_details": parsing_details}) # --- - + # Check if this is a native function call (has id field) if "id" in tool_call: # Format as a proper tool message according to OpenAI spec function_name = tool_call.get("function_name", "") - + # Format the tool result content - tool role needs string content if isinstance(result, str): content = result @@ -1576,10 +1576,10 @@ async def _add_tool_result( else: # Fallback to string representation of the whole result content = str(result) - + logger.info(f"Formatted tool result content: {content[:100]}...") self.trace.event(name="formatted_tool_result_content", level="DEFAULT", status_message=(f"Formatted tool result content: {content[:100]}...")) - + # Create the tool response message with proper format tool_message = { "role": "tool", @@ -1587,10 +1587,10 @@ async def _add_tool_result( "name": function_name, "content": content } - + logger.info(f"Adding native tool result for tool_call_id={tool_call['id']} with role=tool") self.trace.event(name="adding_native_tool_result_for_tool_call_id", level="DEFAULT", status_message=(f"Adding native tool result for tool_call_id={tool_call['id']} with role=tool")) - + # Add as a tool message to the conversation history # This makes the result visible to the LLM in the next turn message_obj = await self.add_message( @@ -1601,10 +1601,10 @@ async def _add_tool_result( metadata=metadata ) return message_obj # Return the full message object - + # Check if this is an MCP tool (function_name starts with "call_mcp_tool") function_name = tool_call.get("function_name", "") - + # Check if this is an MCP tool - either the old call_mcp_tool or a dynamically registered MCP tool is_mcp_tool = False if function_name == "call_mcp_tool": @@ -1618,42 +1618,42 @@ async def _add_tool_result( # Also check for MCP metadata in JSON format elif "mcp_metadata" in result.output: is_mcp_tool = True - + if is_mcp_tool: # Special handling for MCP tools - make content prominent and LLM-friendly result_role = "user" if strategy == "user_message" else "assistant" - + # Extract the actual content from the ToolResult if hasattr(result, 'output'): mcp_content = str(result.output) else: mcp_content = str(result) - + # Create a simple, LLM-friendly message format that puts content first simple_message = { "role": result_role, "content": mcp_content # Direct content, no complex nesting } - + logger.info(f"Adding MCP tool result with simplified format for LLM visibility") self.trace.event(name="adding_mcp_tool_result_simplified", level="DEFAULT", status_message="Adding MCP tool result with simplified format for LLM visibility") - + message_obj = await self.add_message( - thread_id=thread_id, + thread_id=thread_id, type="tool", content=simple_message, is_llm_message=True, metadata=metadata ) return message_obj - + # For XML and other non-native tools, use the new structured format # Determine message role based on strategy result_role = "user" if strategy == "user_message" else "assistant" - + # Create the new structured tool result format structured_result = self._create_structured_tool_result(tool_call, result, parsing_details) - + # Add the message with the appropriate role to the conversation history # This allows the LLM to see the tool result in subsequent interactions result_message = { @@ -1661,7 +1661,7 @@ async def _add_tool_result( "content": json.dumps(structured_result) } message_obj = await self.add_message( - thread_id=thread_id, + thread_id=thread_id, type="tool", content=result_message, is_llm_message=True, @@ -1678,8 +1678,8 @@ async def _add_tool_result( "content": str(result) } message_obj = await self.add_message( - thread_id=thread_id, - type="tool", + thread_id=thread_id, + type="tool", content=fallback_message, is_llm_message=True, metadata={"assistant_message_id": assistant_message_id} if assistant_message_id else {} @@ -1692,12 +1692,12 @@ async def _add_tool_result( def _create_structured_tool_result(self, tool_call: Dict[str, Any], result: ToolResult, parsing_details: Optional[Dict[str, Any]] = None): """Create a structured tool result format that's tool-agnostic and provides rich information. - + Args: tool_call: The original tool call that was executed result: The result from the tool execution parsing_details: Optional parsing details for XML calls - + Returns: Structured dictionary containing tool execution information """ @@ -1707,7 +1707,7 @@ def _create_structured_tool_result(self, tool_call: Dict[str, Any], result: Tool arguments = tool_call.get("arguments", {}) tool_call_id = tool_call.get("id") logger.info(f"Creating structured tool result for tool_call: {tool_call}") - + # Process the output - if it's a JSON string, parse it back to an object output = result.output if hasattr(result, 'output') else str(result) if isinstance(output, str): @@ -1721,7 +1721,7 @@ def _create_structured_tool_result(self, tool_call: Dict[str, Any], result: Tool except Exception: # If parsing fails, keep the original string pass - + # Create the structured result structured_result_v1 = { "tool_execution": { @@ -1739,22 +1739,22 @@ def _create_structured_tool_result(self, tool_call: Dict[str, Any], result: Tool # "parsing_details": parsing_details # } } - } + } # STRUCTURED_OUTPUT_TOOLS = { - # "str_replace", + # "str_replace", # "get_data_provider_endpoints", # } # summary_output = result.output if hasattr(result, 'output') else str(result) - + # if xml_tag_name: # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" # summary = f"Tool '{xml_tag_name}' {status}. Output: {summary_output}" # else: # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" # summary = f"Function '{function_name}' {status}. Output: {summary_output}" - + # if self.is_agent_builder: # return summary # if function_name in STRUCTURED_OUTPUT_TOOLS: @@ -1764,7 +1764,7 @@ def _create_structured_tool_result(self, tool_call: Dict[str, Any], result: Tool summary_output = result.output if hasattr(result, 'output') else str(result) success_status = structured_result_v1["tool_execution"]["result"]["success"] - + # # Create a more comprehensive summary for the LLM # if xml_tag_name: # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" @@ -1772,18 +1772,18 @@ def _create_structured_tool_result(self, tool_call: Dict[str, Any], result: Tool # else: # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" # summary = f"Function '{function_name}' {status}. Output: {summary_output}" - + # if self.is_agent_builder: # return summary # elif function_name == "get_data_provider_endpoints": # logger.info(f"Returning sumnary for data provider call: {summary}") # return summary - + return structured_result_v1 def _format_xml_tool_result(self, tool_call: Dict[str, Any], result: ToolResult) -> str: """Format a tool result wrapped in a tag. - + DEPRECATED: This method is kept for backwards compatibility. New implementations should use _create_structured_tool_result instead. @@ -1798,7 +1798,7 @@ def _format_xml_tool_result(self, tool_call: Dict[str, Any], result: ToolResult) if "xml_tag_name" in tool_call: xml_tag_name = tool_call["xml_tag_name"] return f" <{xml_tag_name}> {str(result)} " - + # Non-XML tool, just return the function result function_name = tool_call["function_name"] return f"Result for {function_name}: {str(result)}" @@ -1811,7 +1811,7 @@ def _create_tool_context(self, tool_call: Dict[str, Any], tool_index: int, assis assistant_message_id=assistant_message_id, parsing_details=parsing_details ) - + # Set function_name and xml_tag_name fields if "xml_tag_name" in tool_call: context.xml_tag_name = tool_call["xml_tag_name"] @@ -1820,9 +1820,9 @@ def _create_tool_context(self, tool_call: Dict[str, Any], tool_index: int, assis # For non-XML tools, use function name directly context.function_name = tool_call.get("function_name", "unknown") context.xml_tag_name = None - + return context - + async def _yield_and_save_tool_started(self, context: ToolExecutionContext, thread_id: str, thread_run_id: str) -> Optional[Dict[str, Any]]: """Formats, saves, and returns a tool started status message.""" tool_name = context.xml_tag_name or context.function_name @@ -1858,7 +1858,7 @@ async def _yield_and_save_tool_completed(self, context: ToolExecutionContext, to # Add the *actual* tool result message ID to the metadata if available and successful if context.result.success and tool_message_id: metadata["linked_tool_result_message_id"] = tool_message_id - + # <<< ADDED: Signal if this is a terminating tool >>> if context.function_name in ['ask', 'complete']: metadata["agent_should_terminate"] = True diff --git a/app/agentpress/thread_manager.py b/app/agentpress/thread_manager.py index 2eb963af6..addc5716b 100644 --- a/app/agentpress/thread_manager.py +++ b/app/agentpress/thread_manager.py @@ -12,18 +12,18 @@ import json from typing import List, Dict, Any, Optional, Type, Union, AsyncGenerator, Literal -from services.llm import make_llm_api_call -from agentpress.tool import Tool -from agentpress.tool_registry import ToolRegistry -from agentpress.context_manager import ContextManager -from agentpress.response_processor import ( +from app.services.llm import make_llm_api_call +from app.agentpress.tool import Tool +from app.agentpress.tool_registry import ToolRegistry +from app.agentpress.context_manager import ContextManager +from app.agentpress.response_processor import ( ResponseProcessor, ProcessorConfig ) -from services.supabase import DBConnection -from utils.logger import logger +from app.services.supabase import DBConnection +from app.utils.logger import logger from langfuse.client import StatefulGenerationClient, StatefulTraceClient -from services.langfuse import langfuse +from app.services.langfuse import langfuse import datetime from litellm import token_counter @@ -80,7 +80,7 @@ def _is_tool_result_message(self, msg: Dict[str, Any]) -> bool: except (json.JSONDecodeError, TypeError): pass return False - + def _compress_message(self, msg_content: Union[str, dict], message_id: Optional[str] = None, max_length: int = 3000) -> Union[str, dict]: """Compress the message content.""" # print("max_length", max_length) @@ -94,7 +94,7 @@ def _compress_message(self, msg_content: Union[str, dict], message_id: Optional[ return json.dumps(msg_content)[:max_length] + "... (truncated)" + f"\n\nmessage_id \"{message_id}\"\nUse expand-message tool to see contents" else: return msg_content - + def _safe_truncate(self, msg_content: Union[str, dict], max_length: int = 100000) -> Union[str, dict]: """Truncate the message content safely by removing the middle portion.""" max_length = min(max_length, 100000) @@ -104,10 +104,10 @@ def _safe_truncate(self, msg_content: Union[str, dict], max_length: int = 100000 keep_length = max_length - 150 # Reserve space for truncation message start_length = keep_length // 2 end_length = keep_length - start_length - + start_part = msg_content[:start_length] end_part = msg_content[-end_length:] if end_length > 0 else "" - + return start_part + f"\n\n... (middle truncated) ...\n\n" + end_part + f"\n\nThis message is too long, repeat relevant information in your response to remember it" else: return msg_content @@ -118,14 +118,14 @@ def _safe_truncate(self, msg_content: Union[str, dict], max_length: int = 100000 keep_length = max_length - 150 # Reserve space for truncation message start_length = keep_length // 2 end_length = keep_length - start_length - + start_part = json_str[:start_length] end_part = json_str[-end_length:] if end_length > 0 else "" - + return start_part + f"\n\n... (middle truncated) ...\n\n" + end_part + f"\n\nThis message is too long, repeat relevant information in your response to remember it" else: return msg_content - + def _compress_tool_result_messages(self, messages: List[Dict[str, Any]], llm_model: str, max_tokens: Optional[int], token_threshold: Optional[int] = 1000) -> List[Dict[str, Any]]: """Compress the tool result messages except the most recent one.""" uncompressed_total_token_count = token_counter(model=llm_model, messages=messages) @@ -186,7 +186,7 @@ def _compress_assistant_messages(self, messages: List[Dict[str, Any]], llm_model logger.warning(f"UNEXPECTED: Message has no message_id {str(msg)[:100]}") else: msg["content"] = self._safe_truncate(msg["content"], int(max_tokens * 2)) - + return messages @@ -254,17 +254,17 @@ def _compress_messages(self, messages: List[Dict[str, Any]], llm_model: str, max result = self._compress_messages(messages, llm_model, max_tokens, int(token_threshold / 2), max_iterations - 1) return self._middle_out_messages(result) - + def _compress_messages_by_omitting_messages( - self, - messages: List[Dict[str, Any]], - llm_model: str, + self, + messages: List[Dict[str, Any]], + llm_model: str, max_tokens: Optional[int] = 41000, removal_batch_size: int = 10, min_messages_to_keep: int = 10 ) -> List[Dict[str, Any]]: """Compress the messages by omitting messages from the middle. - + Args: messages: List of messages to compress llm_model: Model name for token counting @@ -274,27 +274,27 @@ def _compress_messages_by_omitting_messages( """ if not messages: return messages - + result = messages result = self._remove_meta_messages(result) # Early exit if no compression needed initial_token_count = token_counter(model=llm_model, messages=result) max_allowed_tokens = max_tokens or (100 * 1000) - + if initial_token_count <= max_allowed_tokens: return result # Separate system message (assumed to be first) from conversation messages system_message = messages[0] if messages and messages[0].get('role') == 'system' else None conversation_messages = result[1:] if system_message else result - + safety_limit = 500 current_token_count = initial_token_count - + while current_token_count > max_allowed_tokens and safety_limit > 0: safety_limit -= 1 - + if len(conversation_messages) <= min_messages_to_keep: logger.warning(f"Cannot compress further: only {len(conversation_messages)} messages remain (min: {min_messages_to_keep})") break @@ -321,20 +321,20 @@ def _compress_messages_by_omitting_messages( # Prepare final result final_messages = ([system_message] + conversation_messages) if system_message else conversation_messages final_token_count = token_counter(model=llm_model, messages=final_messages) - + logger.info(f"_compress_messages_by_omitting_messages: {initial_token_count} -> {final_token_count} tokens ({len(messages)} -> {len(final_messages)} messages)") - + return final_messages - + def _middle_out_messages(self, messages: List[Dict[str, Any]], max_messages: int = 320) -> List[Dict[str, Any]]: """Remove messages from the middle of the list, keeping max_messages total.""" if len(messages) <= max_messages: return messages - + # Keep half from the beginning and half from the end keep_start = max_messages // 2 keep_end = max_messages - keep_start - + return messages[:keep_start] + messages[-keep_end:] @@ -377,7 +377,7 @@ async def add_message( 'is_llm_message': is_llm_message, 'metadata': metadata or {}, } - + # Add agent information if provided if agent_id: data_to_insert['agent_id'] = agent_id @@ -415,26 +415,26 @@ async def get_llm_messages(self, thread_id: str) -> List[Dict[str, Any]]: try: # result = await client.rpc('get_llm_formatted_messages', {'p_thread_id': thread_id}).execute() - + # Fetch messages in batches of 1000 to avoid overloading the database all_messages = [] batch_size = 1000 offset = 0 - + while True: result = await client.table('messages').select('message_id, content').eq('thread_id', thread_id).eq('is_llm_message', True).order('created_at').range(offset, offset + batch_size - 1).execute() - + if not result.data or len(result.data) == 0: break - + all_messages.extend(result.data) - + # If we got fewer than batch_size records, we've reached the end if len(result.data) < batch_size: break - + offset += batch_size - + # Use all_messages instead of result.data in the rest of the method result_data = all_messages diff --git a/app/agentpress/tool.py b/app/agentpress/tool.py index de7a50458..8bcb90247 100644 --- a/app/agentpress/tool.py +++ b/app/agentpress/tool.py @@ -13,7 +13,7 @@ import json import inspect from enum import Enum -from utils.logger import logger +from app.utils.logger import logger class SchemaType(Enum): """Enumeration of supported schema types for tool definitions.""" @@ -24,7 +24,7 @@ class SchemaType(Enum): @dataclass class XMLNodeMapping: """Maps an XML node to a function parameter. - + Attributes: param_name (str): Name of the function parameter node_type (str): Type of node ("element", "attribute", or "content") @@ -39,22 +39,22 @@ class XMLNodeMapping: @dataclass class XMLTagSchema: """Schema definition for XML tool tags. - + Attributes: tag_name (str): Root tag name for the tool mappings (List[XMLNodeMapping]): Parameter mappings for the tag example (str, optional): Example showing tag usage - + Methods: add_mapping: Add a new parameter mapping to the schema """ tag_name: str mappings: List[XMLNodeMapping] = field(default_factory=list) example: Optional[str] = None - + def add_mapping(self, param_name: str, node_type: str = "element", path: str = ".", required: bool = True) -> None: """Add a new node mapping to the schema. - + Args: param_name: Name of the function parameter node_type: Type of node ("element", "attribute", or "content") @@ -63,7 +63,7 @@ def add_mapping(self, param_name: str, node_type: str = "element", path: str = " """ self.mappings.append(XMLNodeMapping( param_name=param_name, - node_type=node_type, + node_type=node_type, path=path, required=required )) @@ -72,7 +72,7 @@ def add_mapping(self, param_name: str, node_type: str = "element", path: str = " @dataclass class ToolSchema: """Container for tool schemas with type information. - + Attributes: schema_type (SchemaType): Type of schema (OpenAPI, XML, or Custom) schema (Dict[str, Any]): The actual schema definition @@ -85,7 +85,7 @@ class ToolSchema: @dataclass class ToolResult: """Container for tool execution results. - + Attributes: success (bool): Whether the tool execution succeeded output (str): Output message or error description @@ -95,19 +95,19 @@ class ToolResult: class Tool(ABC): """Abstract base class for all tools. - + Provides the foundation for implementing tools with schema registration and result handling capabilities. - + Attributes: _schemas (Dict[str, List[ToolSchema]]): Registered schemas for tool methods - + Methods: get_schemas: Get all registered tool schemas success_response: Create a successful result fail_response: Create a failed result """ - + def __init__(self): """Initialize tool with empty schema registry.""" self._schemas: Dict[str, List[ToolSchema]] = {} @@ -123,7 +123,7 @@ def _register_schemas(self): def get_schemas(self) -> Dict[str, List[ToolSchema]]: """Get all registered tool schemas. - + Returns: Dict mapping method names to their schema definitions """ @@ -131,10 +131,10 @@ def get_schemas(self) -> Dict[str, List[ToolSchema]]: def success_response(self, data: Union[Dict[str, Any], str]) -> ToolResult: """Create a successful tool result. - + Args: data: Result data (dictionary or string) - + Returns: ToolResult with success=True and formatted output """ @@ -147,10 +147,10 @@ def success_response(self, data: Union[Dict[str, Any], str]) -> ToolResult: def fail_response(self, msg: str) -> ToolResult: """Create a failed tool result. - + Args: msg: Error message describing the failure - + Returns: ToolResult with success=False and error message """ @@ -182,16 +182,16 @@ def xml_schema( ): """ Decorator for XML schema tools with improved node mapping. - + Args: tag_name: Name of the root XML tag mappings: List of mapping definitions, each containing: - param_name: Name of the function parameter - - node_type: "element", "attribute", or "content" + - node_type: "element", "attribute", or "content" - path: Path to the node (default "." for root) - required: Whether the parameter is required (default True) example: Optional example showing how to use the XML tag - + Example: @xml_schema( tag_name="str-replace", @@ -211,7 +211,7 @@ def xml_schema( def decorator(func): logger.debug(f"Applying XML schema with tag '{tag_name}' to function {func.__name__}") xml_schema = XMLTagSchema(tag_name=tag_name, example=example) - + # Add mappings if mappings: for mapping in mappings: @@ -221,7 +221,7 @@ def decorator(func): path=mapping.get("path", "."), required=mapping.get("required", True) ) - + return _add_schema(func, ToolSchema( schema_type=SchemaType.XML, schema={}, # OpenAPI schema could be added here if needed diff --git a/app/agentpress/tool_registry.py b/app/agentpress/tool_registry.py index b50438a18..481410b6e 100644 --- a/app/agentpress/tool_registry.py +++ b/app/agentpress/tool_registry.py @@ -1,18 +1,18 @@ from typing import Dict, Type, Any, List, Optional, Callable -from agentpress.tool import Tool, SchemaType -from utils.logger import logger +from app.agentpress.tool import Tool, SchemaType +from app.utils.logger import logger class ToolRegistry: """Registry for managing and accessing tools. - + Maintains a collection of tool instances and their schemas, allowing for selective registration of tool functions and easy access to tool capabilities. - + Attributes: tools (Dict[str, Dict[str, Any]]): OpenAPI-style tools and schemas xml_tools (Dict[str, Dict[str, Any]]): XML-style tools and schemas - + Methods: register_tool: Register a tool with optional function filtering get_tool: Get a specific tool by name @@ -20,21 +20,21 @@ class ToolRegistry: get_openapi_schemas: Get OpenAPI schemas for function calling get_xml_examples: Get examples of XML tool usage """ - + def __init__(self): """Initialize a new ToolRegistry instance.""" self.tools = {} self.xml_tools = {} logger.debug("Initialized new ToolRegistry instance") - + def register_tool(self, tool_class: Type[Tool], function_names: Optional[List[str]] = None, **kwargs): """Register a tool with optional function filtering. - + Args: tool_class: The tool class to register function_names: Optional list of specific functions to register **kwargs: Additional arguments passed to tool initialization - + Notes: - If function_names is None, all functions are registered - Handles both OpenAPI and XML schema registration @@ -42,12 +42,12 @@ def register_tool(self, tool_class: Type[Tool], function_names: Optional[List[st logger.debug(f"Registering tool class: {tool_class.__name__}") tool_instance = tool_class(**kwargs) schemas = tool_instance.get_schemas() - + logger.debug(f"Available schemas for {tool_class.__name__}: {list(schemas.keys())}") - + registered_openapi = 0 registered_xml = 0 - + for func_name, schema_list in schemas.items(): if function_names is None or func_name in function_names: for schema in schema_list: @@ -58,7 +58,7 @@ def register_tool(self, tool_class: Type[Tool], function_names: Optional[List[st } registered_openapi += 1 logger.debug(f"Registered OpenAPI function {func_name} from {tool_class.__name__}") - + if schema.schema_type == SchemaType.XML and schema.xml_schema: self.xml_tools[schema.xml_schema.tag_name] = { "instance": tool_instance, @@ -67,40 +67,40 @@ def register_tool(self, tool_class: Type[Tool], function_names: Optional[List[st } registered_xml += 1 logger.debug(f"Registered XML tag {schema.xml_schema.tag_name} -> {func_name} from {tool_class.__name__}") - + logger.debug(f"Tool registration complete for {tool_class.__name__}: {registered_openapi} OpenAPI functions, {registered_xml} XML tags") def get_available_functions(self) -> Dict[str, Callable]: """Get all available tool functions. - + Returns: Dict mapping function names to their implementations """ available_functions = {} - + # Get OpenAPI tool functions for tool_name, tool_info in self.tools.items(): tool_instance = tool_info['instance'] function_name = tool_name function = getattr(tool_instance, function_name) available_functions[function_name] = function - + # Get XML tool functions for tag_name, tool_info in self.xml_tools.items(): tool_instance = tool_info['instance'] method_name = tool_info['method'] function = getattr(tool_instance, method_name) available_functions[method_name] = function - + logger.debug(f"Retrieved {len(available_functions)} available functions") return available_functions def get_tool(self, tool_name: str) -> Dict[str, Any]: """Get a specific tool by name. - + Args: tool_name: Name of the tool function - + Returns: Dict containing tool instance and schema, or empty dict if not found """ @@ -111,10 +111,10 @@ def get_tool(self, tool_name: str) -> Dict[str, Any]: def get_xml_tool(self, tag_name: str) -> Dict[str, Any]: """Get tool info by XML tag name. - + Args: tag_name: XML tag name for the tool - + Returns: Dict containing tool instance, method name, and schema """ @@ -125,12 +125,12 @@ def get_xml_tool(self, tag_name: str) -> Dict[str, Any]: def get_openapi_schemas(self) -> List[Dict[str, Any]]: """Get OpenAPI schemas for function calling. - + Returns: List of OpenAPI-compatible schema definitions """ schemas = [ - tool_info['schema'].schema + tool_info['schema'].schema for tool_info in self.tools.values() if tool_info['schema'].schema_type == SchemaType.OPENAPI ] @@ -139,7 +139,7 @@ def get_openapi_schemas(self) -> List[Dict[str, Any]]: def get_xml_examples(self) -> Dict[str, str]: """Get all XML tag examples. - + Returns: Dict mapping tag names to their example usage """ diff --git a/app/config.py b/app/config.py index 217b516e4..1702be8ce 100644 --- a/app/config.py +++ b/app/config.py @@ -105,6 +105,13 @@ class SandboxSettings(BaseModel): ) +class DaytonaSettings(BaseModel): + daytona_api_key: str + daytona_server_url: Optional[str] = Field("https://app.daytona.io/api", description="") + daytona_target: Optional[str] = Field("asia", description="enum ['asia', 'eu', 'us']") + sandbox_image_name: Optional[str]= Field("kortix/suna:0.1.3", description="") + sandbox_entrypoint: Optional[str]= Field("/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf", description="") + class MCPServerConfig(BaseModel): """Configuration for a single MCP server""" @@ -167,6 +174,9 @@ class AppConfig(BaseModel): run_flow_config: Optional[RunflowSettings] = Field( None, description="Run flow configuration" ) + daytona_config: Optional[DaytonaSettings] = Field( + None, description="Daytona configuration" + ) class Config: arbitrary_types_allowed = True @@ -268,6 +278,11 @@ def _load_initial_config(self): sandbox_settings = SandboxSettings(**sandbox_config) else: sandbox_settings = SandboxSettings() + daytona_config = raw_config.get("daytona", {}) + if daytona_config: + daytona_settings = DaytonaSettings(**daytona_config) + else: + daytona_settings = DaytonaSettings() mcp_config = raw_config.get("mcp", {}) mcp_settings = None @@ -296,6 +311,7 @@ def _load_initial_config(self): "search_config": search_settings, "mcp_config": mcp_settings, "run_flow_config": run_flow_settings, + "daytona_config": daytona_settings, } self._config = AppConfig(**config_dict) @@ -307,6 +323,9 @@ def llm(self) -> Dict[str, LLMSettings]: @property def sandbox(self) -> SandboxSettings: return self._config.sandbox + @property + def daytona(self) -> DaytonaSettings: + return self._config.daytona_config @property def browser_config(self) -> Optional[BrowserSettings]: diff --git a/app/daytona/requirements.txt b/app/daytona/requirements.txt new file mode 100644 index 000000000..b2a148607 --- /dev/null +++ b/app/daytona/requirements.txt @@ -0,0 +1,3 @@ +structlog +daytona +litellm diff --git a/app/daytona/sandbox.py b/app/daytona/sandbox.py index cc9a3b01d..51372940a 100644 --- a/app/daytona/sandbox.py +++ b/app/daytona/sandbox.py @@ -1,16 +1,17 @@ -from daytona_sdk import Daytona, DaytonaConfig, CreateSandboxFromImageParams, Sandbox, SessionExecuteRequest, Resources, SandboxState +from daytona import Daytona, DaytonaConfig, CreateSandboxFromImageParams, Sandbox, SessionExecuteRequest, Resources, SandboxState from dotenv import load_dotenv -from utils.logger import logger -from utils.config import config -from utils.config import Configuration - -load_dotenv() - +from app.utils.logger import logger +# from app.utils.config import config +# from utils.config import config +# from app.utils.config import Configuration +from app.config import config +# load_dotenv() +daytona_settings=config.daytona logger.debug("Initializing Daytona sandbox configuration") daytona_config = DaytonaConfig( - api_key=config.DAYTONA_API_KEY, - server_url=config.DAYTONA_SERVER_URL, - target=config.DAYTONA_TARGET + api_key=daytona_settings.daytona_api_key, + server_url=daytona_settings.daytona_server_url, + target=daytona_settings.daytona_target ) if daytona_config.api_key: @@ -33,12 +34,12 @@ async def get_or_start_sandbox(sandbox_id: str): """Retrieve a sandbox by ID, check its state, and start it if needed.""" - + logger.info(f"Getting or starting sandbox with ID: {sandbox_id}") - + try: sandbox = daytona.get(sandbox_id) - + # Check if sandbox needs to be started if sandbox.state == SandboxState.ARCHIVED or sandbox.state == SandboxState.STOPPED: logger.info(f"Sandbox is in {sandbox.state} state. Starting...") @@ -48,16 +49,16 @@ async def get_or_start_sandbox(sandbox_id: str): # sleep(5) # Refresh sandbox state after starting sandbox = daytona.get(sandbox_id) - + # Start supervisord in a session when restarting start_supervisord_session(sandbox) except Exception as e: logger.error(f"Error starting sandbox: {e}") raise e - + logger.info(f"Sandbox {sandbox_id} is ready") return sandbox - + except Exception as e: logger.error(f"Error retrieving or starting sandbox: {str(e)}") raise e @@ -68,7 +69,7 @@ def start_supervisord_session(sandbox: Sandbox): try: logger.info(f"Creating session {session_id} for supervisord") sandbox.process.create_session(session_id) - + # Execute supervisord command sandbox.process.execute_session_command(session_id, SessionExecuteRequest( command="exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf", @@ -81,17 +82,17 @@ def start_supervisord_session(sandbox: Sandbox): def create_sandbox(password: str, project_id: str = None): """Create a new sandbox with all required services configured and running.""" - + logger.debug("Creating new Daytona sandbox environment") logger.debug("Configuring sandbox with browser-use image and environment variables") - + labels = None if project_id: logger.debug(f"Using sandbox_id as label: {project_id}") labels = {'id': project_id} - + params = CreateSandboxFromImageParams( - image=Configuration.SANDBOX_IMAGE_NAME, + image=daytona_settings.sandbox_image_name, public=True, labels=labels, env_vars={ @@ -115,30 +116,30 @@ def create_sandbox(password: str, project_id: str = None): auto_stop_interval=15, auto_archive_interval=24 * 60, ) - + # Create the sandbox sandbox = daytona.create(params) logger.debug(f"Sandbox created with ID: {sandbox.id}") - + # Start supervisord in a session for new sandbox start_supervisord_session(sandbox) - + logger.debug(f"Sandbox environment successfully initialized") return sandbox async def delete_sandbox(sandbox_id: str): """Delete a sandbox by its ID.""" logger.info(f"Deleting sandbox with ID: {sandbox_id}") - + try: # Get the sandbox sandbox = daytona.get(sandbox_id) - + # Delete the sandbox daytona.delete(sandbox) - + logger.info(f"Successfully deleted sandbox {sandbox_id}") return True except Exception as e: logger.error(f"Error deleting sandbox {sandbox_id}: {str(e)}") - raise e \ No newline at end of file + raise e diff --git a/app/daytona/tool_base.py b/app/daytona/tool_base.py index 70cb02dfe..6390c5421 100644 --- a/app/daytona/tool_base.py +++ b/app/daytona/tool_base.py @@ -1,72 +1,77 @@ -from typing import Optional - -from agentpress.thread_manager import ThreadManager +from typing import Optional,ClassVar +from pydantic import Field +# from app.agentpress.thread_manager import ThreadManager from app.tool.base import BaseTool, ToolResult -from daytona_sdk import Sandbox -from daytona.sandbox import get_or_start_sandbox -from utils.logger import logger -from utils.files_utils import clean_path +from daytona import Sandbox +from app.daytona.sandbox import get_or_start_sandbox +from app.utils.logger import logger +from app.utils.files_utils import clean_path class SandboxToolsBase(BaseTool): """Base class for all sandbox tools that provides project-based sandbox access.""" # Class variable to track if sandbox URLs have been printed - _urls_printed = False - - def __init__(self, project_id: str, thread_manager: Optional[ThreadManager] = None): - super().__init__() - self.project_id = project_id - self.thread_manager = thread_manager - self.workspace_path = "/workspace" - self._sandbox = None - self._sandbox_id = None - self._sandbox_pass = None - - async def _ensure_sandbox(self) -> Sandbox: - """Ensure we have a valid sandbox instance, retrieving it from the project if needed.""" - if self._sandbox is None: - try: - # Get database client - client = await self.thread_manager.db.client - - # Get project data - project = await client.table('projects').select('*').eq('project_id', self.project_id).execute() - if not project.data or len(project.data) == 0: - raise ValueError(f"Project {self.project_id} not found") - - project_data = project.data[0] - sandbox_info = project_data.get('sandbox', {}) - - if not sandbox_info.get('id'): - raise ValueError(f"No sandbox found for project {self.project_id}") - - # Store sandbox info - self._sandbox_id = sandbox_info['id'] - self._sandbox_pass = sandbox_info.get('pass') - - # Get or start the sandbox - self._sandbox = await get_or_start_sandbox(self._sandbox_id) - - # # Log URLs if not already printed - # if not SandboxToolsBase._urls_printed: - # vnc_link = self._sandbox.get_preview_link(6080) - # website_link = self._sandbox.get_preview_link(8080) - - # vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link) - # website_url = website_link.url if hasattr(website_link, 'url') else str(website_link) - - # print("\033[95m***") - # print(f"VNC URL: {vnc_url}") - # print(f"Website URL: {website_url}") - # print("***\033[0m") - # SandboxToolsBase._urls_printed = True - - except Exception as e: - logger.error(f"Error retrieving sandbox for project {self.project_id}: {str(e)}", exc_info=True) - raise e - - return self._sandbox + _urls_printed: ClassVar[bool] = False + + # Required fields + project_id: Optional[str] = None + # thread_manager: Optional[ThreadManager] = None + + # Private fields (not part of the model schema) + _sandbox: Optional[Sandbox] = None + _sandbox_id: Optional[str] = None + _sandbox_pass: Optional[str] = None + workspace_path: str = Field(default="/workspace", exclude=True) + + class Config: + arbitrary_types_allowed = True # Allow non-pydantic types like ThreadManager + underscore_attrs_are_private = True + + # async def _ensure_sandbox(self) -> Sandbox: + # """Ensure we have a valid sandbox instance, retrieving it from the project if needed.""" + # if self._sandbox is None: + # try: + # # Get database client + # client = await self.thread_manager.db.client + + # # Get project data + # project = await client.table('projects').select('*').eq('project_id', self.project_id).execute() + # if not project.data or len(project.data) == 0: + # raise ValueError(f"Project {self.project_id} not found") + + # project_data = project.data[0] + # sandbox_info = project_data.get('sandbox', {}) + + # if not sandbox_info.get('id'): + # raise ValueError(f"No sandbox found for project {self.project_id}") + + # # Store sandbox info + # self._sandbox_id = sandbox_info['id'] + # self._sandbox_pass = sandbox_info.get('pass') + + # # Get or start the sandbox + # self._sandbox = await get_or_start_sandbox(self._sandbox_id) + + # # # Log URLs if not already printed + # # if not SandboxToolsBase._urls_printed: + # # vnc_link = self._sandbox.get_preview_link(6080) + # # website_link = self._sandbox.get_preview_link(8080) + + # # vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link) + # # website_url = website_link.url if hasattr(website_link, 'url') else str(website_link) + + # # print("\033[95m***") + # # print(f"VNC URL: {vnc_url}") + # # print(f"Website URL: {website_url}") + # # print("***\033[0m") + # # SandboxToolsBase._urls_printed = True + + # except Exception as e: + # logger.error(f"Error retrieving sandbox for project {self.project_id}: {str(e)}", exc_info=True) + # raise e + + # return self._sandbox @property def sandbox(self) -> Sandbox: diff --git a/app/tool/base.py b/app/tool/base.py index f9574c11a..bcd9f0012 100644 --- a/app/tool/base.py +++ b/app/tool/base.py @@ -3,7 +3,7 @@ from typing import Any, Dict, Optional, Union import inspect import json -from utils.logger import logger +from app.utils.logger import logger # class BaseTool(ABC, BaseModel): # name: str @@ -33,6 +33,46 @@ # } + +class ToolResult(BaseModel): + """Represents the result of a tool execution.""" + + output: Any = Field(default=None) + error: Optional[str] = Field(default=None) + base64_image: Optional[str] = Field(default=None) + system: Optional[str] = Field(default=None) + + class Config: + arbitrary_types_allowed = True + + def __bool__(self): + return any(getattr(self, field) for field in self.__fields__) + + def __add__(self, other: "ToolResult"): + def combine_fields( + field: Optional[str], other_field: Optional[str], concatenate: bool = True + ): + if field and other_field: + if concatenate: + return field + other_field + raise ValueError("Cannot combine tool results") + return field or other_field + + return ToolResult( + output=combine_fields(self.output, other.output), + error=combine_fields(self.error, other.error), + base64_image=combine_fields(self.base64_image, other.base64_image, False), + system=combine_fields(self.system, other.system), + ) + + def __str__(self): + return f"Error: {self.error}" if self.error else self.output + + def replace(self, **kwargs): + """Returns a new ToolResult with the given fields replaced.""" + # return self.copy(update=kwargs) + return type(self)(**{**self.dict(), **kwargs}) + class BaseTool(ABC, BaseModel): """Consolidated base class for all tools combining BaseModel and Tool functionality. @@ -129,48 +169,6 @@ def fail_response(self, msg: str) -> ToolResult: """ logger.debug(f"Tool {self.__class__.__name__} returned failed result: {msg}") return ToolResult(error=msg) - - -class ToolResult(BaseModel): - """Represents the result of a tool execution.""" - - output: Any = Field(default=None) - error: Optional[str] = Field(default=None) - base64_image: Optional[str] = Field(default=None) - system: Optional[str] = Field(default=None) - - class Config: - arbitrary_types_allowed = True - - def __bool__(self): - return any(getattr(self, field) for field in self.__fields__) - - def __add__(self, other: "ToolResult"): - def combine_fields( - field: Optional[str], other_field: Optional[str], concatenate: bool = True - ): - if field and other_field: - if concatenate: - return field + other_field - raise ValueError("Cannot combine tool results") - return field or other_field - - return ToolResult( - output=combine_fields(self.output, other.output), - error=combine_fields(self.error, other.error), - base64_image=combine_fields(self.base64_image, other.base64_image, False), - system=combine_fields(self.system, other.system), - ) - - def __str__(self): - return f"Error: {self.error}" if self.error else self.output - - def replace(self, **kwargs): - """Returns a new ToolResult with the given fields replaced.""" - # return self.copy(update=kwargs) - return type(self)(**{**self.dict(), **kwargs}) - - class CLIResult(ToolResult): """A ToolResult that can be rendered as a CLI output.""" diff --git a/app/tool/computer_use_tool.py b/app/tool/computer_use_tool.py index 4b9cef1c0..c71ec3111 100644 --- a/app/tool/computer_use_tool.py +++ b/app/tool/computer_use_tool.py @@ -4,9 +4,9 @@ import aiohttp import asyncio import logging -from typing import Optional, Dict +from typing import Optional, Dict,Literal import os - +from pydantic import Field # from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema from app.daytona.tool_base import SandboxToolsBase, Sandbox from app.tool.base import ToolResult @@ -775,7 +775,19 @@ class ComputerUseTool(SandboxToolsBase): mouse_x: int = Field(default=0, exclude=True) mouse_y: int = Field(default=0, exclude=True) api_base_url: Optional[str] = Field(default=None, exclude=True) - sandbox: Optional[Sandbox] = Field(default=None, exclude=True) + def __init__(self, sandbox: Optional[Sandbox] = None, **data): + """Initialize with optional sandbox.""" + super().__init__(**data) + if sandbox is not None: + self._sandbox = sandbox # 直接操作基类的私有属性 + self.api_base_url = sandbox.get_preview_link(8000) + logging.info(f"Initialized ComputerUseTool with API URL: {self.api_base_url}") + + @classmethod + def create_with_sandbox(cls, sandbox: Sandbox) -> "ComputerUseTool": + """Factory method to create a tool with sandbox.""" + return cls(sandbox=sandbox) # 通过构造函数初始化 + async def _get_session(self) -> aiohttp.ClientSession: """Get or create aiohttp session for API requests.""" if self.session is None or self.session.closed: @@ -1008,21 +1020,31 @@ async def cleanup(self): if self.session and not self.session.closed: await self.session.close() self.session = None + def __del__(self): - """Ensure cleanup when object is destroyed.""" - if self.session is not None: + """Ensure cleanup on destruction.""" + if hasattr(self, 'session') and self.session is not None: try: asyncio.run(self.cleanup()) except RuntimeError: loop = asyncio.new_event_loop() loop.run_until_complete(self.cleanup()) loop.close() - @classmethod - def create_with_sandbox(cls, sandbox: Sandbox) -> "ComputerUseTool": - """Factory method to create a ComputerUseTool with a sandbox connection.""" - tool = cls() - tool.sandbox = sandbox - tool.api_base_url = sandbox.get_preview_link(8000) - logging.info(f"Initialized Computer Use Tool with API URL: {tool.api_base_url}") - return tool + # def __del__(self): + # """Ensure cleanup when object is destroyed.""" + # if self.session is not None: + # try: + # asyncio.run(self.cleanup()) + # except RuntimeError: + # loop = asyncio.new_event_loop() + # loop.run_until_complete(self.cleanup()) + # loop.close() + # @classmethod + # def create_with_sandbox(cls, sandbox: Sandbox) -> "ComputerUseTool": + # """Factory method to create a ComputerUseTool with a sandbox connection.""" + # tool = cls() + # tool.sandbox = sandbox + # tool.api_base_url = sandbox.get_preview_link(8000) + # logging.info(f"Initialized Computer Use Tool with API URL: {tool.api_base_url}") + # return tool diff --git a/app/tool/data_providers/ActiveJobsProvider.py b/app/tool/data_providers/ActiveJobsProvider.py deleted file mode 100644 index 0b09aae17..000000000 --- a/app/tool/data_providers/ActiveJobsProvider.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import Dict - -from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema - - -class ActiveJobsProvider(RapidDataProviderBase): - def __init__(self): - endpoints: Dict[str, EndpointSchema] = { - "active_jobs": { - "route": "/active-ats-7d", - "method": "GET", - "name": "Active Jobs Search", - "description": "Get active job listings with various filter options.", - "payload": { - "limit": "Optional. Number of jobs per API call (10-100). Default is 100.", - "offset": "Optional. Offset for pagination. Default is 0.", - "title_filter": "Optional. Search terms for job title.", - "advanced_title_filter": "Optional. Advanced title filter with operators (can't be used with title_filter).", - "location_filter": "Optional. Filter by location(s). Use full names like 'United States' not 'US'.", - "description_filter": "Optional. Filter on job description content.", - "organization_filter": "Optional. Filter by company name(s).", - "description_type": "Optional. Return format for description: 'text' or 'html'. Leave empty to exclude descriptions.", - "source": "Optional. Filter by ATS source.", - "date_filter": "Optional. Filter by posting date (greater than).", - "ai_employment_type_filter": "Optional. Filter by employment type (FULL_TIME, PART_TIME, etc).", - "ai_work_arrangement_filter": "Optional. Filter by work arrangement (On-site, Hybrid, Remote OK, Remote Solely).", - "ai_experience_level_filter": "Optional. Filter by experience level (0-2, 2-5, 5-10, 10+).", - "li_organization_slug_filter": "Optional. Filter by LinkedIn company slug.", - "li_organization_slug_exclusion_filter": "Optional. Exclude LinkedIn company slugs.", - "li_industry_filter": "Optional. Filter by LinkedIn industry.", - "li_organization_specialties_filter": "Optional. Filter by LinkedIn company specialties.", - "li_organization_description_filter": "Optional. Filter by LinkedIn company description." - } - } - } - - base_url = "https://active-jobs-db.p.rapidapi.com" - super().__init__(base_url, endpoints) - - -if __name__ == "__main__": - from dotenv import load_dotenv - load_dotenv() - tool = ActiveJobsProvider() - - # Example for searching active jobs - jobs = tool.call_endpoint( - route="active_jobs", - payload={ - "limit": "10", - "offset": "0", - "title_filter": "\"Data Engineer\"", - "location_filter": "\"United States\" OR \"United Kingdom\"", - "description_type": "text" - } - ) - print("Active Jobs:", jobs) \ No newline at end of file diff --git a/app/tool/data_providers/AmazonProvider.py b/app/tool/data_providers/AmazonProvider.py deleted file mode 100644 index b29720893..000000000 --- a/app/tool/data_providers/AmazonProvider.py +++ /dev/null @@ -1,191 +0,0 @@ -from typing import Dict - -from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema - - -class AmazonProvider(RapidDataProviderBase): - def __init__(self): - endpoints: Dict[str, EndpointSchema] = { - "search": { - "route": "/search", - "method": "GET", - "name": "Amazon Product Search", - "description": "Search for products on Amazon with various filters and parameters.", - "payload": { - "query": "Search query (supports both free-form text queries or a product asin)", - "page": "Results page to return (default: 1)", - "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", - "sort_by": "Return the results in a specific sort order (RELEVANCE, LOWEST_PRICE, HIGHEST_PRICE, REVIEWS, NEWEST, BEST_SELLERS)", - "product_condition": "Return products in a specific condition (ALL, NEW, USED, RENEWED, COLLECTIBLE)", - "is_prime": "Only return prime products (boolean)", - "deals_and_discounts": "Return deals and discounts in a specific condition (NONE, ALL_DISCOUNTS, TODAYS_DEALS)", - "category_id": "Find products in a specific category / department (optional)", - "category": "Filter by specific numeric Amazon category (optional)", - "min_price": "Only return product offers with price greater than a certain value (optional)", - "max_price": "Only return product offers with price lower than a certain value (optional)", - "brand": "Find products with a specific brand (optional)", - "seller_id": "Find products sold by specific seller (optional)", - "four_stars_and_up": "Return product listings with ratings of 4 stars & up (optional)", - "additional_filters": "Any filters available on the Amazon page but not part of this endpoint's parameters (optional)" - } - }, - "product-details": { - "route": "/product-details", - "method": "GET", - "name": "Amazon Product Details", - "description": "Get detailed information about specific Amazon products by ASIN.", - "payload": { - "asin": "Product ASIN for which to get details. Supports batching of up to 10 ASINs in a single request, separated by comma.", - "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", - "more_info_query": "A query to search and get more info about the product as part of Product Information, Customer Q&As, and Customer Reviews (optional)", - "fields": "A comma separated list of product fields to include in the response (field projection). By default all fields are returned. (optional)" - } - }, - "products-by-category": { - "route": "/products-by-category", - "method": "GET", - "name": "Amazon Products by Category", - "description": "Get products from a specific Amazon category.", - "payload": { - "category_id": "The Amazon category for which to return results. Multiple category values can be separated by comma.", - "page": "Page to return (default: 1)", - "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", - "sort_by": "Return the results in a specific sort order (RELEVANCE, LOWEST_PRICE, HIGHEST_PRICE, REVIEWS, NEWEST, BEST_SELLERS)", - "min_price": "Only return product offers with price greater than a certain value (optional)", - "max_price": "Only return product offers with price lower than a certain value (optional)", - "product_condition": "Return products in a specific condition (ALL, NEW, USED, RENEWED, COLLECTIBLE)", - "brand": "Only return products of a specific brand. Multiple brands can be specified as a comma separated list (optional)", - "is_prime": "Only return prime products (boolean)", - "deals_and_discounts": "Return deals and discounts in a specific condition (NONE, ALL_DISCOUNTS, TODAYS_DEALS)", - "four_stars_and_up": "Return product listings with ratings of 4 stars & up (optional)", - "additional_filters": "Any filters available on the Amazon page but not part of this endpoint's parameters (optional)" - } - }, - "product-reviews": { - "route": "/product-reviews", - "method": "GET", - "name": "Amazon Product Reviews", - "description": "Get customer reviews for a specific Amazon product by ASIN.", - "payload": { - "asin": "Product asin for which to get reviews.", - "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", - "page": "Results page to return (default: 1)", - "sort_by": "Return reviews in a specific sort order (TOP_REVIEWS, MOST_RECENT)", - "star_rating": "Only return reviews with a specific star rating (ALL, 5_STARS, 4_STARS, 3_STARS, 2_STARS, 1_STARS, POSITIVE, CRITICAL)", - "verified_purchases_only": "Only return reviews by reviewers who made a verified purchase (boolean)", - "images_or_videos_only": "Only return reviews containing images and / or videos (boolean)", - "current_format_only": "Only return reviews of the current format (product variant - e.g. Color) (boolean)" - } - }, - "seller-profile": { - "route": "/seller-profile", - "method": "GET", - "name": "Amazon Seller Profile", - "description": "Get detailed information about a specific Amazon seller by Seller ID.", - "payload": { - "seller_id": "The Amazon Seller ID for which to get seller profile details", - "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", - "fields": "A comma separated list of seller profile fields to include in the response (field projection). By default all fields are returned. (optional)" - } - }, - "seller-reviews": { - "route": "/seller-reviews", - "method": "GET", - "name": "Amazon Seller Reviews", - "description": "Get customer reviews for a specific Amazon seller by Seller ID.", - "payload": { - "seller_id": "The Amazon Seller ID for which to get seller reviews", - "country": "Sets the Amazon domain, marketplace country, language and currency (default: US)", - "star_rating": "Only return reviews with a specific star rating or positive / negative sentiment (ALL, 5_STARS, 4_STARS, 3_STARS, 2_STARS, 1_STARS, POSITIVE, CRITICAL)", - "page": "The page of seller feedback results to retrieve (default: 1)", - "fields": "A comma separated list of seller review fields to include in the response (field projection). By default all fields are returned. (optional)" - } - } - } - base_url = "https://real-time-amazon-data.p.rapidapi.com" - super().__init__(base_url, endpoints) - - -if __name__ == "__main__": - from dotenv import load_dotenv - load_dotenv() - tool = AmazonProvider() - - # Example for product search - search_result = tool.call_endpoint( - route="search", - payload={ - "query": "Phone", - "page": 1, - "country": "US", - "sort_by": "RELEVANCE", - "product_condition": "ALL", - "is_prime": False, - "deals_and_discounts": "NONE" - } - ) - print("Search Result:", search_result) - - # Example for product details - details_result = tool.call_endpoint( - route="product-details", - payload={ - "asin": "B07ZPKBL9V", - "country": "US" - } - ) - print("Product Details:", details_result) - - # Example for products by category - category_result = tool.call_endpoint( - route="products-by-category", - payload={ - "category_id": "2478868012", - "page": 1, - "country": "US", - "sort_by": "RELEVANCE", - "product_condition": "ALL", - "is_prime": False, - "deals_and_discounts": "NONE" - } - ) - print("Category Products:", category_result) - - # Example for product reviews - reviews_result = tool.call_endpoint( - route="product-reviews", - payload={ - "asin": "B07ZPKN6YR", - "country": "US", - "page": 1, - "sort_by": "TOP_REVIEWS", - "star_rating": "ALL", - "verified_purchases_only": False, - "images_or_videos_only": False, - "current_format_only": False - } - ) - print("Product Reviews:", reviews_result) - - # Example for seller profile - seller_result = tool.call_endpoint( - route="seller-profile", - payload={ - "seller_id": "A02211013Q5HP3OMSZC7W", - "country": "US" - } - ) - print("Seller Profile:", seller_result) - - # Example for seller reviews - seller_reviews_result = tool.call_endpoint( - route="seller-reviews", - payload={ - "seller_id": "A02211013Q5HP3OMSZC7W", - "country": "US", - "star_rating": "ALL", - "page": 1 - } - ) - print("Seller Reviews:", seller_reviews_result) - diff --git a/app/tool/data_providers/LinkedinProvider.py b/app/tool/data_providers/LinkedinProvider.py deleted file mode 100644 index 6a70e21ac..000000000 --- a/app/tool/data_providers/LinkedinProvider.py +++ /dev/null @@ -1,250 +0,0 @@ -from typing import Dict - -from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema - - -class LinkedinProvider(RapidDataProviderBase): - def __init__(self): - endpoints: Dict[str, EndpointSchema] = { - "person": { - "route": "/person", - "method": "POST", - "name": "Person Data", - "description": "Fetches any Linkedin profiles data including skills, certificates, experiences, qualifications and much more.", - "payload": { - "link": "LinkedIn Profile URL" - } - }, - "person_urn": { - "route": "/person_urn", - "method": "POST", - "name": "Person Data (Using Urn)", - "description": "It takes profile urn instead of profile public identifier in input", - "payload": { - "link": "LinkedIn Profile URL or URN" - } - }, - "person_deep": { - "route": "/person_deep", - "method": "POST", - "name": "Person Data (Deep)", - "description": "Fetches all experiences, educations, skills, languages, publications... related to a profile.", - "payload": { - "link": "LinkedIn Profile URL" - } - }, - "profile_updates": { - "route": "/profile_updates", - "method": "GET", - "name": "Person Posts (WITH PAGINATION)", - "description": "Fetches posts of a linkedin profile alongwith reactions, comments, postLink and reposts data.", - "payload": { - "profile_url": "LinkedIn Profile URL", - "page": "Page number", - "reposts": "Include reposts (1 or 0)", - "comments": "Include comments (1 or 0)" - } - }, - "profile_recent_comments": { - "route": "/profile_recent_comments", - "method": "POST", - "name": "Person Recent Activity (Comments on Posts)", - "description": "Fetches 20 most recent comments posted by a linkedin user (per page).", - "payload": { - "profile_url": "LinkedIn Profile URL", - "page": "Page number", - "paginationToken": "Token for pagination" - } - }, - "comments_from_recent_activity": { - "route": "/comments_from_recent_activity", - "method": "GET", - "name": "Comments from recent activity", - "description": "Fetches recent comments posted by a person as per his recent activity tab.", - "payload": { - "profile_url": "LinkedIn Profile URL", - "page": "Page number" - } - }, - "person_skills": { - "route": "/person_skills", - "method": "POST", - "name": "Person Skills", - "description": "Scraper all skills of a linkedin user", - "payload": { - "link": "LinkedIn Profile URL" - } - }, - "email_to_linkedin_profile": { - "route": "/email_to_linkedin_profile", - "method": "POST", - "name": "Email to LinkedIn Profile", - "description": "Finds LinkedIn profile associated with an email address", - "payload": { - "email": "Email address to search" - } - }, - "company": { - "route": "/company", - "method": "POST", - "name": "Company Data", - "description": "Fetches LinkedIn company profile data", - "payload": { - "link": "LinkedIn Company URL" - } - }, - "web_domain": { - "route": "/web-domain", - "method": "POST", - "name": "Web Domain to Company", - "description": "Fetches LinkedIn company profile data from a web domain", - "payload": { - "link": "Website domain (e.g., huzzle.app)" - } - }, - "similar_profiles": { - "route": "/similar_profiles", - "method": "GET", - "name": "Similar Profiles", - "description": "Fetches profiles similar to a given LinkedIn profile", - "payload": { - "profileUrl": "LinkedIn Profile URL" - } - }, - "company_jobs": { - "route": "/company_jobs", - "method": "POST", - "name": "Company Jobs", - "description": "Fetches job listings from a LinkedIn company page", - "payload": { - "company_url": "LinkedIn Company URL", - "count": "Number of job listings to fetch" - } - }, - "company_updates": { - "route": "/company_updates", - "method": "GET", - "name": "Company Posts", - "description": "Fetches posts from a LinkedIn company page", - "payload": { - "company_url": "LinkedIn Company URL", - "page": "Page number", - "reposts": "Include reposts (0, 1, or 2)", - "comments": "Include comments (0, 1, or 2)" - } - }, - "company_employee": { - "route": "/company_employee", - "method": "GET", - "name": "Company Employees", - "description": "Fetches employees of a LinkedIn company using company ID", - "payload": { - "companyId": "LinkedIn Company ID", - "page": "Page number" - } - }, - "company_updates_post": { - "route": "/company_updates", - "method": "POST", - "name": "Company Posts (POST)", - "description": "Fetches posts from a LinkedIn company page with specific count parameters", - "payload": { - "company_url": "LinkedIn Company URL", - "posts": "Number of posts to fetch", - "comments": "Number of comments to fetch per post", - "reposts": "Number of reposts to fetch" - } - }, - "search_posts_with_filters": { - "route": "/search_posts_with_filters", - "method": "GET", - "name": "Search Posts With Filters", - "description": "Searches LinkedIn posts with various filtering options", - "payload": { - "query": "Keywords/Search terms (text you put in LinkedIn search bar)", - "page": "Page number (1-100, each page contains 20 results)", - "sort_by": "Sort method: 'relevance' (Top match) or 'date_posted' (Latest)", - "author_job_title": "Filter by job title of author (e.g., CEO)", - "content_type": "Type of content post contains (photos, videos, liveVideos, collaborativeArticles, documents)", - "from_member": "URN of person who posted (comma-separated for multiple)", - "from_organization": "ID of organization who posted (comma-separated for multiple)", - "author_company": "ID of company author works for (comma-separated for multiple)", - "author_industry": "URN of industry author is connected with (comma-separated for multiple)", - "mentions_member": "URN of person mentioned in post (comma-separated for multiple)", - "mentions_organization": "ID of organization mentioned in post (comma-separated for multiple)" - } - }, - "search_jobs": { - "route": "/search_jobs", - "method": "GET", - "name": "Search Jobs", - "description": "Searches LinkedIn jobs with various filtering options", - "payload": { - "query": "Job search keywords (e.g., Software developer)", - "page": "Page number", - "searchLocationId": "Location ID for job search (get from Suggestion location endpoint)", - "easyApply": "Filter for easy apply jobs (true or false)", - "experience": "Experience level required (1=Internship, 2=Entry level, 3=Associate, 4=Mid senior, 5=Director, 6=Executive, comma-separated)", - "jobType": "Job type (F=Full time, P=Part time, C=Contract, T=Temporary, V=Volunteer, I=Internship, O=Other, comma-separated)", - "postedAgo": "Time jobs were posted in seconds (e.g., 3600 for past hour)", - "workplaceType": "Workplace type (1=On-Site, 2=Remote, 3=Hybrid, comma-separated)", - "sortBy": "Sort method (DD=most recent, R=most relevant)", - "companyIdsList": "List of company IDs, comma-separated", - "industryIdsList": "List of industry IDs, comma-separated", - "functionIdsList": "List of function IDs, comma-separated", - "titleIdsList": "List of job title IDs, comma-separated", - "locationIdsList": "List of location IDs within specified searchLocationId country, comma-separated" - } - }, - "search_people_with_filters": { - "route": "/search_people_with_filters", - "method": "POST", - "name": "Search People With Filters", - "description": "Searches LinkedIn profiles with detailed filtering options", - "payload": { - "keyword": "General search keyword", - "page": "Page number", - "title_free_text": "Job title to filter by (e.g., CEO)", - "company_free_text": "Company name to filter by", - "first_name": "First name of person", - "last_name": "Last name of person", - "current_company_list": "List of current companies (comma-separated IDs)", - "past_company_list": "List of past companies (comma-separated IDs)", - "location_list": "List of locations (comma-separated IDs)", - "language_list": "List of languages (comma-separated)", - "service_catagory_list": "List of service categories (comma-separated)", - "school_free_text": "School name to filter by", - "industry_list": "List of industries (comma-separated IDs)", - "school_list": "List of schools (comma-separated IDs)" - } - }, - "search_company_with_filters": { - "route": "/search_company_with_filters", - "method": "POST", - "name": "Search Company With Filters", - "description": "Searches LinkedIn companies with detailed filtering options", - "payload": { - "keyword": "General search keyword", - "page": "Page number", - "company_size_list": "List of company sizes (comma-separated, e.g., A,D)", - "hasJobs": "Filter companies with jobs (true or false)", - "location_list": "List of location IDs (comma-separated)", - "industry_list": "List of industry IDs (comma-separated)" - } - } - } - base_url = "https://linkedin-data-scraper.p.rapidapi.com" - super().__init__(base_url, endpoints) - - -if __name__ == "__main__": - from dotenv import load_dotenv - load_dotenv() - tool = LinkedinProvider() - - result = tool.call_endpoint( - route="comments_from_recent_activity", - payload={"profile_url": "https://www.linkedin.com/in/adamcohenhillel/", "page": 1} - ) - print(result) - diff --git a/app/tool/data_providers/RapidDataProviderBase.py b/app/tool/data_providers/RapidDataProviderBase.py deleted file mode 100644 index 5b7ccd664..000000000 --- a/app/tool/data_providers/RapidDataProviderBase.py +++ /dev/null @@ -1,61 +0,0 @@ -import os -import requests -from typing import Dict, Any, Optional, TypedDict, Literal - - -class EndpointSchema(TypedDict): - route: str - method: Literal['GET', 'POST'] - name: str - description: str - payload: Dict[str, Any] - - -class RapidDataProviderBase: - def __init__(self, base_url: str, endpoints: Dict[str, EndpointSchema]): - self.base_url = base_url - self.endpoints = endpoints - - def get_endpoints(self): - return self.endpoints - - def call_endpoint( - self, - route: str, - payload: Optional[Dict[str, Any]] = None - ): - """ - Call an API endpoint with the given parameters and data. - - Args: - endpoint (EndpointSchema): The endpoint configuration dictionary - params (dict, optional): Query parameters for GET requests - payload (dict, optional): JSON payload for POST requests - - Returns: - dict: The JSON response from the API - """ - if route.startswith("/"): - route = route[1:] - - endpoint = self.endpoints.get(route) - if not endpoint: - raise ValueError(f"Endpoint {route} not found") - - url = f"{self.base_url}{endpoint['route']}" - - headers = { - "x-rapidapi-key": os.getenv("RAPID_API_KEY"), - "x-rapidapi-host": url.split("//")[1].split("/")[0], - "Content-Type": "application/json" - } - - method = endpoint.get('method', 'GET').upper() - - if method == 'GET': - response = requests.get(url, params=payload, headers=headers) - elif method == 'POST': - response = requests.post(url, json=payload, headers=headers) - else: - raise ValueError(f"Unsupported HTTP method: {method}") - return response.json() diff --git a/app/tool/data_providers/TwitterProvider.py b/app/tool/data_providers/TwitterProvider.py deleted file mode 100644 index df6358ebf..000000000 --- a/app/tool/data_providers/TwitterProvider.py +++ /dev/null @@ -1,240 +0,0 @@ -from typing import Dict - -from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema - - -class TwitterProvider(RapidDataProviderBase): - def __init__(self): - endpoints: Dict[str, EndpointSchema] = { - "user_info": { - "route": "/screenname.php", - "method": "GET", - "name": "Twitter User Info", - "description": "Get information about a Twitter user by screenname or user ID.", - "payload": { - "screenname": "Twitter username without the @ symbol", - "rest_id": "Optional Twitter user's ID. If provided, overwrites screenname parameter." - } - }, - "timeline": { - "route": "/timeline.php", - "method": "GET", - "name": "User Timeline", - "description": "Get tweets from a user's timeline.", - "payload": { - "screenname": "Twitter username without the @ symbol", - "rest_id": "Optional parameter that overwrites the screenname", - "cursor": "Optional pagination cursor" - } - }, - "following": { - "route": "/following.php", - "method": "GET", - "name": "User Following", - "description": "Get users that a specific user follows.", - "payload": { - "screenname": "Twitter username without the @ symbol", - "rest_id": "Optional parameter that overwrites the screenname", - "cursor": "Optional pagination cursor" - } - }, - "followers": { - "route": "/followers.php", - "method": "GET", - "name": "User Followers", - "description": "Get followers of a specific user.", - "payload": { - "screenname": "Twitter username without the @ symbol", - "cursor": "Optional pagination cursor" - } - }, - "search": { - "route": "/search.php", - "method": "GET", - "name": "Twitter Search", - "description": "Search for tweets with a specific query.", - "payload": { - "query": "Search query string", - "cursor": "Optional pagination cursor", - "search_type": "Optional search type (e.g. 'Top')" - } - }, - "replies": { - "route": "/replies.php", - "method": "GET", - "name": "User Replies", - "description": "Get replies made by a user.", - "payload": { - "screenname": "Twitter username without the @ symbol", - "cursor": "Optional pagination cursor" - } - }, - "check_retweet": { - "route": "/checkretweet.php", - "method": "GET", - "name": "Check Retweet", - "description": "Check if a user has retweeted a specific tweet.", - "payload": { - "screenname": "Twitter username without the @ symbol", - "tweet_id": "ID of the tweet to check" - } - }, - "tweet": { - "route": "/tweet.php", - "method": "GET", - "name": "Get Tweet", - "description": "Get details of a specific tweet by ID.", - "payload": { - "id": "ID of the tweet" - } - }, - "tweet_thread": { - "route": "/tweet_thread.php", - "method": "GET", - "name": "Get Tweet Thread", - "description": "Get a thread of tweets starting from a specific tweet ID.", - "payload": { - "id": "ID of the tweet", - "cursor": "Optional pagination cursor" - } - }, - "retweets": { - "route": "/retweets.php", - "method": "GET", - "name": "Get Retweets", - "description": "Get users who retweeted a specific tweet.", - "payload": { - "id": "ID of the tweet", - "cursor": "Optional pagination cursor" - } - }, - "latest_replies": { - "route": "/latest_replies.php", - "method": "GET", - "name": "Get Latest Replies", - "description": "Get the latest replies to a specific tweet.", - "payload": { - "id": "ID of the tweet", - "cursor": "Optional pagination cursor" - } - } - } - base_url = "https://twitter-api45.p.rapidapi.com" - super().__init__(base_url, endpoints) - - -if __name__ == "__main__": - from dotenv import load_dotenv - load_dotenv() - tool = TwitterProvider() - - # Example for getting user info - user_info = tool.call_endpoint( - route="user_info", - payload={ - "screenname": "elonmusk", - # "rest_id": "44196397" # Optional, uncomment to use user ID instead of screenname - } - ) - print("User Info:", user_info) - - # Example for getting user timeline - timeline = tool.call_endpoint( - route="timeline", - payload={ - "screenname": "elonmusk", - # "cursor": "optional-cursor-value" # Optional for pagination - } - ) - print("Timeline:", timeline) - - # Example for getting user following - following = tool.call_endpoint( - route="following", - payload={ - "screenname": "elonmusk", - # "cursor": "optional-cursor-value" # Optional for pagination - } - ) - print("Following:", following) - - # Example for getting user followers - followers = tool.call_endpoint( - route="followers", - payload={ - "screenname": "elonmusk", - # "cursor": "optional-cursor-value" # Optional for pagination - } - ) - print("Followers:", followers) - - # Example for searching tweets - search_results = tool.call_endpoint( - route="search", - payload={ - "query": "cybertruck", - "search_type": "Top" # Optional, defaults to Top - # "cursor": "optional-cursor-value" # Optional for pagination - } - ) - print("Search Results:", search_results) - - # Example for getting user replies - replies = tool.call_endpoint( - route="replies", - payload={ - "screenname": "elonmusk", - # "cursor": "optional-cursor-value" # Optional for pagination - } - ) - print("Replies:", replies) - - # Example for checking if user retweeted a tweet - check_retweet = tool.call_endpoint( - route="check_retweet", - payload={ - "screenname": "elonmusk", - "tweet_id": "1671370010743263233" - } - ) - print("Check Retweet:", check_retweet) - - # Example for getting tweet details - tweet = tool.call_endpoint( - route="tweet", - payload={ - "id": "1671370010743263233" - } - ) - print("Tweet:", tweet) - - # Example for getting a tweet thread - tweet_thread = tool.call_endpoint( - route="tweet_thread", - payload={ - "id": "1738106896777699464", - # "cursor": "optional-cursor-value" # Optional for pagination - } - ) - print("Tweet Thread:", tweet_thread) - - # Example for getting retweets of a tweet - retweets = tool.call_endpoint( - route="retweets", - payload={ - "id": "1700199139470942473", - # "cursor": "optional-cursor-value" # Optional for pagination - } - ) - print("Retweets:", retweets) - - # Example for getting latest replies to a tweet - latest_replies = tool.call_endpoint( - route="latest_replies", - payload={ - "id": "1738106896777699464", - # "cursor": "optional-cursor-value" # Optional for pagination - } - ) - print("Latest Replies:", latest_replies) - \ No newline at end of file diff --git a/app/tool/data_providers/YahooFinanceProvider.py b/app/tool/data_providers/YahooFinanceProvider.py deleted file mode 100644 index d18674e72..000000000 --- a/app/tool/data_providers/YahooFinanceProvider.py +++ /dev/null @@ -1,190 +0,0 @@ -from typing import Dict - -from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema - - -class YahooFinanceProvider(RapidDataProviderBase): - def __init__(self): - endpoints: Dict[str, EndpointSchema] = { - "get_tickers": { - "route": "/v2/markets/tickers", - "method": "GET", - "name": "Yahoo Finance Tickers", - "description": "Get financial tickers from Yahoo Finance with various filters and parameters.", - "payload": { - "page": "Page number for pagination (optional, default: 1)", - "type": "Asset class type (required): STOCKS, ETF, MUTUALFUNDS, or FUTURES", - } - }, - "search": { - "route": "/v1/markets/search", - "method": "GET", - "name": "Yahoo Finance Search", - "description": "Search for financial instruments on Yahoo Finance", - "payload": { - "search": "Search term (required)", - } - }, - "get_news": { - "route": "/v2/markets/news", - "method": "GET", - "name": "Yahoo Finance News", - "description": "Get news related to specific tickers from Yahoo Finance", - "payload": { - "tickers": "Stock symbol (optional, e.g., AAPL)", - "type": "News type (optional): ALL, VIDEO, or PRESS_RELEASE", - } - }, - "get_stock_module": { - "route": "/v1/markets/stock/modules", - "method": "GET", - "name": "Yahoo Finance Stock Module", - "description": "Get detailed information about a specific stock module", - "payload": { - "ticker": "Company ticker symbol (required, e.g., AAPL)", - "module": "Module to retrieve (required): asset-profile, financial-data, earnings, etc.", - } - }, - "get_sma": { - "route": "/v1/markets/indicators/sma", - "method": "GET", - "name": "Yahoo Finance SMA Indicator", - "description": "Get Simple Moving Average (SMA) indicator data for a stock", - "payload": { - "symbol": "Stock symbol (required, e.g., AAPL)", - "interval": "Time interval (required): 5m, 15m, 30m, 1h, 1d, 1wk, 1mo, 3mo", - "series_type": "Series type (required): open, close, high, low", - "time_period": "Number of data points used for calculation (required)", - "limit": "Limit the number of results (optional, default: 50)", - } - }, - "get_rsi": { - "route": "/v1/markets/indicators/rsi", - "method": "GET", - "name": "Yahoo Finance RSI Indicator", - "description": "Get Relative Strength Index (RSI) indicator data for a stock", - "payload": { - "symbol": "Stock symbol (required, e.g., AAPL)", - "interval": "Time interval (required): 5m, 15m, 30m, 1h, 1d, 1wk, 1mo, 3mo", - "series_type": "Series type (required): open, close, high, low", - "time_period": "Number of data points used for calculation (required)", - "limit": "Limit the number of results (optional, default: 50)", - } - }, - "get_earnings_calendar": { - "route": "/v1/markets/calendar/earnings", - "method": "GET", - "name": "Yahoo Finance Earnings Calendar", - "description": "Get earnings calendar data for a specific date", - "payload": { - "date": "Calendar date in yyyy-mm-dd format (optional, e.g., 2023-11-30)", - } - }, - "get_insider_trades": { - "route": "/v1/markets/insider-trades", - "method": "GET", - "name": "Yahoo Finance Insider Trades", - "description": "Get recent insider trading activity", - "payload": {} - }, - } - base_url = "https://yahoo-finance15.p.rapidapi.com/api" - super().__init__(base_url, endpoints) - - -if __name__ == "__main__": - from dotenv import load_dotenv - load_dotenv() - tool = YahooFinanceProvider() - - # Example for getting stock tickers - tickers_result = tool.call_endpoint( - route="get_tickers", - payload={ - "page": 1, - "type": "STOCKS" - } - ) - print("Tickers Result:", tickers_result) - - # Example for searching financial instruments - search_result = tool.call_endpoint( - route="search", - payload={ - "search": "AA" - } - ) - print("Search Result:", search_result) - - # Example for getting financial news - news_result = tool.call_endpoint( - route="get_news", - payload={ - "tickers": "AAPL", - "type": "ALL" - } - ) - print("News Result:", news_result) - - # Example for getting stock asset profile module - stock_module_result = tool.call_endpoint( - route="get_stock_module", - payload={ - "ticker": "AAPL", - "module": "asset-profile" - } - ) - print("Asset Profile Result:", stock_module_result) - - # Example for getting financial data module - financial_data_result = tool.call_endpoint( - route="get_stock_module", - payload={ - "ticker": "AAPL", - "module": "financial-data" - } - ) - print("Financial Data Result:", financial_data_result) - - # Example for getting SMA indicator data - sma_result = tool.call_endpoint( - route="get_sma", - payload={ - "symbol": "AAPL", - "interval": "5m", - "series_type": "close", - "time_period": "50", - "limit": "50" - } - ) - print("SMA Result:", sma_result) - - # Example for getting RSI indicator data - rsi_result = tool.call_endpoint( - route="get_rsi", - payload={ - "symbol": "AAPL", - "interval": "5m", - "series_type": "close", - "time_period": "50", - "limit": "50" - } - ) - print("RSI Result:", rsi_result) - - # Example for getting earnings calendar data - earnings_calendar_result = tool.call_endpoint( - route="get_earnings_calendar", - payload={ - "date": "2023-11-30" - } - ) - print("Earnings Calendar Result:", earnings_calendar_result) - - # Example for getting insider trades - insider_trades_result = tool.call_endpoint( - route="get_insider_trades", - payload={} - ) - print("Insider Trades Result:", insider_trades_result) - diff --git a/app/tool/data_providers/ZillowProvider.py b/app/tool/data_providers/ZillowProvider.py deleted file mode 100644 index 95597f42b..000000000 --- a/app/tool/data_providers/ZillowProvider.py +++ /dev/null @@ -1,187 +0,0 @@ -from typing import Dict -import logging - -from agent.tools.data_providers.RapidDataProviderBase import RapidDataProviderBase, EndpointSchema - -logger = logging.getLogger(__name__) - - -class ZillowProvider(RapidDataProviderBase): - def __init__(self): - endpoints: Dict[str, EndpointSchema] = { - "search": { - "route": "/search", - "method": "GET", - "name": "Zillow Property Search", - "description": "Search for properties by neighborhood, city, or ZIP code with various filters.", - "payload": { - "location": "Location can be an address, neighborhood, city, or ZIP code (required)", - "page": "Page number for pagination (optional, default: 0)", - "output": "Output format: json, csv, xlsx (optional, default: json)", - "status": "Status of properties: forSale, forRent, recentlySold (optional, default: forSale)", - "sortSelection": "Sorting criteria (optional, default: priorityscore)", - "listing_type": "Listing type: by_agent, by_owner_other (optional, default: by_agent)", - "doz": "Days on Zillow: any, 1, 7, 14, 30, 90, 6m, 12m, 24m, 36m (optional, default: any)", - "price_min": "Minimum price (optional)", - "price_max": "Maximum price (optional)", - "sqft_min": "Minimum square footage (optional)", - "sqft_max": "Maximum square footage (optional)", - "beds_min": "Minimum number of bedrooms (optional)", - "beds_max": "Maximum number of bedrooms (optional)", - "baths_min": "Minimum number of bathrooms (optional)", - "baths_max": "Maximum number of bathrooms (optional)", - "built_min": "Minimum year built (optional)", - "built_max": "Maximum year built (optional)", - "lotSize_min": "Minimum lot size in sqft (optional)", - "lotSize_max": "Maximum lot size in sqft (optional)", - "keywords": "Keywords to search for (optional)" - } - }, - "search_address": { - "route": "/search_address", - "method": "GET", - "name": "Zillow Address Search", - "description": "Search for a specific property by its full address.", - "payload": { - "address": "Full property address (required)" - } - }, - "propertyV2": { - "route": "/propertyV2", - "method": "GET", - "name": "Zillow Property Details", - "description": "Get detailed information about a specific property by zpid or URL.", - "payload": { - "zpid": "Zillow property ID (optional if URL is provided)", - "url": "Property details URL (optional if zpid is provided)" - } - }, - "zestimate_history": { - "route": "/zestimate_history", - "method": "GET", - "name": "Zillow Zestimate History", - "description": "Get historical Zestimate values for a specific property.", - "payload": { - "zpid": "Zillow property ID (optional if URL is provided)", - "url": "Property details URL (optional if zpid is provided)" - } - }, - "similar_properties": { - "route": "/similar_properties", - "method": "GET", - "name": "Zillow Similar Properties", - "description": "Find properties similar to a specific property.", - "payload": { - "zpid": "Zillow property ID (optional if URL or address is provided)", - "url": "Property details URL (optional if zpid or address is provided)", - "address": "Property address (optional if zpid or URL is provided)" - } - }, - "mortgage_rates": { - "route": "/mortgage/rates", - "method": "GET", - "name": "Zillow Mortgage Rates", - "description": "Get current mortgage rates for different loan programs and conditions.", - "payload": { - "program": "Loan program (required): Fixed30Year, Fixed20Year, Fixed15Year, Fixed10Year, ARM3, ARM5, ARM7, etc.", - "state": "State abbreviation (optional, default: US)", - "refinance": "Whether this is for refinancing (optional, default: false)", - "loanType": "Type of loan: Conventional, etc. (optional)", - "loanAmount": "Loan amount category: Micro, SmallConforming, Conforming, SuperConforming, Jumbo (optional)", - "loanToValue": "Loan to value ratio: Normal, High, VeryHigh (optional)", - "creditScore": "Credit score category: Low, High, VeryHigh (optional)", - "duration": "Duration in days (optional, default: 30)" - } - }, - } - base_url = "https://zillow56.p.rapidapi.com" - super().__init__(base_url, endpoints) - - -if __name__ == "__main__": - from dotenv import load_dotenv - from time import sleep - load_dotenv() - tool = ZillowProvider() - - # Example for searching properties in Houston - search_result = tool.call_endpoint( - route="search", - payload={ - "location": "houston, tx", - "status": "forSale", - "sortSelection": "priorityscore", - "listing_type": "by_agent", - "doz": "any" - } - ) - logger.debug("Search Result: %s", search_result) - logger.debug("***") - logger.debug("***") - logger.debug("***") - sleep(1) - # Example for searching by address - address_result = tool.call_endpoint( - route="search_address", - payload={ - "address": "1161 Natchez Dr College Station Texas 77845" - } - ) - logger.debug("Address Search Result: %s", address_result) - logger.debug("***") - logger.debug("***") - logger.debug("***") - sleep(1) - # Example for getting property details - property_result = tool.call_endpoint( - route="propertyV2", - payload={ - "zpid": "7594920" - } - ) - logger.debug("Property Details Result: %s", property_result) - sleep(1) - logger.debug("***") - logger.debug("***") - logger.debug("***") - - # Example for getting zestimate history - zestimate_result = tool.call_endpoint( - route="zestimate_history", - payload={ - "zpid": "20476226" - } - ) - logger.debug("Zestimate History Result: %s", zestimate_result) - sleep(1) - logger.debug("***") - logger.debug("***") - logger.debug("***") - # Example for getting similar properties - similar_result = tool.call_endpoint( - route="similar_properties", - payload={ - "zpid": "28253016" - } - ) - logger.debug("Similar Properties Result: %s", similar_result) - sleep(1) - logger.debug("***") - logger.debug("***") - logger.debug("***") - # Example for getting mortgage rates - mortgage_result = tool.call_endpoint( - route="mortgage_rates", - payload={ - "program": "Fixed30Year", - "state": "US", - "refinance": "false", - "loanType": "Conventional", - "loanAmount": "Conforming", - "loanToValue": "Normal", - "creditScore": "Low", - "duration": "30" - } - ) - logger.debug("Mortgage Rates Result: %s", mortgage_result) - \ No newline at end of file diff --git a/tests/daytona/test_computer_use_tool.py b/tests/daytona/test_computer_use_tool.py new file mode 100644 index 000000000..b91448d5b --- /dev/null +++ b/tests/daytona/test_computer_use_tool.py @@ -0,0 +1,22 @@ +from app.tool.computer_use_tool import ComputerUseTool +from app.daytona.sandbox import create_sandbox +import asyncio + +async def main(): + # 创建沙箱和工具 + sandbox = create_sandbox(password="123456") + base_url=sandbox.get_preview_link(8000) + print(f"Sandbox base URL: {base_url}") + + print(f"Sandbox ID: {sandbox.id}") + computer_tool = ComputerUseTool.create_with_sandbox(sandbox) + + # 执行截图操作 + result = await computer_tool.execute(action="screenshot") + print(result) + + # 清理资源(可选) + await computer_tool.cleanup() + +if __name__ == "__main__": + asyncio.run(main()) # 运行异步主函数 diff --git a/tests/daytona/test_daytona.py b/tests/daytona/test_daytona.py new file mode 100644 index 000000000..654d67c3b --- /dev/null +++ b/tests/daytona/test_daytona.py @@ -0,0 +1,81 @@ +from daytona import Daytona, DaytonaConfig,CreateSandboxFromImageParams,Resources, Image +# Using environment variables (DAYTONA_API_KEY, DAYTONA_API_URL, DAYTONA_TARGET) +# daytona = Daytona() +# Using explicit configuration +config = DaytonaConfig( + api_key="dtn_bedf8ed9953f0b5c410c042090e1002a56ba8129b573c92f5607aef04b08c82a", + api_url="https://app.daytona.io/api", + target="us" +) +daytona = Daytona(config) +params = CreateSandboxFromImageParams( + image="kortix/suna:0.1.3", + # image=Image.debian_slim("3.12"), + public=True, + labels=None, + env_vars={ + "CHROME_PERSISTENT_SESSION": "true", + "RESOLUTION": "1024x768x24", + "RESOLUTION_WIDTH": "1024", + "RESOLUTION_HEIGHT": "768", + "VNC_PASSWORD": "123456", + "ANONYMIZED_TELEMETRY": "false", + "CHROME_PATH": "", + "CHROME_USER_DATA": "", + "CHROME_DEBUGGING_PORT": "9222", + "CHROME_DEBUGGING_HOST": "localhost", + "CHROME_CDP": "" + }, + resources=Resources( + cpu=1, + memory=1, + disk=1, + ), + auto_stop_interval=15, + auto_archive_interval=24 * 60, + ) + # Create the sandbox +sandbox = daytona.create(params) +print(f"Sandbox created with ID: {sandbox.id}") + + +# from daytona import Daytona + +# def main(): +# # Initialize the SDK (uses environment variables by default) +# daytona = Daytona(config) + +# # Create a new sandbox +# sandbox = daytona.create() + +# # Execute a command +# response = sandbox.process.exec("echo 'Hello, World!'") +# print(response.result) + +# if __name__ == "__main__": +# main() +# from daytona import Daytona, DaytonaConfig + +# Define the configuration + +# config = DaytonaConfig(api_key="dtn_bedf8ed9953f0b5c410c042090e1002a56ba8129b573c92f5607aef04b08c82a") + +# # Initialize the Daytona client + +# daytona = Daytona(config) + +# # Create the Sandbox instance + +# sandbox = daytona.create() + +# # Run the code securely inside the Sandbox + +# response = sandbox.process.code_run('print("Hello World from code!")') +# if response.exit_code != 0: +# print(f"Error: {response.exit_code} {response.result}") +# else: +# print(response.result) + +# Clean up + +# sandbox.delete() From 686ace2b09dab1633e157e2633a062af9c84a560 Mon Sep 17 00:00:00 2001 From: white-rm <704748696@qq.com> Date: Sun, 6 Jul 2025 22:42:09 +0800 Subject: [PATCH 06/33] add shell sandox --- app/config.py | 2 +- app/tool/sb_shell_tool.py | 819 +++++++++++++++++++-------- tests/daytona/test_sb_shell_tools.py | 23 + 3 files changed, 619 insertions(+), 225 deletions(-) create mode 100644 tests/daytona/test_sb_shell_tools.py diff --git a/app/config.py b/app/config.py index 1702be8ce..88cc5694a 100644 --- a/app/config.py +++ b/app/config.py @@ -109,7 +109,7 @@ class DaytonaSettings(BaseModel): daytona_api_key: str daytona_server_url: Optional[str] = Field("https://app.daytona.io/api", description="") daytona_target: Optional[str] = Field("asia", description="enum ['asia', 'eu', 'us']") - sandbox_image_name: Optional[str]= Field("kortix/suna:0.1.3", description="") + sandbox_image_name: Optional[str]= Field("daytonaio/sandbox:0.4.1", description="") sandbox_entrypoint: Optional[str]= Field("/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf", description="") class MCPServerConfig(BaseModel): diff --git a/app/tool/sb_shell_tool.py b/app/tool/sb_shell_tool.py index 74acc6cb8..0d94c3ff0 100644 --- a/app/tool/sb_shell_tool.py +++ b/app/tool/sb_shell_tool.py @@ -1,18 +1,80 @@ -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, TypeVar import time from uuid import uuid4 -from agentpress.tool import ToolResult, openapi_schema, xml_schema -from daytona.tool_base import SandboxToolsBase -from agentpress.thread_manager import ThreadManager +from app.tool.base import ToolResult +from app.daytona.tool_base import SandboxToolsBase +from app.utils.logger import logger + +Context = TypeVar("Context") +_SHELL_DESCRIPTION = """\ +Execute a shell command in the workspace directory. +IMPORTANT: Commands are non-blocking by default and run in a tmux session. +This is ideal for long-running operations like starting servers or build processes. +Uses sessions to maintain state between commands. +This tool is essential for running CLI tools, installing packages, and managing system operations. +""" + class SandboxShellTool(SandboxToolsBase): """Tool for executing tasks in a Daytona sandbox with browser-use capabilities. Uses sessions for maintaining state between commands and provides comprehensive process management.""" - - def __init__(self, project_id: str, thread_manager: ThreadManager): - super().__init__(project_id, thread_manager) - self._sessions: Dict[str, str] = {} # Maps session names to session IDs - self.workspace_path = "/workspace" # Ensure we're always operating in /workspace + name: str = "sandbox_shell" + description: str = _SHELL_DESCRIPTION + parameters: dict = { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "execute_command", + "check_command_output", + "terminate_command", + "list_commands" + ], + "description": "The shell action to perform", + }, + "command": { + "type": "string", + "description": "The shell command to execute. Use this for running CLI tools, installing packages, " + "or system operations. Commands can be chained using &&, ||, and | operators." + }, + "folder": { + "type": "string", + "description": "Optional relative path to a subdirectory of /workspace where the command should be " + "executed. Example: 'data/pdfs'" + }, + "session_name": { + "type": "string", + "description": "Optional name of the tmux session to use. Use named sessions for related commands " + "that need to maintain state. Defaults to a random session name.", + }, + "blocking": { + "type": "boolean", + "description": "Whether to wait for the command to complete. Defaults to false for non-blocking " + "execution.", + "default": False + }, + "timeout": { + "type": "integer", + "description": "Optional timeout in seconds for blocking commands. Defaults to 60. Ignored for " + "non-blocking commands.", + "default": 60 + }, + "kill_session": { + "type": "boolean", + "description": "Whether to terminate the tmux session after checking. Set to true when you're done " + "with the command.", + "default": False + } + }, + "required": ["action"], + "dependencies": { + "execute_command": ["command"], + "check_command_output": ["session_name"], + "terminate_command": ["session_name"], + "list_commands": [] + }, + } async def _ensure_session(self, session_name: str = "default") -> str: """Ensure a session exists and return its ID.""" @@ -36,84 +98,42 @@ async def _cleanup_session(self, session_name: str): except Exception as e: print(f"Warning: Failed to cleanup session {session_name}: {str(e)}") - @openapi_schema({ - "type": "function", - "function": { - "name": "execute_command", - "description": "Execute a shell command in the workspace directory. IMPORTANT: Commands are non-blocking by default and run in a tmux session. This is ideal for long-running operations like starting servers or build processes. Uses sessions to maintain state between commands. This tool is essential for running CLI tools, installing packages, and managing system operations.", - "parameters": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The shell command to execute. Use this for running CLI tools, installing packages, or system operations. Commands can be chained using &&, ||, and | operators." - }, - "folder": { - "type": "string", - "description": "Optional relative path to a subdirectory of /workspace where the command should be executed. Example: 'data/pdfs'" - }, - "session_name": { - "type": "string", - "description": "Optional name of the tmux session to use. Use named sessions for related commands that need to maintain state. Defaults to a random session name.", - }, - "blocking": { - "type": "boolean", - "description": "Whether to wait for the command to complete. Defaults to false for non-blocking execution.", - "default": False - }, - "timeout": { - "type": "integer", - "description": "Optional timeout in seconds for blocking commands. Defaults to 60. Ignored for non-blocking commands.", - "default": 60 - } - }, - "required": ["command"] - } + async def _execute_raw_command(self, command: str) -> Dict[str, Any]: + """Execute a raw command directly in the sandbox.""" + # Ensure session exists for raw commands + session_id = await self._ensure_session("raw_commands") + + # Execute command in session + from app.daytona.sandbox import SessionExecuteRequest + req = SessionExecuteRequest( + command=command, + run_async=False, + cwd=self.workspace_path + ) + + response = self.sandbox.process.execute_session_command( + session_id=session_id, + req=req, + timeout=30 # Short timeout for utility commands + ) + + logs = self.sandbox.process.get_session_command_logs( + session_id=session_id, + command_id=response.cmd_id + ) + + return { + "output": logs, + "exit_code": response.exit_code } - }) - @xml_schema( - tag_name="execute-command", - mappings=[ - {"param_name": "command", "node_type": "content", "path": "."}, - {"param_name": "folder", "node_type": "attribute", "path": ".", "required": False}, - {"param_name": "session_name", "node_type": "attribute", "path": ".", "required": False}, - {"param_name": "blocking", "node_type": "attribute", "path": ".", "required": False}, - {"param_name": "timeout", "node_type": "attribute", "path": ".", "required": False} - ], - example=''' - - - npm run dev - dev_server - - - - - - - npm run build - frontend - build_process - - - - - - - npm install - true - 300 - - - ''' - ) - async def execute_command( - self, - command: str, - folder: Optional[str] = None, - session_name: Optional[str] = None, - blocking: bool = False, - timeout: int = 60 + + async def _execute_command( + self, + command: str, + folder: Optional[str] = None, + session_name: Optional[str] = None, + blocking: bool = False, + timeout: int = 60 ) -> ToolResult: try: # Ensure sandbox is initialized @@ -130,7 +150,8 @@ async def execute_command( session_name = f"session_{str(uuid4())[:8]}" # Check if tmux session already exists - check_session = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") + check_session = await self._execute_raw_command( + f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") session_exists = "not_exists" not in check_session.get("output", "") if not session_exists: @@ -152,7 +173,8 @@ async def execute_command( time.sleep(2) # Check if session still exists (command might have exited) - check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'ended'") + check_result = await self._execute_raw_command( + f"tmux has-session -t {session_name} 2>/dev/null || echo 'ended'") if "ended" in check_result.get("output", ""): break @@ -184,7 +206,8 @@ async def execute_command( return self.success_response({ "session_name": session_name, "cwd": cwd, - "message": f"Command sent to tmux session '{session_name}'. Use check_command_output to view results.", + "message": + f"Command sent to tmux session '{session_name}'. Use check_command_output to view results.", "completed": False }) @@ -197,90 +220,18 @@ async def execute_command( pass return self.fail_response(f"Error executing command: {str(e)}") - async def _execute_raw_command(self, command: str) -> Dict[str, Any]: - """Execute a raw command directly in the sandbox.""" - # Ensure session exists for raw commands - session_id = await self._ensure_session("raw_commands") - - # Execute command in session - from sandbox.sandbox import SessionExecuteRequest - req = SessionExecuteRequest( - command=command, - var_async=False, - cwd=self.workspace_path - ) - - response = self.sandbox.process.execute_session_command( - session_id=session_id, - req=req, - timeout=30 # Short timeout for utility commands - ) - - logs = self.sandbox.process.get_session_command_logs( - session_id=session_id, - command_id=response.cmd_id - ) - - return { - "output": logs, - "exit_code": response.exit_code - } - - @openapi_schema({ - "type": "function", - "function": { - "name": "check_command_output", - "description": "Check the output of a previously executed command in a tmux session. Use this to monitor the progress or results of non-blocking commands.", - "parameters": { - "type": "object", - "properties": { - "session_name": { - "type": "string", - "description": "The name of the tmux session to check." - }, - "kill_session": { - "type": "boolean", - "description": "Whether to terminate the tmux session after checking. Set to true when you're done with the command.", - "default": False - } - }, - "required": ["session_name"] - } - } - }) - @xml_schema( - tag_name="check-command-output", - mappings=[ - {"param_name": "session_name", "node_type": "attribute", "path": ".", "required": True}, - {"param_name": "kill_session", "node_type": "attribute", "path": ".", "required": False} - ], - example=''' - - - dev_server - - - - - - - build_process - true - - - ''' - ) - async def check_command_output( - self, - session_name: str, - kill_session: bool = False + async def _check_command_output( + self, + session_name: str, + kill_session: bool = False ) -> ToolResult: try: # Ensure sandbox is initialized await self._ensure_sandbox() # Check if session exists - check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") + check_result = await self._execute_raw_command( + f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") if "not_exists" in check_result.get("output", ""): return self.fail_response(f"Tmux session '{session_name}' does not exist.") @@ -304,46 +255,17 @@ async def check_command_output( except Exception as e: return self.fail_response(f"Error checking command output: {str(e)}") - @openapi_schema({ - "type": "function", - "function": { - "name": "terminate_command", - "description": "Terminate a running command by killing its tmux session.", - "parameters": { - "type": "object", - "properties": { - "session_name": { - "type": "string", - "description": "The name of the tmux session to terminate." - } - }, - "required": ["session_name"] - } - } - }) - @xml_schema( - tag_name="terminate-command", - mappings=[ - {"param_name": "session_name", "node_type": "attribute", "path": ".", "required": True} - ], - example=''' - - - dev_server - - - ''' - ) - async def terminate_command( - self, - session_name: str + async def _terminate_command( + self, + session_name: str ) -> ToolResult: try: # Ensure sandbox is initialized await self._ensure_sandbox() # Check if session exists - check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") + check_result = await self._execute_raw_command( + f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") if "not_exists" in check_result.get("output", ""): return self.fail_response(f"Tmux session '{session_name}' does not exist.") @@ -357,28 +279,7 @@ async def terminate_command( except Exception as e: return self.fail_response(f"Error terminating command: {str(e)}") - @openapi_schema({ - "type": "function", - "function": { - "name": "list_commands", - "description": "List all running tmux sessions and their status.", - "parameters": { - "type": "object", - "properties": {} - } - } - }) - @xml_schema( - tag_name="list-commands", - mappings=[], - example=''' - - - - - ''' - ) - async def list_commands(self) -> ToolResult: + async def _list_commands(self) -> ToolResult: try: # Ensure sandbox is initialized await self._ensure_sandbox() @@ -410,6 +311,52 @@ async def list_commands(self) -> ToolResult: except Exception as e: return self.fail_response(f"Error listing commands: {str(e)}") + async def execute( + self, + action: str, + command: str, + folder: Optional[str] = None, + session_name: Optional[str] = None, + blocking: bool = False, + timeout: int = 60, + kill_session: bool = False, + ) -> ToolResult: + """ + Execute a browser action in the sandbox environment. + Args: + timeout: + blocking: + session_name: + folder: + command: + kill_session: + action: The browser action to perform + Returns: + ToolResult with the action's output or error + """ + async with self.lock: + try: + # Navigation actions + if action == "execute_command": + if not command: + return self.fail_response("command is required for navigation") + return await self._execute_command(command, folder, session_name, blocking, timeout) + elif action == "check_command_output": + if session_name is None: + return self.fail_response("session_name is required for navigation") + return await self._check_command_output(session_name, kill_session) + elif action == "terminate_command": + if session_name is None: + return self.fail_response("session_name is required for click_element") + return await self._terminate_command(session_name) + elif action == "list_commands": + return await self._list_commands() + else: + return self.fail_response(f"Unknown action: {action}") + except Exception as e: + logger.error(f"Error executing shell action: {e}") + return self.fail_response(f"Error executing shell action: {e}") + async def cleanup(self): """Clean up all sessions.""" for session_name in list(self._sessions.keys()): @@ -419,5 +366,429 @@ async def cleanup(self): try: await self._ensure_sandbox() await self._execute_raw_command("tmux kill-server 2>/dev/null || true") - except: + except Exception as e: + logger.error(f"Error shell box cleanup action: {e}") pass +# from typing import Optional, Dict, Any +# import time +# from uuid import uuid4 +# from agentpress.tool import ToolResult, openapi_schema, xml_schema +# from daytona.tool_base import SandboxToolsBase +# from agentpress.thread_manager import ThreadManager +# +# class SandboxShellTool(SandboxToolsBase): +# """Tool for executing tasks in a Daytona sandbox with browser-use capabilities. +# Uses sessions for maintaining state between commands and provides comprehensive process management.""" +# +# def __init__(self, project_id: str, thread_manager: ThreadManager): +# super().__init__(project_id, thread_manager) +# self._sessions: Dict[str, str] = {} # Maps session names to session IDs +# self.workspace_path = "/workspace" # Ensure we're always operating in /workspace +# +# async def _ensure_session(self, session_name: str = "default") -> str: +# """Ensure a session exists and return its ID.""" +# if session_name not in self._sessions: +# session_id = str(uuid4()) +# try: +# await self._ensure_sandbox() # Ensure sandbox is initialized +# self.sandbox.process.create_session(session_id) +# self._sessions[session_name] = session_id +# except Exception as e: +# raise RuntimeError(f"Failed to create session: {str(e)}") +# return self._sessions[session_name] +# +# async def _cleanup_session(self, session_name: str): +# """Clean up a session if it exists.""" +# if session_name in self._sessions: +# try: +# await self._ensure_sandbox() # Ensure sandbox is initialized +# self.sandbox.process.delete_session(self._sessions[session_name]) +# del self._sessions[session_name] +# except Exception as e: +# print(f"Warning: Failed to cleanup session {session_name}: {str(e)}") +# +# @openapi_schema({ +# "type": "function", +# "function": { +# "name": "execute_command", +# "description": "Execute a shell command in the workspace directory. IMPORTANT: Commands are non-blocking by default and run in a tmux session. This is ideal for long-running operations like starting servers or build processes. Uses sessions to maintain state between commands. This tool is essential for running CLI tools, installing packages, and managing system operations.", +# "parameters": { +# "type": "object", +# "properties": { +# "command": { +# "type": "string", +# "description": "The shell command to execute. Use this for running CLI tools, installing packages, or system operations. Commands can be chained using &&, ||, and | operators." +# }, +# "folder": { +# "type": "string", +# "description": "Optional relative path to a subdirectory of /workspace where the command should be executed. Example: 'data/pdfs'" +# }, +# "session_name": { +# "type": "string", +# "description": "Optional name of the tmux session to use. Use named sessions for related commands that need to maintain state. Defaults to a random session name.", +# }, +# "blocking": { +# "type": "boolean", +# "description": "Whether to wait for the command to complete. Defaults to false for non-blocking execution.", +# "default": False +# }, +# "timeout": { +# "type": "integer", +# "description": "Optional timeout in seconds for blocking commands. Defaults to 60. Ignored for non-blocking commands.", +# "default": 60 +# } +# }, +# "required": ["command"] +# } +# } +# }) +# @xml_schema( +# tag_name="execute-command", +# mappings=[ +# {"param_name": "command", "node_type": "content", "path": "."}, +# {"param_name": "folder", "node_type": "attribute", "path": ".", "required": False}, +# {"param_name": "session_name", "node_type": "attribute", "path": ".", "required": False}, +# {"param_name": "blocking", "node_type": "attribute", "path": ".", "required": False}, +# {"param_name": "timeout", "node_type": "attribute", "path": ".", "required": False} +# ], +# example=''' +# +# +# npm run dev +# dev_server +# +# +# +# +# +# +# npm run build +# frontend +# build_process +# +# +# +# +# +# +# npm install +# true +# 300 +# +# +# ''' +# ) +# async def execute_command( +# self, +# command: str, +# folder: Optional[str] = None, +# session_name: Optional[str] = None, +# blocking: bool = False, +# timeout: int = 60 +# ) -> ToolResult: +# try: +# # Ensure sandbox is initialized +# await self._ensure_sandbox() +# +# # Set up working directory +# cwd = self.workspace_path +# if folder: +# folder = folder.strip('/') +# cwd = f"{self.workspace_path}/{folder}" +# +# # Generate a session name if not provided +# if not session_name: +# session_name = f"session_{str(uuid4())[:8]}" +# +# # Check if tmux session already exists +# check_session = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") +# session_exists = "not_exists" not in check_session.get("output", "") +# +# if not session_exists: +# # Create a new tmux session +# await self._execute_raw_command(f"tmux new-session -d -s {session_name}") +# +# # Ensure we're in the correct directory and send command to tmux +# full_command = f"cd {cwd} && {command}" +# wrapped_command = full_command.replace('"', '\\"') # Escape double quotes +# +# # Send command to tmux session +# await self._execute_raw_command(f'tmux send-keys -t {session_name} "{wrapped_command}" Enter') +# +# if blocking: +# # For blocking execution, wait and capture output +# start_time = time.time() +# while (time.time() - start_time) < timeout: +# # Wait a bit before checking +# time.sleep(2) +# +# # Check if session still exists (command might have exited) +# check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'ended'") +# if "ended" in check_result.get("output", ""): +# break +# +# # Get current output and check for common completion indicators +# output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") +# current_output = output_result.get("output", "") +# +# # Check for prompt indicators that suggest command completion +# last_lines = current_output.split('\n')[-3:] +# completion_indicators = ['$', '#', '>', 'Done', 'Completed', 'Finished', '✓'] +# if any(indicator in line for indicator in completion_indicators for line in last_lines): +# break +# +# # Capture final output +# output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") +# final_output = output_result.get("output", "") +# +# # Kill the session after capture +# await self._execute_raw_command(f"tmux kill-session -t {session_name}") +# +# return self.success_response({ +# "output": final_output, +# "session_name": session_name, +# "cwd": cwd, +# "completed": True +# }) +# else: +# # For non-blocking, just return immediately +# return self.success_response({ +# "session_name": session_name, +# "cwd": cwd, +# "message": f"Command sent to tmux session '{session_name}'. Use check_command_output to view results.", +# "completed": False +# }) +# +# except Exception as e: +# # Attempt to clean up session in case of error +# if session_name: +# try: +# await self._execute_raw_command(f"tmux kill-session -t {session_name}") +# except: +# pass +# return self.fail_response(f"Error executing command: {str(e)}") +# +# async def _execute_raw_command(self, command: str) -> Dict[str, Any]: +# """Execute a raw command directly in the sandbox.""" +# # Ensure session exists for raw commands +# session_id = await self._ensure_session("raw_commands") +# +# # Execute command in session +# from sandbox.sandbox import SessionExecuteRequest +# req = SessionExecuteRequest( +# command=command, +# var_async=False, +# cwd=self.workspace_path +# ) +# +# response = self.sandbox.process.execute_session_command( +# session_id=session_id, +# req=req, +# timeout=30 # Short timeout for utility commands +# ) +# +# logs = self.sandbox.process.get_session_command_logs( +# session_id=session_id, +# command_id=response.cmd_id +# ) +# +# return { +# "output": logs, +# "exit_code": response.exit_code +# } +# +# @openapi_schema({ +# "type": "function", +# "function": { +# "name": "check_command_output", +# "description": "Check the output of a previously executed command in a tmux session. Use this to monitor the progress or results of non-blocking commands.", +# "parameters": { +# "type": "object", +# "properties": { +# "session_name": { +# "type": "string", +# "description": "The name of the tmux session to check." +# }, +# "kill_session": { +# "type": "boolean", +# "description": "Whether to terminate the tmux session after checking. Set to true when you're done with the command.", +# "default": False +# } +# }, +# "required": ["session_name"] +# } +# } +# }) +# @xml_schema( +# tag_name="check-command-output", +# mappings=[ +# {"param_name": "session_name", "node_type": "attribute", "path": ".", "required": True}, +# {"param_name": "kill_session", "node_type": "attribute", "path": ".", "required": False} +# ], +# example=''' +# +# +# dev_server +# +# +# +# +# +# +# build_process +# true +# +# +# ''' +# ) +# async def check_command_output( +# self, +# session_name: str, +# kill_session: bool = False +# ) -> ToolResult: +# try: +# # Ensure sandbox is initialized +# await self._ensure_sandbox() +# +# # Check if session exists +# check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") +# if "not_exists" in check_result.get("output", ""): +# return self.fail_response(f"Tmux session '{session_name}' does not exist.") +# +# # Get output from tmux pane +# output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") +# output = output_result.get("output", "") +# +# # Kill session if requested +# if kill_session: +# await self._execute_raw_command(f"tmux kill-session -t {session_name}") +# termination_status = "Session terminated." +# else: +# termination_status = "Session still running." +# +# return self.success_response({ +# "output": output, +# "session_name": session_name, +# "status": termination_status +# }) +# +# except Exception as e: +# return self.fail_response(f"Error checking command output: {str(e)}") +# +# @openapi_schema({ +# "type": "function", +# "function": { +# "name": "terminate_command", +# "description": "Terminate a running command by killing its tmux session.", +# "parameters": { +# "type": "object", +# "properties": { +# "session_name": { +# "type": "string", +# "description": "The name of the tmux session to terminate." +# } +# }, +# "required": ["session_name"] +# } +# } +# }) +# @xml_schema( +# tag_name="terminate-command", +# mappings=[ +# {"param_name": "session_name", "node_type": "attribute", "path": ".", "required": True} +# ], +# example=''' +# +# +# dev_server +# +# +# ''' +# ) +# async def terminate_command( +# self, +# session_name: str +# ) -> ToolResult: +# try: +# # Ensure sandbox is initialized +# await self._ensure_sandbox() +# +# # Check if session exists +# check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") +# if "not_exists" in check_result.get("output", ""): +# return self.fail_response(f"Tmux session '{session_name}' does not exist.") +# +# # Kill the session +# await self._execute_raw_command(f"tmux kill-session -t {session_name}") +# +# return self.success_response({ +# "message": f"Tmux session '{session_name}' terminated successfully." +# }) +# +# except Exception as e: +# return self.fail_response(f"Error terminating command: {str(e)}") +# +# @openapi_schema({ +# "type": "function", +# "function": { +# "name": "list_commands", +# "description": "List all running tmux sessions and their status.", +# "parameters": { +# "type": "object", +# "properties": {} +# } +# } +# }) +# @xml_schema( +# tag_name="list-commands", +# mappings=[], +# example=''' +# +# +# +# +# ''' +# ) +# async def list_commands(self) -> ToolResult: +# try: +# # Ensure sandbox is initialized +# await self._ensure_sandbox() +# +# # List all tmux sessions +# result = await self._execute_raw_command("tmux list-sessions 2>/dev/null || echo 'No sessions'") +# output = result.get("output", "") +# +# if "No sessions" in output or not output.strip(): +# return self.success_response({ +# "message": "No active tmux sessions found.", +# "sessions": [] +# }) +# +# # Parse session list +# sessions = [] +# for line in output.split('\n'): +# if line.strip(): +# parts = line.split(':') +# if parts: +# session_name = parts[0].strip() +# sessions.append(session_name) +# +# return self.success_response({ +# "message": f"Found {len(sessions)} active sessions.", +# "sessions": sessions +# }) +# +# except Exception as e: +# return self.fail_response(f"Error listing commands: {str(e)}") +# +# async def cleanup(self): +# """Clean up all sessions.""" +# for session_name in list(self._sessions.keys()): +# await self._cleanup_session(session_name) +# +# # Also clean up any tmux sessions +# try: +# await self._ensure_sandbox() +# await self._execute_raw_command("tmux kill-server 2>/dev/null || true") +# except: +# pass diff --git a/tests/daytona/test_sb_shell_tools.py b/tests/daytona/test_sb_shell_tools.py new file mode 100644 index 000000000..4b8b1575e --- /dev/null +++ b/tests/daytona/test_sb_shell_tools.py @@ -0,0 +1,23 @@ +from app.tool.sb_shell_tool import SandboxShellTool +from app.daytona.sandbox import create_sandbox +import asyncio + +async def main(): + # 创建沙箱和工具 + sandbox = create_sandbox(password="123456") + base_url=sandbox.get_preview_link(8000) + print(f"Sandbox base URL: {base_url}") + + print(f"Sandbox ID: {sandbox.id}") + sb_shell_tool = SandboxShellTool.create_with_sandbox(sandbox) + + # 执行截图操作 + result = await sb_shell_tool.execute(action="execute_command", command="ls") + print(result) + + # 清理资源(可选) + await sb_shell_tool.cleanup() + +if __name__ == "__main__": + print("123") + asyncio.run(main()) \ No newline at end of file From fb30c432f3f28b2816d62358a910fd372c4114b9 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Sun, 6 Jul 2025 23:57:00 +0800 Subject: [PATCH 07/33] add daytona config --- config/config.example.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/config/config.example.toml b/config/config.example.toml index 7693ee8df..0b9e13058 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -95,6 +95,14 @@ temperature = 0.0 # Controls randomness for vision mode #timeout = 300 #network_enabled = true +# Daytona configuration +[daytona] +daytona_api_key = "" +daytona_server_url = "https://app.daytona.io/api" +daytona_target = "us" +#sandbox_image_name = +#sandbox_entrypoint = + # MCP (Model Context Protocol) configuration [mcp] server_reference = "app.mcp.server" # default server module reference From 17e1becb9f872cc17e95e6838577b388c7696d22 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Tue, 8 Jul 2025 22:24:02 +0800 Subject: [PATCH 08/33] test sb_browser_use_tool --- app/config.py | 4 +- app/tool/computer_use_tool.py | 2 +- app/tool/sb_browser_tool.py | 215 +++++++++++++----------- tests/daytona/test_computer_use_tool.py | 2 +- tests/daytona/test_sb_browser_use.py | 53 ++++++ 5 files changed, 171 insertions(+), 105 deletions(-) create mode 100644 tests/daytona/test_sb_browser_use.py diff --git a/app/config.py b/app/config.py index 88cc5694a..fd2c8fdfe 100644 --- a/app/config.py +++ b/app/config.py @@ -108,8 +108,8 @@ class SandboxSettings(BaseModel): class DaytonaSettings(BaseModel): daytona_api_key: str daytona_server_url: Optional[str] = Field("https://app.daytona.io/api", description="") - daytona_target: Optional[str] = Field("asia", description="enum ['asia', 'eu', 'us']") - sandbox_image_name: Optional[str]= Field("daytonaio/sandbox:0.4.1", description="") + daytona_target: Optional[str] = Field("us", description="enum ['asia', 'eu', 'us']") + sandbox_image_name: Optional[str]= Field("kortix/suna:0.1.3", description="") sandbox_entrypoint: Optional[str]= Field("/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf", description="") class MCPServerConfig(BaseModel): diff --git a/app/tool/computer_use_tool.py b/app/tool/computer_use_tool.py index c71ec3111..38688c293 100644 --- a/app/tool/computer_use_tool.py +++ b/app/tool/computer_use_tool.py @@ -780,7 +780,7 @@ def __init__(self, sandbox: Optional[Sandbox] = None, **data): super().__init__(**data) if sandbox is not None: self._sandbox = sandbox # 直接操作基类的私有属性 - self.api_base_url = sandbox.get_preview_link(8000) + self.api_base_url = sandbox.get_preview_link(8000).url logging.info(f"Initialized ComputerUseTool with API URL: {self.api_base_url}") @classmethod diff --git a/app/tool/sb_browser_tool.py b/app/tool/sb_browser_tool.py index 2d8f09523..5623e3142 100644 --- a/app/tool/sb_browser_tool.py +++ b/app/tool/sb_browser_tool.py @@ -3,16 +3,18 @@ import base64 import io from PIL import Image - +import asyncio +from typing import Optional # Add this import for Optional +from app.daytona.tool_base import Sandbox # Ensure Sandbox is imported correctly # from app.agentpress.tool import ToolResult, openapi_schema, xml_schema from app.tool.base import ToolResult -from app.agentpress.thread_manager import ThreadManager +# from app.agentpress.thread_manager import ThreadManager from app.daytona.tool_base import SandboxToolsBase from app.utils.logger import logger -from app.utils.s3_upload_utils import upload_base64_image +#from app.utils.s3_upload_utils import upload_base64_image -Context = TypeVar("Context") +# Context = TypeVar("Context") _BROWSER_DESCRIPTION = """\ A sandbox-based browser automation tool that allows interaction with web pages through various actions. * This tool provides commands for controlling a browser session in a sandboxed environment @@ -27,6 +29,8 @@ * Content extraction: Get dropdown options or select dropdown options """ class SandboxBrowserTool(SandboxToolsBase): + """Tool for executing tasks in a Daytona sandbox with browser-use capabilities.""" + name: str = "sandbox_browser" description: str = _BROWSER_DESCRIPTION parameters: dict = { @@ -116,10 +120,14 @@ class SandboxBrowserTool(SandboxToolsBase): "wait": ["seconds"], }, } - def __init__(self, project_id: str, thread_id: str, thread_manager: ThreadManager): - super().__init__(project_id=project_id, thread_manager=thread_manager) - self.thread_id = thread_id - self.lock = asyncio.Lock() + def __init__(self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data): + """Initialize with optional sandbox and thread_id.""" + super().__init__(**data) + if sandbox is not None: + self._sandbox = sandbox # Directly set the base class private attribute + # self.api_base_url = sandbox.get_preview_link(8003) # Set API base URL for browser automation + # logger.info(f"Initialized SandboxBrowserTool with API URL: {self.api_base_url}") + def _validate_base64_image(self, base64_string: str, max_size_mb: int = 10) -> tuple[bool, str]: """ Validate base64 image data. @@ -190,28 +198,28 @@ async def _execute_browser_action(self, endpoint: str, params: dict = None, meth result = json.loads(response.result) result.setdefault("content", "") result.setdefault("role", "assistant") - if "screenshot_base64" in result: - screenshot_data = result["screenshot_base64"] - is_valid, validation_message = self._validate_base64_image(screenshot_data) - if is_valid: - image_url = await upload_base64_image(screenshot_data) - result["image_url"] = image_url - else: - logger.warning(f"Screenshot validation failed: {validation_message}") - result["image_validation_error"] = validation_message - del result["screenshot_base64"] - added_message = await self.thread_manager.add_message( - thread_id=self.thread_id, - type="browser_state", - content=result, - is_llm_message=False - ) + # if "screenshot_base64" in result: + # screenshot_data = result["screenshot_base64"] + # is_valid, validation_message = self._validate_base64_image(screenshot_data) + # if is_valid: + # image_url = await upload_base64_image(screenshot_data) + # result["image_url"] = image_url + # else: + # logger.warning(f"Screenshot validation failed: {validation_message}") + # result["image_validation_error"] = validation_message + # del result["screenshot_base64"] + # added_message = await self.thread_manager.add_message( + # thread_id=self.thread_id, + # type="browser_state", + # content=result, + # is_llm_message=False + # ) success_response = { "success": result.get("success", False), "message": result.get("message", "Browser action completed") } - if added_message and 'message_id' in added_message: - success_response['message_id'] = added_message['message_id'] + # if added_message and 'message_id' in added_message: + # success_response['message_id'] = added_message['message_id'] for field in ["url", "title", "element_count", "pixels_below", "ocr_text", "image_url"]: if field in result: success_response[field] = result[field] @@ -260,85 +268,85 @@ async def execute( Returns: ToolResult with the action's output or error """ - async with self.lock: - try: + # async with self.lock: + try: # Navigation actions - if action == "navigate_to": - if not url: - return self.fail_response("URL is required for navigation") - return await self._execute_browser_action("navigate_to", {"url": url}) - elif action == "go_back": - return await self._execute_browser_action("go_back", {}) + if action == "navigate_to": + if not url: + return self.fail_response("URL is required for navigation") + return await self._execute_browser_action("navigate_to", {"url": url}) + elif action == "go_back": + return await self._execute_browser_action("go_back", {}) # Interaction actions - elif action == "click_element": - if index is None: - return self.fail_response("Index is required for click_element") - return await self._execute_browser_action("click_element", {"index": index}) - elif action == "input_text": - if index is None or not text: - return self.fail_response("Index and text are required for input_text") - return await self._execute_browser_action("input_text", {"index": index, "text": text}) - elif action == "send_keys": - if not keys: - return self.fail_response("Keys are required for send_keys") - return await self._execute_browser_action("send_keys", {"keys": keys}) + elif action == "click_element": + if index is None: + return self.fail_response("Index is required for click_element") + return await self._execute_browser_action("click_element", {"index": index}) + elif action == "input_text": + if index is None or not text: + return self.fail_response("Index and text are required for input_text") + return await self._execute_browser_action("input_text", {"index": index, "text": text}) + elif action == "send_keys": + if not keys: + return self.fail_response("Keys are required for send_keys") + return await self._execute_browser_action("send_keys", {"keys": keys}) # Tab management - elif action == "switch_tab": - if page_id is None: - return self.fail_response("Page ID is required for switch_tab") - return await self._execute_browser_action("switch_tab", {"page_id": page_id}) - elif action == "close_tab": - if page_id is None: - return self.fail_response("Page ID is required for close_tab") - return await self._execute_browser_action("close_tab", {"page_id": page_id}) + elif action == "switch_tab": + if page_id is None: + return self.fail_response("Page ID is required for switch_tab") + return await self._execute_browser_action("switch_tab", {"page_id": page_id}) + elif action == "close_tab": + if page_id is None: + return self.fail_response("Page ID is required for close_tab") + return await self._execute_browser_action("close_tab", {"page_id": page_id}) # Scrolling actions - elif action == "scroll_down": - params = {"amount": amount} if amount is not None else {} - return await self._execute_browser_action("scroll_down", params) - elif action == "scroll_up": - params = {"amount": amount} if amount is not None else {} - return await self._execute_browser_action("scroll_up", params) - elif action == "scroll_to_text": - if not text: - return self.fail_response("Text is required for scroll_to_text") - return await self._execute_browser_action("scroll_to_text", {"text": text}) - # Dropdown actions - elif action == "get_dropdown_options": - if index is None: - return self.fail_response("Index is required for get_dropdown_options") - return await self._execute_browser_action("get_dropdown_options", {"index": index}) - elif action == "select_dropdown_option": - if index is None or not text: - return self.fail_response("Index and text are required for select_dropdown_option") - return await self._execute_browser_action("select_dropdown_option", {"index": index, "text": text}) + elif action == "scroll_down": + params = {"amount": amount} if amount is not None else {} + return await self._execute_browser_action("scroll_down", params) + elif action == "scroll_up": + params = {"amount": amount} if amount is not None else {} + return await self._execute_browser_action("scroll_up", params) + elif action == "scroll_to_text": + if not text: + return self.fail_response("Text is required for scroll_to_text") + return await self._execute_browser_action("scroll_to_text", {"text": text}) + # Dropdown actions + elif action == "get_dropdown_options": + if index is None: + return self.fail_response("Index is required for get_dropdown_options") + return await self._execute_browser_action("get_dropdown_options", {"index": index}) + elif action == "select_dropdown_option": + if index is None or not text: + return self.fail_response("Index and text are required for select_dropdown_option") + return await self._execute_browser_action("select_dropdown_option", {"index": index, "text": text}) # Coordinate-based actions - elif action == "click_coordinates": - if x is None or y is None: - return self.fail_response("X and Y coordinates are required for click_coordinates") - return await self._execute_browser_action("click_coordinates", {"x": x, "y": y}) - elif action == "drag_drop": - if not element_source or not element_target: - return self.fail_response("Source and target elements are required for drag_drop") - return await self._execute_browser_action("drag_drop", { - "element_source": element_source, - "element_target": element_target - }) - # Utility actions - elif action == "wait": - seconds_to_wait = seconds if seconds is not None else 3 - return await self._execute_browser_action("wait", {"seconds": seconds_to_wait}) - else: - return self.fail_response(f"Unknown action: {action}") - except Exception as e: - logger.error(f"Error executing browser action: {e}") - return self.fail_response(f"Error executing browser action: {e}") - async def cleanup(self): - """Clean up sandbox resources.""" - pass - @classmethod - def create_with_context(cls, context: Context) -> "SandboxBrowserTool[Context]": - """Factory method to create a SandboxBrowserTool with a specific context.""" - raise NotImplementedError("create_with_context not implemented for SandboxBrowserTool") + elif action == "click_coordinates": + if x is None or y is None: + return self.fail_response("X and Y coordinates are required for click_coordinates") + return await self._execute_browser_action("click_coordinates", {"x": x, "y": y}) + elif action == "drag_drop": + if not element_source or not element_target: + return self.fail_response("Source and target elements are required for drag_drop") + return await self._execute_browser_action("drag_drop", { + "element_source": element_source, + "element_target": element_target + }) + # Utility actions + elif action == "wait": + seconds_to_wait = seconds if seconds is not None else 3 + return await self._execute_browser_action("wait", {"seconds": seconds_to_wait}) + else: + return self.fail_response(f"Unknown action: {action}") + except Exception as e: + logger.error(f"Error executing browser action: {e}") + return self.fail_response(f"Error executing browser action: {e}") + # async def cleanup(self): + # """Clean up sandbox resources.""" + # pass + # @classmethod + # def create_with_context(cls, context: Context) -> "SandboxBrowserTool[Context]": + # """Factory method to create a SandboxBrowserTool with a specific context.""" + # raise NotImplementedError("create_with_context not implemented for SandboxBrowserTool") # class SandboxBrowserTool(SandboxToolsBase): @@ -350,7 +358,7 @@ def create_with_context(cls, context: Context) -> "SandboxBrowserTool[Context]": # def _validate_base64_image(self, base64_string: str, max_size_mb: int = 10) -> tuple[bool, str]: # """ -# Comprehensive validation of base64 image data. +# Comprehensive validation of base64 image data # Args: # base64_string (str): The base64 encoded image data @@ -1380,3 +1388,8 @@ def create_with_context(cls, context: Context) -> "SandboxBrowserTool[Context]": # """ # logger.debug(f"\033[95mClicking at coordinates: ({x}, {y})\033[0m") # return await self._execute_browser_action("click_coordinates", {"x": x, "y": y}) + + @classmethod + def create_with_sandbox(cls, sandbox: Sandbox) -> "SandboxBrowserTool": + """Factory method to create a tool with sandbox.""" + return cls(sandbox=sandbox) diff --git a/tests/daytona/test_computer_use_tool.py b/tests/daytona/test_computer_use_tool.py index b91448d5b..545dcb1f8 100644 --- a/tests/daytona/test_computer_use_tool.py +++ b/tests/daytona/test_computer_use_tool.py @@ -1,5 +1,5 @@ from app.tool.computer_use_tool import ComputerUseTool -from app.daytona.sandbox import create_sandbox +from app.daytona.sandbox import create_sandbox,start_supervisord_session import asyncio async def main(): diff --git a/tests/daytona/test_sb_browser_use.py b/tests/daytona/test_sb_browser_use.py new file mode 100644 index 000000000..41da2560c --- /dev/null +++ b/tests/daytona/test_sb_browser_use.py @@ -0,0 +1,53 @@ +from app.tool.sb_browser_tool import SandboxBrowserTool +from app.daytona.sandbox import create_sandbox,start_supervisord_session +import asyncio +from app.utils.logger import logger +from daytona import DaytonaConfig, Daytona +import json +async def main(): + # 创建沙箱和工具 + # sandbox = create_sandbox(password="123456") + config = DaytonaConfig( + api_key="dtn_bedf8ed9953f0b5c410c042090e1002a56ba8129b573c92f5607aef04b08c82a", + api_url="https://app.daytona.io/api", + target="us" + ) + daytona = Daytona(config) + sandbox = daytona.find_one("84fd0b0c-de80-4d2f-b395-e27317248655") + sandbox.start() + vnc_link = sandbox.get_preview_link(6080) + website_link = sandbox.get_preview_link(8080) + print(f"VNC Link: {vnc_link}") + print(f"Website Link: {website_link}") + # sandbox.start() + # base_url=sandbox.get_preview_link(8000) + # print(f"Sandbox base URL: {base_url}") + + # print(f"Sandbox ID: {sandbox.id}") + tool = SandboxBrowserTool(sandbox) + + # # 执行截图操作 + result = await tool.execute(action="navigate_to",url="https://www.google.com") + print(result) + + # endpoint = "navigate_to" + # method = "POST" + # params = { + # "url": "https://www.google.com" + # } + # url = f"http://localhost:8003/api/automation/{endpoint}" + # curl_cmd = f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'" + # json_data = json.dumps(params) + # curl_cmd += f" -d '{json_data}'" + # logger.debug(f"Executing curl command: {curl_cmd}") + # response = sandbox.process.exec(curl_cmd, timeout=30) + # print(f"Response: {response}") + + + # response = sandbox.process.exec("ls -la") + # print(response.result) + # 清理资源(可选) + # await computer_tool.cleanup() + +if __name__ == "__main__": + asyncio.run(main()) # 运行异步主函数 From 904dec240b9eaf021a7057d562150a8510eb9a5d Mon Sep 17 00:00:00 2001 From: white-rm <704748696@qq.com> Date: Tue, 8 Jul 2025 22:40:59 +0800 Subject: [PATCH 09/33] update shell sandox --- app/daytona/tool_base.py | 90 ++++++++++++++-------------- app/tool/sb_shell_tool.py | 12 +++- tests/daytona/test_sb_shell_tools.py | 16 +++-- 3 files changed, 66 insertions(+), 52 deletions(-) diff --git a/app/daytona/tool_base.py b/app/daytona/tool_base.py index 6390c5421..5f762952f 100644 --- a/app/daytona/tool_base.py +++ b/app/daytona/tool_base.py @@ -1,4 +1,4 @@ - +import asyncio from typing import Optional,ClassVar from pydantic import Field # from app.agentpress.thread_manager import ThreadManager @@ -23,55 +23,55 @@ class SandboxToolsBase(BaseTool): _sandbox_id: Optional[str] = None _sandbox_pass: Optional[str] = None workspace_path: str = Field(default="/workspace", exclude=True) + _sessions: dict[str, str] = {} class Config: arbitrary_types_allowed = True # Allow non-pydantic types like ThreadManager underscore_attrs_are_private = True + async def _ensure_sandbox(self) -> Sandbox: + """Ensure we have a valid sandbox instance, retrieving it from the project if needed.""" + if self._sandbox is None: + # try: + # # Get database client + # client = await self.thread_manager.db.client + # + # # Get project data + # project = await client.table('projects').select('*').eq('project_id', self.project_id).execute() + # if not project.data or len(project.data) == 0: + # raise ValueError(f"Project {self.project_id} not found") + # + # project_data = project.data[0] + # sandbox_info = project_data.get('sandbox', {}) + # + # if not sandbox_info.get('id'): + # raise ValueError(f"No sandbox found for project {self.project_id}") + # + # # Store sandbox info + # self._sandbox_id = sandbox_info['id'] + # self._sandbox_pass = sandbox_info.get('pass') + # + # # Get or start the sandbox + # self._sandbox = await get_or_start_sandbox(self._sandbox_id) + # + # # # Log URLs if not already printed + # # if not SandboxToolsBase._urls_printed: + # # vnc_link = self._sandbox.get_preview_link(6080) + # # website_link = self._sandbox.get_preview_link(8080) + # + # # vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link) + # # website_url = website_link.url if hasattr(website_link, 'url') else str(website_link) + # + # # print("\033[95m***") + # # print(f"VNC URL: {vnc_url}") + # # print(f"Website URL: {website_url}") + # # print("***\033[0m") + # # SandboxToolsBase._urls_printed = True + # + # except Exception as e: + logger.error(f"Error retrieving sandbox for project {self.project_id}: {str(e)}", exc_info=True) + raise Exception - # async def _ensure_sandbox(self) -> Sandbox: - # """Ensure we have a valid sandbox instance, retrieving it from the project if needed.""" - # if self._sandbox is None: - # try: - # # Get database client - # client = await self.thread_manager.db.client - - # # Get project data - # project = await client.table('projects').select('*').eq('project_id', self.project_id).execute() - # if not project.data or len(project.data) == 0: - # raise ValueError(f"Project {self.project_id} not found") - - # project_data = project.data[0] - # sandbox_info = project_data.get('sandbox', {}) - - # if not sandbox_info.get('id'): - # raise ValueError(f"No sandbox found for project {self.project_id}") - - # # Store sandbox info - # self._sandbox_id = sandbox_info['id'] - # self._sandbox_pass = sandbox_info.get('pass') - - # # Get or start the sandbox - # self._sandbox = await get_or_start_sandbox(self._sandbox_id) - - # # # Log URLs if not already printed - # # if not SandboxToolsBase._urls_printed: - # # vnc_link = self._sandbox.get_preview_link(6080) - # # website_link = self._sandbox.get_preview_link(8080) - - # # vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link) - # # website_url = website_link.url if hasattr(website_link, 'url') else str(website_link) - - # # print("\033[95m***") - # # print(f"VNC URL: {vnc_url}") - # # print(f"Website URL: {website_url}") - # # print("***\033[0m") - # # SandboxToolsBase._urls_printed = True - - # except Exception as e: - # logger.error(f"Error retrieving sandbox for project {self.project_id}: {str(e)}", exc_info=True) - # raise e - - # return self._sandbox + return self._sandbox @property def sandbox(self) -> Sandbox: diff --git a/app/tool/sb_shell_tool.py b/app/tool/sb_shell_tool.py index 0d94c3ff0..291bce4c3 100644 --- a/app/tool/sb_shell_tool.py +++ b/app/tool/sb_shell_tool.py @@ -1,8 +1,9 @@ +import asyncio from typing import Optional, Dict, Any, TypeVar import time from uuid import uuid4 from app.tool.base import ToolResult -from app.daytona.tool_base import SandboxToolsBase +from app.daytona.tool_base import SandboxToolsBase, Sandbox from app.utils.logger import logger Context = TypeVar("Context") @@ -76,6 +77,12 @@ class SandboxShellTool(SandboxToolsBase): }, } + def __init__(self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data): + """Initialize with optional sandbox and thread_id.""" + super().__init__(**data) + if sandbox is not None: + self._sandbox = sandbox + async def _ensure_session(self, session_name: str = "default") -> str: """Ensure a session exists and return its ID.""" if session_name not in self._sessions: @@ -334,7 +341,7 @@ async def execute( Returns: ToolResult with the action's output or error """ - async with self.lock: + async with asyncio.Lock(): try: # Navigation actions if action == "execute_command": @@ -357,6 +364,7 @@ async def execute( logger.error(f"Error executing shell action: {e}") return self.fail_response(f"Error executing shell action: {e}") + async def cleanup(self): """Clean up all sessions.""" for session_name in list(self._sessions.keys()): diff --git a/tests/daytona/test_sb_shell_tools.py b/tests/daytona/test_sb_shell_tools.py index 4b8b1575e..25762a1d6 100644 --- a/tests/daytona/test_sb_shell_tools.py +++ b/tests/daytona/test_sb_shell_tools.py @@ -5,18 +5,24 @@ async def main(): # 创建沙箱和工具 sandbox = create_sandbox(password="123456") - base_url=sandbox.get_preview_link(8000) - print(f"Sandbox base URL: {base_url}") + # base_url=sandbox.get_preview_link(8000) + # print(f"Sandbox base URL: {base_url}") print(f"Sandbox ID: {sandbox.id}") - sb_shell_tool = SandboxShellTool.create_with_sandbox(sandbox) + + vnc_link = sandbox.get_preview_link(6080) + website_link = sandbox.get_preview_link(8080) + print(f"VNC Link: {vnc_link}") + print(f"Website Link: {website_link}") + sb_shell_tool = SandboxShellTool(sandbox) + # 执行截图操作 - result = await sb_shell_tool.execute(action="execute_command", command="ls") + result = await sb_shell_tool.execute(action="execute_command", command="pwd") print(result) # 清理资源(可选) - await sb_shell_tool.cleanup() + # await sb_shell_tool.cleanup() if __name__ == "__main__": print("123") From 7ce31d1c34c65fc09b7dadd573c76de978bc6c60 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Thu, 10 Jul 2025 23:02:50 +0800 Subject: [PATCH 10/33] init sandbox agent --- app/agent/manus.py | 7 -- app/agent/sandbox_agent.py | 174 +++++++++++++++++++++++++++ app/tool/sb_browser_tool.py | 2 +- tests/daytona/test_sb_browser_use.py | 21 +++- 4 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 app/agent/sandbox_agent.py diff --git a/app/agent/manus.py b/app/agent/manus.py index c8ba9cd3a..960ab08a0 100644 --- a/app/agent/manus.py +++ b/app/agent/manus.py @@ -63,16 +63,9 @@ async def create(cls, **kwargs) -> "Manus": """Factory method to create and properly initialize a Manus instance.""" instance = cls(**kwargs) await instance.initialize_mcp_servers() - instance.initialize_sandbox_tools() instance._initialized = True return instance - def initialize_sandbox_tools(self,password="123456") -> None: - sandbox = create_sandbox(password=password) - computer_tool = ComputerUseTool.create_with_sandbox(sandbox) - sandbox_tools=[computer_tool] - self.available_tools.add_tools(*sandbox_tools) - async def initialize_mcp_servers(self) -> None: """Initialize connections to configured MCP servers.""" for server_id, server_config in config.mcp_config.servers.items(): diff --git a/app/agent/sandbox_agent.py b/app/agent/sandbox_agent.py new file mode 100644 index 000000000..cfcf24446 --- /dev/null +++ b/app/agent/sandbox_agent.py @@ -0,0 +1,174 @@ +from typing import Dict, List, Optional + +from pydantic import Field, model_validator + +from app.agent.browser import BrowserContextHelper +from app.agent.toolcall import ToolCallAgent +from app.config import config +from app.logger import logger +from app.prompt.manus import NEXT_STEP_PROMPT, SYSTEM_PROMPT +from app.tool import Terminate, ToolCollection +from app.tool.ask_human import AskHuman +from app.tool.browser_use_tool import BrowserUseTool +from app.tool.mcp import MCPClients, MCPClientTool +from app.tool.python_execute import PythonExecute +from app.tool.str_replace_editor import StrReplaceEditor + +from app.tool.sb_browser_tool import SandboxBrowserTool +from app.daytona.sandbox import create_sandbox + +class SandboxManus(ToolCallAgent): + """A versatile general-purpose agent with support for both local and MCP tools.""" + + name: str = "SandboxManus" + description: str = "A versatile agent that can solve various tasks using multiple sandbox-tools including MCP-based tools" + + system_prompt: str = SYSTEM_PROMPT.format(directory=config.workspace_root) + next_step_prompt: str = NEXT_STEP_PROMPT + + max_observe: int = 10000 + max_steps: int = 20 + + # MCP clients for remote tool access + mcp_clients: MCPClients = Field(default_factory=MCPClients) + + # Add general-purpose tools to the tool collection + available_tools: ToolCollection = Field( + default_factory=lambda: ToolCollection( + # PythonExecute(), + # BrowserUseTool(), + # StrReplaceEditor(), + AskHuman(), + Terminate(), + ) + ) + + special_tool_names: list[str] = Field(default_factory=lambda: [Terminate().name]) + browser_context_helper: Optional[BrowserContextHelper] = None + + # Track connected MCP servers + connected_servers: Dict[str, str] = Field( + default_factory=dict + ) # server_id -> url/command + _initialized: bool = False + + @model_validator(mode="after") + def initialize_helper(self) -> "Manus": + """Initialize basic components synchronously.""" + self.browser_context_helper = BrowserContextHelper(self) + return self + + @classmethod + async def create(cls, **kwargs) -> "Manus": + """Factory method to create and properly initialize a Manus instance.""" + instance = cls(**kwargs) + await instance.initialize_mcp_servers() + instance.initialize_sandbox_tools() + instance._initialized = True + return instance + + def initialize_sandbox_tools(self,password="123456") -> None: + sandbox = create_sandbox(password=password) + computer_tool = SandboxBrowserTool.create_with_sandbox(sandbox) + sandbox_tools=[computer_tool] + self.available_tools.add_tools(*sandbox_tools) + + async def initialize_mcp_servers(self) -> None: + """Initialize connections to configured MCP servers.""" + for server_id, server_config in config.mcp_config.servers.items(): + try: + if server_config.type == "sse": + if server_config.url: + await self.connect_mcp_server(server_config.url, server_id) + logger.info( + f"Connected to MCP server {server_id} at {server_config.url}" + ) + elif server_config.type == "stdio": + if server_config.command: + await self.connect_mcp_server( + server_config.command, + server_id, + use_stdio=True, + stdio_args=server_config.args, + ) + logger.info( + f"Connected to MCP server {server_id} using command {server_config.command}" + ) + except Exception as e: + logger.error(f"Failed to connect to MCP server {server_id}: {e}") + + async def connect_mcp_server( + self, + server_url: str, + server_id: str = "", + use_stdio: bool = False, + stdio_args: List[str] = None, + ) -> None: + """Connect to an MCP server and add its tools.""" + if use_stdio: + await self.mcp_clients.connect_stdio( + server_url, stdio_args or [], server_id + ) + self.connected_servers[server_id or server_url] = server_url + else: + await self.mcp_clients.connect_sse(server_url, server_id) + self.connected_servers[server_id or server_url] = server_url + + # Update available tools with only the new tools from this server + new_tools = [ + tool for tool in self.mcp_clients.tools if tool.server_id == server_id + ] + self.available_tools.add_tools(*new_tools) + + async def disconnect_mcp_server(self, server_id: str = "") -> None: + """Disconnect from an MCP server and remove its tools.""" + await self.mcp_clients.disconnect(server_id) + if server_id: + self.connected_servers.pop(server_id, None) + else: + self.connected_servers.clear() + + # Rebuild available tools without the disconnected server's tools + base_tools = [ + tool + for tool in self.available_tools.tools + if not isinstance(tool, MCPClientTool) + ] + self.available_tools = ToolCollection(*base_tools) + self.available_tools.add_tools(*self.mcp_clients.tools) + + async def cleanup(self): + """Clean up Manus agent resources.""" + if self.browser_context_helper: + await self.browser_context_helper.cleanup_browser() + # Disconnect from all MCP servers only if we were initialized + if self._initialized: + await self.disconnect_mcp_server() + self._initialized = False + + async def think(self) -> bool: + """Process current state and decide next actions with appropriate context.""" + if not self._initialized: + await self.initialize_mcp_servers() + self._initialized = True + + original_prompt = self.next_step_prompt + recent_messages = self.memory.messages[-3:] if self.memory.messages else [] + browser_in_use = any( + tc.function.name == BrowserUseTool().name + for msg in recent_messages + if msg.tool_calls + for tc in msg.tool_calls + ) + + if browser_in_use: + self.next_step_prompt = ( + await self.browser_context_helper.format_next_step_prompt() + ) + + result = await super().think() + + # Restore original prompt + self.next_step_prompt = original_prompt + + return result diff --git a/app/tool/sb_browser_tool.py b/app/tool/sb_browser_tool.py index 5623e3142..d8ca424e8 100644 --- a/app/tool/sb_browser_tool.py +++ b/app/tool/sb_browser_tool.py @@ -223,7 +223,7 @@ async def _execute_browser_action(self, endpoint: str, params: dict = None, meth for field in ["url", "title", "element_count", "pixels_below", "ocr_text", "image_url"]: if field in result: success_response[field] = result[field] - return self.success_response(success_response) if success_response["success"] else self.fail_response(success_response) + return (result,self.success_response(success_response)) if success_response["success"] else self.fail_response(success_response) except json.JSONDecodeError as e: logger.error(f"Failed to parse response JSON: {e}") return self.fail_response(f"Failed to parse response JSON: {e}") diff --git a/tests/daytona/test_sb_browser_use.py b/tests/daytona/test_sb_browser_use.py index 41da2560c..926210553 100644 --- a/tests/daytona/test_sb_browser_use.py +++ b/tests/daytona/test_sb_browser_use.py @@ -13,12 +13,27 @@ async def main(): target="us" ) daytona = Daytona(config) - sandbox = daytona.find_one("84fd0b0c-de80-4d2f-b395-e27317248655") - sandbox.start() + sandbox = daytona.find_one("201415a9-28ad-4b6d-8756-13b1e34a70c3") + if sandbox.state == "archived" or sandbox.state == "stopped": + logger.info(f"Sandbox is in {sandbox.state} state. Starting...") + try: + daytona.start(sandbox) + start_supervisord_session(sandbox) + # Wait a moment for the sandbox to initialize + # sleep(5) + # Refresh sandbox state after starting + # sandbox = daytona.get(sandbox.id) + except Exception as e: + logger.error(f"Error starting sandbox: {e}") + raise e + + # sandbox.start() vnc_link = sandbox.get_preview_link(6080) website_link = sandbox.get_preview_link(8080) + computer_link = sandbox.get_preview_link(8000) print(f"VNC Link: {vnc_link}") print(f"Website Link: {website_link}") + print(f"Computer Link: {computer_link}") # sandbox.start() # base_url=sandbox.get_preview_link(8000) # print(f"Sandbox base URL: {base_url}") @@ -27,7 +42,7 @@ async def main(): tool = SandboxBrowserTool(sandbox) # # 执行截图操作 - result = await tool.execute(action="navigate_to",url="https://www.google.com") + result,tooresult = await tool.execute(action="navigate_to",url="https://www.github.com") print(result) # endpoint = "navigate_to" From e4c4d4a00f69b345a85bc78dac4ee4dff6d70a21 Mon Sep 17 00:00:00 2001 From: white-rm <704748696@qq.com> Date: Sun, 13 Jul 2025 21:09:58 +0800 Subject: [PATCH 11/33] update browser sandbox tool --- app/agent/browser.py | 4 +- app/agent/sandbox_agent.py | 8 ++-- app/daytona/tool_base.py | 29 +++++++++++- app/tool/sb_browser_tool.py | 94 ++++++++++++++++++++++++++++++------- 4 files changed, 113 insertions(+), 22 deletions(-) diff --git a/app/agent/browser.py b/app/agent/browser.py index 92d8ea62f..5ddf34540 100644 --- a/app/agent/browser.py +++ b/app/agent/browser.py @@ -8,7 +8,7 @@ from app.prompt.browser import NEXT_STEP_PROMPT, SYSTEM_PROMPT from app.schema import Message, ToolChoice from app.tool import BrowserUseTool, Terminate, ToolCollection - +from app.tool.sb_browser_tool import SandboxBrowserTool # Avoid circular import if BrowserAgent needs BrowserContextHelper if TYPE_CHECKING: @@ -22,6 +22,8 @@ def __init__(self, agent: "BaseAgent"): async def get_browser_state(self) -> Optional[dict]: browser_tool = self.agent.available_tools.get_tool(BrowserUseTool().name) + if not browser_tool: + browser_tool = self.agent.available_tools.get_tool(SandboxBrowserTool().name) if not browser_tool or not hasattr(browser_tool, "get_current_state"): logger.warning("BrowserUseTool not found or doesn't have get_current_state") return None diff --git a/app/agent/sandbox_agent.py b/app/agent/sandbox_agent.py index cfcf24446..605e99dab 100644 --- a/app/agent/sandbox_agent.py +++ b/app/agent/sandbox_agent.py @@ -1,4 +1,5 @@ -from typing import Dict, List, Optional +import json +from typing import Dict, List, Optional, TYPE_CHECKING from pydantic import Field, model_validator @@ -7,6 +8,7 @@ from app.config import config from app.logger import logger from app.prompt.manus import NEXT_STEP_PROMPT, SYSTEM_PROMPT +from app.schema import Message from app.tool import Terminate, ToolCollection from app.tool.ask_human import AskHuman from app.tool.browser_use_tool import BrowserUseTool @@ -53,13 +55,13 @@ class SandboxManus(ToolCallAgent): _initialized: bool = False @model_validator(mode="after") - def initialize_helper(self) -> "Manus": + def initialize_helper(self) -> "SandboxManus": """Initialize basic components synchronously.""" self.browser_context_helper = BrowserContextHelper(self) return self @classmethod - async def create(cls, **kwargs) -> "Manus": + async def create(cls, **kwargs) -> "SandboxManus": """Factory method to create and properly initialize a Manus instance.""" instance = cls(**kwargs) await instance.initialize_mcp_servers() diff --git a/app/daytona/tool_base.py b/app/daytona/tool_base.py index 5f762952f..92908d5da 100644 --- a/app/daytona/tool_base.py +++ b/app/daytona/tool_base.py @@ -1,5 +1,8 @@ import asyncio -from typing import Optional,ClassVar + +from dataclasses import field, dataclass +from datetime import datetime +from typing import Optional, ClassVar, Dict, Any from pydantic import Field # from app.agentpress.thread_manager import ThreadManager from app.tool.base import BaseTool, ToolResult @@ -8,6 +11,29 @@ from app.utils.logger import logger from app.utils.files_utils import clean_path +@dataclass +class ThreadMessage: + """ + Represents a message to be added to a thread. + """ + thread_id: str + type: str + content: Dict[str, Any] + is_llm_message: bool = False + metadata: Optional[Dict[str, Any]] = None + timestamp: Optional[float] = field(default_factory=lambda: datetime.now().timestamp()) + + def to_dict(self) -> Dict[str, Any]: + """Convert the message to a dictionary for API calls""" + return { + "thread_id": self.thread_id, + "type": self.type, + "content": self.content, + "is_llm_message": self.is_llm_message, + "metadata": self.metadata or {}, + "timestamp": self.timestamp + } + class SandboxToolsBase(BaseTool): """Base class for all sandbox tools that provides project-based sandbox access.""" @@ -25,6 +51,7 @@ class SandboxToolsBase(BaseTool): workspace_path: str = Field(default="/workspace", exclude=True) _sessions: dict[str, str] = {} + class Config: arbitrary_types_allowed = True # Allow non-pydantic types like ThreadManager underscore_attrs_are_private = True diff --git a/app/tool/sb_browser_tool.py b/app/tool/sb_browser_tool.py index d8ca424e8..30ea59056 100644 --- a/app/tool/sb_browser_tool.py +++ b/app/tool/sb_browser_tool.py @@ -5,7 +5,13 @@ from PIL import Image import asyncio from typing import Optional # Add this import for Optional -from app.daytona.tool_base import Sandbox # Ensure Sandbox is imported correctly + +from browser_use.browser.context import BrowserContext +from browser_use.browser.views import BrowserState, TabInfo +from daytona.common.process import ExecuteResponse +from pydantic import Field + +from app.daytona.tool_base import Sandbox, ThreadMessage # Ensure Sandbox is imported correctly # from app.agentpress.tool import ToolResult, openapi_schema, xml_schema from app.tool.base import ToolResult # from app.agentpress.thread_manager import ThreadManager @@ -28,6 +34,9 @@ * Tab management: Switch between tabs or close tabs * Content extraction: Get dropdown options or select dropdown options """ + + +# noinspection PyArgumentList class SandboxBrowserTool(SandboxToolsBase): """Tool for executing tasks in a Daytona sandbox with browser-use capabilities.""" @@ -120,6 +129,8 @@ class SandboxBrowserTool(SandboxToolsBase): "wait": ["seconds"], }, } + browser_message: Optional[ThreadMessage] = Field(default=None, exclude=True) + def __init__(self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data): """Initialize with optional sandbox and thread_id.""" super().__init__(**data) @@ -198,22 +209,27 @@ async def _execute_browser_action(self, endpoint: str, params: dict = None, meth result = json.loads(response.result) result.setdefault("content", "") result.setdefault("role", "assistant") - # if "screenshot_base64" in result: - # screenshot_data = result["screenshot_base64"] - # is_valid, validation_message = self._validate_base64_image(screenshot_data) - # if is_valid: - # image_url = await upload_base64_image(screenshot_data) - # result["image_url"] = image_url - # else: - # logger.warning(f"Screenshot validation failed: {validation_message}") - # result["image_validation_error"] = validation_message - # del result["screenshot_base64"] - # added_message = await self.thread_manager.add_message( - # thread_id=self.thread_id, - # type="browser_state", - # content=result, - # is_llm_message=False - # ) + if "screenshot_base64" in result: + screenshot_data = result["screenshot_base64"] + is_valid, validation_message = self._validate_base64_image(screenshot_data) + if not is_valid: + logger.warning(f"Screenshot validation failed: {validation_message}") + result["image_validation_error"] = validation_message + del result["screenshot_base64"] + + # added_message = await self.thread_manager.add_message( + # thread_id=self.thread_id, + # type="browser_state", + # content=result, + # is_llm_message=False + # ) + message = ThreadMessage( + thread_id=self.thread_id, + type="browser_state", + content=result, + is_llm_message=False + ) + self.browser_message = message success_response = { "success": result.get("success", False), "message": result.get("message", "Browser action completed") @@ -234,6 +250,8 @@ async def _execute_browser_action(self, endpoint: str, params: dict = None, meth logger.error(f"Error executing browser action: {e}") logger.debug(traceback.format_exc()) return self.fail_response(f"Error executing browser action: {e}") + + async def execute( self, action: str, @@ -340,6 +358,48 @@ async def execute( except Exception as e: logger.error(f"Error executing browser action: {e}") return self.fail_response(f"Error executing browser action: {e}") + + async def get_current_state( + self, message: Optional[ThreadMessage] = None + ) -> ToolResult: + """ + Get the current browser state as a ToolResult. + If context is not provided, uses self.context. + """ + try: + # Use provided context or fall back to self.context + message = message or self.browser_message + if not message: + return ToolResult(error="Browser context not initialized") + state = message.content + screenshot = state.get("screenshot_base64") + # Build the state info with all required fields + state_info = { + "url": state.get("url",""), + "title": state.get("title", ""), + "tabs": [tab.model_dump() for tab in state.get("tabs", [])], + "pixels_above": getattr(state, "pixels_above", 0), + "pixels_below": getattr(state, "pixels_below", 0), + "help": "[0], [1], [2], etc., represent clickable indices corresponding to the elements listed. Clicking on these indices will navigate to or interact with the respective content behind them.", + # "interactive_elements": ( + # state.get("element_tree").clickable_elements_to_string() + # if state.get("element_tree") + # else "" + # ), + # "scroll_info": { + # "pixels_above": getattr(state, "pixels_above", 0), + # "pixels_below": getattr(state, "pixels_below", 0), + # "total_height": getattr(state, "pixels_above", 0) + # + getattr(state, "pixels_below", 0) + # } + } + + return ToolResult( + output=json.dumps(state_info, indent=4, ensure_ascii=False), + base64_image=screenshot, + ) + except Exception as e: + return ToolResult(error=f"Failed to get browser state: {str(e)}") # async def cleanup(self): # """Clean up sandbox resources.""" # pass From c016d5627ccb73794390582cda62ea1deedd2d47 Mon Sep 17 00:00:00 2001 From: white-rm <704748696@qq.com> Date: Wed, 16 Jul 2025 00:24:56 +0800 Subject: [PATCH 12/33] update browser sandbox tool message --- app/daytona/tool_base.py | 2 -- app/tool/sb_browser_tool.py | 1 - tests/daytona/test_sb_browser_use.py | 40 ++++++++++++++-------------- 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/app/daytona/tool_base.py b/app/daytona/tool_base.py index 92908d5da..6db3fb816 100644 --- a/app/daytona/tool_base.py +++ b/app/daytona/tool_base.py @@ -7,7 +7,6 @@ # from app.agentpress.thread_manager import ThreadManager from app.tool.base import BaseTool, ToolResult from daytona import Sandbox -from app.daytona.sandbox import get_or_start_sandbox from app.utils.logger import logger from app.utils.files_utils import clean_path @@ -16,7 +15,6 @@ class ThreadMessage: """ Represents a message to be added to a thread. """ - thread_id: str type: str content: Dict[str, Any] is_llm_message: bool = False diff --git a/app/tool/sb_browser_tool.py b/app/tool/sb_browser_tool.py index 30ea59056..e3f47ee96 100644 --- a/app/tool/sb_browser_tool.py +++ b/app/tool/sb_browser_tool.py @@ -224,7 +224,6 @@ async def _execute_browser_action(self, endpoint: str, params: dict = None, meth # is_llm_message=False # ) message = ThreadMessage( - thread_id=self.thread_id, type="browser_state", content=result, is_llm_message=False diff --git a/tests/daytona/test_sb_browser_use.py b/tests/daytona/test_sb_browser_use.py index 926210553..0969e3769 100644 --- a/tests/daytona/test_sb_browser_use.py +++ b/tests/daytona/test_sb_browser_use.py @@ -6,26 +6,26 @@ import json async def main(): # 创建沙箱和工具 - # sandbox = create_sandbox(password="123456") - config = DaytonaConfig( - api_key="dtn_bedf8ed9953f0b5c410c042090e1002a56ba8129b573c92f5607aef04b08c82a", - api_url="https://app.daytona.io/api", - target="us" - ) - daytona = Daytona(config) - sandbox = daytona.find_one("201415a9-28ad-4b6d-8756-13b1e34a70c3") - if sandbox.state == "archived" or sandbox.state == "stopped": - logger.info(f"Sandbox is in {sandbox.state} state. Starting...") - try: - daytona.start(sandbox) - start_supervisord_session(sandbox) - # Wait a moment for the sandbox to initialize - # sleep(5) - # Refresh sandbox state after starting - # sandbox = daytona.get(sandbox.id) - except Exception as e: - logger.error(f"Error starting sandbox: {e}") - raise e + sandbox = create_sandbox(password="123456") + # config = DaytonaConfig( + # api_key="dtn_bedf8ed9953f0b5c410c042090e1002a56ba8129b573c92f5607aef04b08c82a", + # api_url="https://app.daytona.io/api", + # target="us" + # ) + # daytona = Daytona(config) + # sandbox = daytona.find_one("201415a9-28ad-4b6d-8756-13b1e34a70c3") + # if sandbox.state == "archived" or sandbox.state == "stopped": + # logger.info(f"Sandbox is in {sandbox.state} state. Starting...") + # try: + # daytona.start(sandbox) + # start_supervisord_session(sandbox) + # # Wait a moment for the sandbox to initialize + # # sleep(5) + # # Refresh sandbox state after starting + # # sandbox = daytona.get(sandbox.id) + # except Exception as e: + # logger.error(f"Error starting sandbox: {e}") + # raise e # sandbox.start() vnc_link = sandbox.get_preview_link(6080) From 6fb48f4dbe29c21ec61a2f6f9654459bae336bcd Mon Sep 17 00:00:00 2001 From: white-rm <704748696@qq.com> Date: Sat, 19 Jul 2025 23:37:01 +0800 Subject: [PATCH 13/33] update browser sandbox tool 2 --- sandox_main.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 sandox_main.py diff --git a/sandox_main.py b/sandox_main.py new file mode 100644 index 000000000..e69de29bb From 5731b639849f235f495ac2d9edfe199d48942fd7 Mon Sep 17 00:00:00 2001 From: cyb1278588254 <1278588254@qq.com> Date: Sat, 19 Jul 2025 23:48:23 +0800 Subject: [PATCH 14/33] init sb_files_tool & sb_vision_tool --- app/tool/sb_files_tool.py | 431 ++++++++++++------------------------- app/tool/sb_vision_tool.py | 193 ++++++----------- 2 files changed, 207 insertions(+), 417 deletions(-) diff --git a/app/tool/sb_files_tool.py b/app/tool/sb_files_tool.py index b16235b02..b6c54b89c 100644 --- a/app/tool/sb_files_tool.py +++ b/app/tool/sb_files_tool.py @@ -1,12 +1,77 @@ -from agentpress.tool import ToolResult, openapi_schema, xml_schema -from daytona.tool_base import SandboxToolsBase -from utils.files_utils import should_exclude_file, clean_path -from agentpress.thread_manager import ThreadManager -from utils.logger import logger +from dataclasses import Field +from typing import Optional, TypeVar +from app.tool.base import ToolResult +from app.daytona.tool_base import SandboxToolsBase, Sandbox +from app.utils.files_utils import should_exclude_file, clean_path +from app.agentpress.thread_manager import ThreadManager +from app.utils.logger import logger import os +import asyncio + +Context = TypeVar("Context") + +_FILES_DESCRIPTION = """\ +A sandbox-based file system tool that allows file operations in a secure sandboxed environment. +* This tool provides commands for creating, reading, updating, and deleting files in the workspace +* All operations are performed relative to the /workspace directory for security +* Use this when you need to manage files, edit code, or manipulate file contents in a sandbox +* Each action requires specific parameters as defined in the tool's dependencies +Key capabilities include: +* File creation: Create new files with specified content and permissions +* File modification: Replace specific strings or completely rewrite files +* File deletion: Remove files from the workspace +* File reading: Read file contents with optional line range specification +""" class SandboxFilesTool(SandboxToolsBase): - """Tool for executing file system operations in a Daytona sandbox. All operations are performed relative to the /workspace directory.""" + name: str = "sandbox_files" + description: str = _FILES_DESCRIPTION + parameters: dict = { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "create_file", + "str_replace", + "full_file_rewrite", + "delete_file" + ], + "description": "The file operation to perform", + }, + "file_path": { + "type": "string", + "description": "Path to the file, relative to /workspace (e.g., 'src/main.py')", + }, + "file_contents": { + "type": "string", + "description": "Content to write to the file", + }, + "old_str": { + "type": "string", + "description": "Text to be replaced (must appear exactly once)", + }, + "new_str": { + "type": "string", + "description": "Replacement text", + }, + "permissions": { + "type": "string", + "description": "File permissions in octal format (e.g., '644')", + "default": "644" + } + }, + "required": ["action"], + "dependencies": { + "create_file": ["file_path", "file_contents"], + "str_replace": ["file_path", "old_str", "new_str"], + "full_file_rewrite": ["file_path", "file_contents"], + "delete_file": ["file_path"], + }, + } + SNIPPET_LINES: int = Field(default=4, exclude=True) + workspace_path: str = Field(default="/workspace", exclude=True) + sandbox: Optional[Sandbox] = Field(default=None, exclude=True) def __init__(self, project_id: str, thread_manager: ThreadManager): super().__init__(project_id, thread_manager) @@ -64,62 +129,63 @@ async def get_workspace_state(self) -> dict: print(f"Error getting workspace state: {str(e)}") return {} - - # def _get_preview_url(self, file_path: str) -> Optional[str]: - # """Get the preview URL for a file if it's an HTML file.""" - # if file_path.lower().endswith('.html') and self._sandbox_url: - # return f"{self._sandbox_url}/{(file_path.replace('/workspace/', ''))}" - # return None - - @openapi_schema({ - "type": "function", - "function": { - "name": "create_file", - "description": "Create a new file with the provided contents at a given path in the workspace. The path must be relative to /workspace (e.g., 'src/main.py' for /workspace/src/main.py)", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to the file to be created, relative to /workspace (e.g., 'src/main.py')" - }, - "file_contents": { - "type": "string", - "description": "The content to write to the file" - }, - "permissions": { - "type": "string", - "description": "File permissions in octal format (e.g., '644')", - "default": "644" - } - }, - "required": ["file_path", "file_contents"] - } - } - }) - @xml_schema( - tag_name="create-file", - mappings=[ - {"param_name": "file_path", "node_type": "attribute", "path": "."}, - {"param_name": "file_contents", "node_type": "content", "path": "."} - ], - example=''' - - - src/main.py - - # This is the file content - def main(): - print("Hello, World!") - - if __name__ == "__main__": - main() - - - - ''' - ) - async def create_file(self, file_path: str, file_contents: str, permissions: str = "644") -> ToolResult: + async def execute( + self, + action: str, + file_path: Optional[str] = None, + file_contents: Optional[str] = None, + old_str: Optional[str] = None, + new_str: Optional[str] = None, + permissions: Optional[str] = "644", + **kwargs + ) -> ToolResult: + """ + Execute a file operation in the sandbox environment. + Args: + action: The file operation to perform + file_path: Path to the file relative to /workspace + file_contents: Content to write to the file + old_str: Text to be replaced (for str_replace) + new_str: Replacement text (for str_replace) + permissions: File permissions in octal format + Returns: + ToolResult with the operation's output or error + """ + async with self.lock: + try: + # File creation + if action == "create_file": + if not file_path or not file_contents: + return self.fail_response("file_path and file_contents are required for create_file") + return await self._create_file(file_path, file_contents, permissions) + + # String replacement + elif action == "str_replace": + if not file_path or not old_str or not new_str: + return self.fail_response("file_path, old_str, and new_str are required for str_replace") + return await self._str_replace(file_path, old_str, new_str) + + # Full file rewrite + elif action == "full_file_rewrite": + if not file_path or not file_contents: + return self.fail_response("file_path and file_contents are required for full_file_rewrite") + return await self._full_file_rewrite(file_path, file_contents, permissions) + + # File deletion + elif action == "delete_file": + if not file_path: + return self.fail_response("file_path is required for delete_file") + return await self._delete_file(file_path) + + else: + return self.fail_response(f"Unknown action: {action}") + + except Exception as e: + logger.error(f"Error executing file action: {e}") + return self.fail_response(f"Error executing file action: {e}") + + async def _create_file(self, file_path: str, file_contents: str, permissions: str = "644") -> ToolResult: + """Create a new file with the provided contents""" try: # Ensure sandbox is initialized await self._ensure_sandbox() @@ -127,7 +193,7 @@ async def create_file(self, file_path: str, file_contents: str, permissions: str file_path = self.clean_path(file_path) full_path = f"{self.workspace_path}/{file_path}" if self._file_exists(full_path): - return self.fail_response(f"File '{file_path}' already exists. Use update_file to modify existing files.") + return self.fail_response(f"File '{file_path}' already exists. Use full_file_rewrite to modify existing files.") # Create parent directories if needed parent_dir = '/'.join(full_path.split('/')[:-1]) @@ -154,49 +220,8 @@ async def create_file(self, file_path: str, file_contents: str, permissions: str except Exception as e: return self.fail_response(f"Error creating file: {str(e)}") - @openapi_schema({ - "type": "function", - "function": { - "name": "str_replace", - "description": "Replace specific text in a file. The file path must be relative to /workspace (e.g., 'src/main.py' for /workspace/src/main.py). Use this when you need to replace a unique string that appears exactly once in the file.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to the target file, relative to /workspace (e.g., 'src/main.py')" - }, - "old_str": { - "type": "string", - "description": "Text to be replaced (must appear exactly once)" - }, - "new_str": { - "type": "string", - "description": "Replacement text" - } - }, - "required": ["file_path", "old_str", "new_str"] - } - } - }) - @xml_schema( - tag_name="str-replace", - mappings=[ - {"param_name": "file_path", "node_type": "attribute", "path": "."}, - {"param_name": "old_str", "node_type": "element", "path": "old_str"}, - {"param_name": "new_str", "node_type": "element", "path": "new_str"} - ], - example=''' - - - src/main.py - text to replace (must appear exactly once in the file) - replacement text that will be inserted instead - - - ''' - ) - async def str_replace(self, file_path: str, old_str: str, new_str: str) -> ToolResult: + async def _str_replace(self, file_path: str, old_str: str, new_str: str) -> ToolResult: + """Replace specific text in a file""" try: # Ensure sandbox is initialized await self._ensure_sandbox() @@ -227,64 +252,15 @@ async def str_replace(self, file_path: str, old_str: str, new_str: str) -> ToolR end_line = replacement_line + self.SNIPPET_LINES + new_str.count('\n') snippet = '\n'.join(new_content.split('\n')[start_line:end_line + 1]) - # Get preview URL if it's an HTML file - # preview_url = self._get_preview_url(file_path) message = f"Replacement successful." - # if preview_url: - # message += f"\n\nYou can preview this HTML file at: {preview_url}" return self.success_response(message) except Exception as e: return self.fail_response(f"Error replacing string: {str(e)}") - @openapi_schema({ - "type": "function", - "function": { - "name": "full_file_rewrite", - "description": "Completely rewrite an existing file with new content. The file path must be relative to /workspace (e.g., 'src/main.py' for /workspace/src/main.py). Use this when you need to replace the entire file content or make extensive changes throughout the file.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to the file to be rewritten, relative to /workspace (e.g., 'src/main.py')" - }, - "file_contents": { - "type": "string", - "description": "The new content to write to the file, replacing all existing content" - }, - "permissions": { - "type": "string", - "description": "File permissions in octal format (e.g., '644')", - "default": "644" - } - }, - "required": ["file_path", "file_contents"] - } - } - }) - @xml_schema( - tag_name="full-file-rewrite", - mappings=[ - {"param_name": "file_path", "node_type": "attribute", "path": "."}, - {"param_name": "file_contents", "node_type": "content", "path": "."} - ], - example=''' - - - src/main.py - - This completely replaces the entire file content. - Use when making major changes to a file or when the changes - are too extensive for str-replace. - All previous content will be lost and replaced with this text. - - - - ''' - ) - async def full_file_rewrite(self, file_path: str, file_contents: str, permissions: str = "644") -> ToolResult: + async def _full_file_rewrite(self, file_path: str, file_contents: str, permissions: str = "644") -> ToolResult: + """Completely rewrite an existing file with new content""" try: # Ensure sandbox is initialized await self._ensure_sandbox() @@ -313,37 +289,8 @@ async def full_file_rewrite(self, file_path: str, file_contents: str, permission except Exception as e: return self.fail_response(f"Error rewriting file: {str(e)}") - @openapi_schema({ - "type": "function", - "function": { - "name": "delete_file", - "description": "Delete a file at the given path. The path must be relative to /workspace (e.g., 'src/main.py' for /workspace/src/main.py)", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to the file to be deleted, relative to /workspace (e.g., 'src/main.py')" - } - }, - "required": ["file_path"] - } - } - }) - @xml_schema( - tag_name="delete-file", - mappings=[ - {"param_name": "file_path", "node_type": "attribute", "path": "."} - ], - example=''' - - - src/main.py - - - ''' - ) - async def delete_file(self, file_path: str) -> ToolResult: + async def _delete_file(self, file_path: str) -> ToolResult: + """Delete a file at the given path""" try: # Ensure sandbox is initialized await self._ensure_sandbox() @@ -358,105 +305,11 @@ async def delete_file(self, file_path: str) -> ToolResult: except Exception as e: return self.fail_response(f"Error deleting file: {str(e)}") - # @openapi_schema({ - # "type": "function", - # "function": { - # "name": "read_file", - # "description": "Read and return the contents of a file. This tool is essential for verifying data, checking file contents, and analyzing information. Always use this tool to read file contents before processing or analyzing data. The file path must be relative to /workspace.", - # "parameters": { - # "type": "object", - # "properties": { - # "file_path": { - # "type": "string", - # "description": "Path to the file to read, relative to /workspace (e.g., 'src/main.py' for /workspace/src/main.py). Must be a valid file path within the workspace." - # }, - # "start_line": { - # "type": "integer", - # "description": "Optional starting line number (1-based). Use this to read specific sections of large files. If not specified, reads from the beginning of the file.", - # "default": 1 - # }, - # "end_line": { - # "type": "integer", - # "description": "Optional ending line number (inclusive). Use this to read specific sections of large files. If not specified, reads to the end of the file.", - # "default": None - # } - # }, - # "required": ["file_path"] - # } - # } - # }) - # @xml_schema( - # tag_name="read-file", - # mappings=[ - # {"param_name": "file_path", "node_type": "attribute", "path": "."}, - # {"param_name": "start_line", "node_type": "attribute", "path": ".", "required": False}, - # {"param_name": "end_line", "node_type": "attribute", "path": ".", "required": False} - # ], - # example=''' - # - # - # - - # - # - # - - # - # - # - - # - # - # - # ''' - # ) - # async def read_file(self, file_path: str, start_line: int = 1, end_line: Optional[int] = None) -> ToolResult: - # """Read file content with optional line range specification. - - # Args: - # file_path: Path to the file relative to /workspace - # start_line: Starting line number (1-based), defaults to 1 - # end_line: Ending line number (inclusive), defaults to None (end of file) - - # Returns: - # ToolResult containing: - # - Success: File content and metadata - # - Failure: Error message if file doesn't exist or is binary - # """ - # try: - # file_path = self.clean_path(file_path) - # full_path = f"{self.workspace_path}/{file_path}" - - # if not self._file_exists(full_path): - # return self.fail_response(f"File '{file_path}' does not exist") - - # # Download and decode file content - # content = self.sandbox.fs.download_file(full_path).decode() - - # # Split content into lines - # lines = content.split('\n') - # total_lines = len(lines) - - # # Handle line range if specified - # if start_line > 1 or end_line is not None: - # # Convert to 0-based indices - # start_idx = max(0, start_line - 1) - # end_idx = end_line if end_line is not None else total_lines - # end_idx = min(end_idx, total_lines) # Ensure we don't exceed file length - - # # Extract the requested lines - # content = '\n'.join(lines[start_idx:end_idx]) - - # return self.success_response({ - # "content": content, - # "file_path": file_path, - # "start_line": start_line, - # "end_line": end_line if end_line is not None else total_lines, - # "total_lines": total_lines - # }) - - # except UnicodeDecodeError: - # return self.fail_response(f"File '{file_path}' appears to be binary and cannot be read as text") - # except Exception as e: - # return self.fail_response(f"Error reading file: {str(e)}") + async def cleanup(self): + """Clean up sandbox resources.""" + pass + @classmethod + def create_with_context(cls, context: Context) -> "SandboxFilesTool[Context]": + """Factory method to create a SandboxFilesTool with a specific context.""" + raise NotImplementedError("create_with_context not implemented for SandboxFilesTool") \ No newline at end of file diff --git a/app/tool/sb_vision_tool.py b/app/tool/sb_vision_tool.py index 2cd356e94..9266f623c 100644 --- a/app/tool/sb_vision_tool.py +++ b/app/tool/sb_vision_tool.py @@ -1,206 +1,143 @@ import os import base64 import mimetypes -from typing import Optional, Tuple +import json +from typing import Optional from io import BytesIO from PIL import Image -from agentpress.tool import ToolResult, openapi_schema, xml_schema -from daytona.tool_base import SandboxToolsBase -from agentpress.thread_manager import ThreadManager -import json - -# Add common image MIME types if mimetypes module is limited -mimetypes.add_type("image/webp", ".webp") -mimetypes.add_type("image/jpeg", ".jpg") -mimetypes.add_type("image/jpeg", ".jpeg") -mimetypes.add_type("image/png", ".png") -mimetypes.add_type("image/gif", ".gif") +from app.tool.base import ToolResult +from app.agentpress.thread_manager import ThreadManager +from app.daytona.tool_base import SandboxToolsBase -# Maximum file size in bytes (e.g., 10MB for original, 5MB for compressed) +# 最大文件大小(原图10MB,压缩后5MB) MAX_IMAGE_SIZE = 10 * 1024 * 1024 MAX_COMPRESSED_SIZE = 5 * 1024 * 1024 -# Compression settings +# 压缩设置 DEFAULT_MAX_WIDTH = 1920 DEFAULT_MAX_HEIGHT = 1080 DEFAULT_JPEG_QUALITY = 85 DEFAULT_PNG_COMPRESS_LEVEL = 6 -class SandboxVisionTool(SandboxToolsBase): - """Tool for allowing the agent to 'see' images within the sandbox.""" - - def __init__(self, project_id: str, thread_id: str, thread_manager: ThreadManager): - super().__init__(project_id, thread_manager) - self.thread_id = thread_id - # Make thread_manager accessible within the tool instance - self.thread_manager = thread_manager +_VISION_DESCRIPTION = """ +A sandbox-based vision tool that allows the agent to read image files inside the sandbox using the see_image action. +* Only the see_image action is supported, with the parameter being the relative path of the image under /workspace. +* The image will be compressed and converted to base64 for use in subsequent context. +* Supported formats: JPG, PNG, GIF, WEBP. Maximum size: 10MB. +""" - def compress_image(self, image_bytes: bytes, mime_type: str, file_path: str) -> Tuple[bytes, str]: - """Compress an image to reduce its size while maintaining reasonable quality. +class SandboxVisionTool(SandboxToolsBase): + name: str = "sandbox_vision" + description: str = _VISION_DESCRIPTION + parameters: dict = { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["see_image"], + "description": "要执行的视觉动作,目前仅支持 see_image" + }, + "file_path": { + "type": "string", + "description": "图片在 /workspace 下的相对路径,如 'screenshots/image.png'" + } + }, + "required": ["action", "file_path"], + "dependencies": { + "see_image": ["file_path"] + } + } - Args: - image_bytes: Original image bytes - mime_type: MIME type of the image - file_path: Path to the image file (for logging) + # def __init__(self, project_id: str, thread_id: str, thread_manager: ThreadManager): + # super().__init__(project_id=project_id, thread_manager=thread_manager) + # self.thread_id = thread_id + # self.thread_manager = thread_manager - Returns: - Tuple of (compressed_bytes, new_mime_type) - """ + def compress_image(self, image_bytes: bytes, mime_type: str, file_path: str): + """压缩图片,保持合理质量。""" try: - # Open image from bytes img = Image.open(BytesIO(image_bytes)) - - # Convert RGBA to RGB if necessary (for JPEG) if img.mode in ('RGBA', 'LA', 'P'): - # Create a white background background = Image.new('RGB', img.size, (255, 255, 255)) if img.mode == 'P': img = img.convert('RGBA') background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) img = background - - # Calculate new dimensions while maintaining aspect ratio width, height = img.size if width > DEFAULT_MAX_WIDTH or height > DEFAULT_MAX_HEIGHT: ratio = min(DEFAULT_MAX_WIDTH / width, DEFAULT_MAX_HEIGHT / height) new_width = int(width * ratio) new_height = int(height * ratio) img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - print(f"[SeeImage] Resized image from {width}x{height} to {new_width}x{new_height}") - - # Save to bytes with compression output = BytesIO() - - # Determine output format based on original mime type if mime_type == 'image/gif': - # Keep GIFs as GIFs to preserve animation img.save(output, format='GIF', optimize=True) output_mime = 'image/gif' elif mime_type == 'image/png': - # Compress PNG img.save(output, format='PNG', optimize=True, compress_level=DEFAULT_PNG_COMPRESS_LEVEL) output_mime = 'image/png' else: - # Convert everything else to JPEG for better compression img.save(output, format='JPEG', quality=DEFAULT_JPEG_QUALITY, optimize=True) output_mime = 'image/jpeg' - compressed_bytes = output.getvalue() - - # Log compression results - original_size = len(image_bytes) - compressed_size = len(compressed_bytes) - compression_ratio = (1 - compressed_size / original_size) * 100 - print(f"[SeeImage] Compressed '{file_path}' from {original_size / 1024:.1f}KB to {compressed_size / 1024:.1f}KB ({compression_ratio:.1f}% reduction)") - return compressed_bytes, output_mime - except Exception as e: - print(f"[SeeImage] Failed to compress image: {str(e)}. Using original.") return image_bytes, mime_type - @openapi_schema({ - "type": "function", - "function": { - "name": "see_image", - "description": "Allows the agent to 'see' an image file located in the /workspace directory. Provide the relative path to the image. The image will be compressed before sending to reduce token usage. The image content will be made available in the next turn's context.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "The relative path to the image file within the /workspace directory (e.g., 'screenshots/image.png'). Supported formats: JPG, PNG, GIF, WEBP. Max size: 10MB." - } - }, - "required": ["file_path"] - } - } - }) - @xml_schema( - tag_name="see-image", - mappings=[ - {"param_name": "file_path", "node_type": "attribute", "path": "."} - ], - example=''' - - - - docs/diagram.png - - - ''' - ) - async def see_image(self, file_path: str) -> ToolResult: - """Reads an image file, compresses it, converts it to base64, and adds it as a temporary message.""" + async def execute(self, action: str, file_path: Optional[str] = None, **kwargs) -> ToolResult: + """ + 执行视觉动作,目前仅支持 see_image。 + 参数: + action: 必须为 'see_image' + file_path: 图片相对路径 + """ + if action != "see_image": + return self.fail_response(f"未知的视觉动作: {action}") + if not file_path: + return self.fail_response("file_path 参数不能为空") try: - # Ensure sandbox is initialized await self._ensure_sandbox() - - # Clean and construct full path cleaned_path = self.clean_path(file_path) full_path = f"{self.workspace_path}/{cleaned_path}" - - # Check if file exists and get info try: file_info = self.sandbox.fs.get_file_info(full_path) if file_info.is_dir: - return self.fail_response(f"Path '{cleaned_path}' is a directory, not an image file.") - except Exception as e: - return self.fail_response(f"Image file not found at path: '{cleaned_path}'") - - # Check file size + return self.fail_response(f"路径 '{cleaned_path}' 是目录,不是图片文件。") + except Exception: + return self.fail_response(f"图片文件未找到: '{cleaned_path}'") if file_info.size > MAX_IMAGE_SIZE: - return self.fail_response(f"Image file '{cleaned_path}' is too large ({file_info.size / (1024*1024):.2f}MB). Maximum size is {MAX_IMAGE_SIZE / (1024*1024)}MB.") - - # Read image file content + return self.fail_response(f"图片文件 '{cleaned_path}' 过大 ({file_info.size / (1024*1024):.2f}MB),最大允许 {MAX_IMAGE_SIZE / (1024*1024)}MB。") try: image_bytes = self.sandbox.fs.download_file(full_path) - except Exception as e: - return self.fail_response(f"Could not read image file: {cleaned_path}") - - # Determine MIME type + except Exception: + return self.fail_response(f"无法读取图片文件: {cleaned_path}") mime_type, _ = mimetypes.guess_type(full_path) if not mime_type or not mime_type.startswith('image/'): - # Basic fallback based on extension if mimetypes fails ext = os.path.splitext(cleaned_path)[1].lower() if ext == '.jpg' or ext == '.jpeg': mime_type = 'image/jpeg' elif ext == '.png': mime_type = 'image/png' elif ext == '.gif': mime_type = 'image/gif' elif ext == '.webp': mime_type = 'image/webp' else: - return self.fail_response(f"Unsupported or unknown image format for file: '{cleaned_path}'. Supported: JPG, PNG, GIF, WEBP.") - - # Compress the image + return self.fail_response(f"不支持或未知的图片格式: '{cleaned_path}'。支持: JPG, PNG, GIF, WEBP。") compressed_bytes, compressed_mime_type = self.compress_image(image_bytes, mime_type, cleaned_path) - - # Check if compressed image is still too large if len(compressed_bytes) > MAX_COMPRESSED_SIZE: - return self.fail_response(f"Image file '{cleaned_path}' is still too large after compression ({len(compressed_bytes) / (1024*1024):.2f}MB). Maximum compressed size is {MAX_COMPRESSED_SIZE / (1024*1024)}MB.") - - # Convert to base64 + return self.fail_response(f"图片文件 '{cleaned_path}' 压缩后仍过大 ({len(compressed_bytes) / (1024*1024):.2f}MB),最大允许 {MAX_COMPRESSED_SIZE / (1024*1024)}MB。") base64_image = base64.b64encode(compressed_bytes).decode('utf-8') - - # Prepare the temporary message content image_context_data = { "mime_type": compressed_mime_type, "base64": base64_image, - "file_path": cleaned_path, # Include path for context + "file_path": cleaned_path, "original_size": file_info.size, "compressed_size": len(compressed_bytes) } - - # Add the temporary message using the thread_manager callback - # Use a distinct type like 'image_context' await self.thread_manager.add_message( thread_id=self.thread_id, - type="image_context", # Use a specific type for this - content=image_context_data, # Store the dict directly - is_llm_message=False # This is context generated by a tool + type="image_context", + content=image_context_data, + is_llm_message=False ) - - # Inform the agent the image will be available next turn - return self.success_response(f"Successfully loaded and compressed the image '{cleaned_path}' (reduced from {file_info.size / 1024:.1f}KB to {len(compressed_bytes) / 1024:.1f}KB).") - + return self.success_response(f"成功加载并压缩图片 '{cleaned_path}' (由 {file_info.size / 1024:.1f}KB 压缩到 {len(compressed_bytes) / 1024:.1f}KB)。") except Exception as e: - return self.fail_response(f"An unexpected error occurred while trying to see the image: {str(e)}") + return self.fail_response(f"see_image 执行异常: {str(e)}") \ No newline at end of file From 398ae434eda2796f93406647d531cd85168de217 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Sun, 20 Jul 2025 02:17:08 +0800 Subject: [PATCH 15/33] add main for sandbox_agent --- app/agent/sandbox_agent.py | 1 + sandbox_main.py | 36 ++++++++++++++++++++++++++++++++++++ sandox_main.py | 0 3 files changed, 37 insertions(+) create mode 100644 sandbox_main.py delete mode 100644 sandox_main.py diff --git a/app/agent/sandbox_agent.py b/app/agent/sandbox_agent.py index 605e99dab..ba1cb339f 100644 --- a/app/agent/sandbox_agent.py +++ b/app/agent/sandbox_agent.py @@ -71,6 +71,7 @@ async def create(cls, **kwargs) -> "SandboxManus": def initialize_sandbox_tools(self,password="123456") -> None: sandbox = create_sandbox(password=password) + print(f"VNC link: {sandbox.get_preview_link(6080)}") computer_tool = SandboxBrowserTool.create_with_sandbox(sandbox) sandbox_tools=[computer_tool] self.available_tools.add_tools(*sandbox_tools) diff --git a/sandbox_main.py b/sandbox_main.py new file mode 100644 index 000000000..4f8e0a54f --- /dev/null +++ b/sandbox_main.py @@ -0,0 +1,36 @@ +import argparse +import asyncio + +from app.agent.sandbox_agent import SandboxManus +from app.logger import logger + + +async def main(): + # Parse command line arguments + parser = argparse.ArgumentParser(description="Run Manus agent with a prompt") + parser.add_argument( + "--prompt", type=str, required=False, help="Input prompt for the agent" + ) + args = parser.parse_args() + + # Create and initialize Manus agent + agent = await SandboxManus.create() + try: + # Use command line prompt if provided, otherwise ask for input + prompt = args.prompt if args.prompt else input("Enter your prompt: ") + if not prompt.strip(): + logger.warning("Empty prompt provided.") + return + + logger.warning("Processing your request...") + await agent.run(prompt) + logger.info("Request processing completed.") + except KeyboardInterrupt: + logger.warning("Operation interrupted.") + finally: + # Ensure agent resources are cleaned up before exiting + await agent.cleanup() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sandox_main.py b/sandox_main.py deleted file mode 100644 index e69de29bb..000000000 From 3301d54625702c64b5198a2900b03af3a3ffb95a Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Sun, 20 Jul 2025 16:02:18 +0800 Subject: [PATCH 16/33] support use existing sandbox by id --- app/agent/sandbox_agent.py | 69 +++++++++++++++-- app/config.py | 2 + config/config.example-daytona.toml | 115 +++++++++++++++++++++++++++++ config/config.example.toml | 8 -- 4 files changed, 178 insertions(+), 16 deletions(-) create mode 100644 config/config.example-daytona.toml diff --git a/app/agent/sandbox_agent.py b/app/agent/sandbox_agent.py index ba1cb339f..6eccb3cc8 100644 --- a/app/agent/sandbox_agent.py +++ b/app/agent/sandbox_agent.py @@ -17,7 +17,8 @@ from app.tool.str_replace_editor import StrReplaceEditor from app.tool.sb_browser_tool import SandboxBrowserTool -from app.daytona.sandbox import create_sandbox +from app.daytona.sandbox import create_sandbox,get_or_start_sandbox +import daytona class SandboxManus(ToolCallAgent): """A versatile general-purpose agent with support for both local and MCP tools.""" @@ -53,6 +54,7 @@ class SandboxManus(ToolCallAgent): default_factory=dict ) # server_id -> url/command _initialized: bool = False + sandbox_link: Optional[dict[str, dict[str, str]]] = Field(default_factory=dict) @model_validator(mode="after") def initialize_helper(self) -> "SandboxManus": @@ -65,16 +67,67 @@ async def create(cls, **kwargs) -> "SandboxManus": """Factory method to create and properly initialize a Manus instance.""" instance = cls(**kwargs) await instance.initialize_mcp_servers() - instance.initialize_sandbox_tools() + await instance.initialize_sandbox_tools() instance._initialized = True return instance - def initialize_sandbox_tools(self,password="123456") -> None: - sandbox = create_sandbox(password=password) - print(f"VNC link: {sandbox.get_preview_link(6080)}") - computer_tool = SandboxBrowserTool.create_with_sandbox(sandbox) - sandbox_tools=[computer_tool] - self.available_tools.add_tools(*sandbox_tools) + async def initialize_sandbox_tools(self, sandbox_id: str = config.daytona.sandbox_id, password:str = config.daytona.VNC_password) -> None: + try: + if sandbox_id: + sandbox = await get_or_start_sandbox(sandbox_id) + + # Initialize sandbox_link if not exists + if not self.sandbox_link: + self.sandbox_link = {} + + # Check if URLs are already cached + if sandbox_id in self.sandbox_link: + vnc_url = self.sandbox_link[sandbox_id]["vnc"] + website_url = self.sandbox_link[sandbox_id]["website"] + logger.info(f"Using cached URLs - VNC: {vnc_url}, Website: {website_url}") + else: + # Get URLs from sandbox and cache them + vnc_link = sandbox.get_preview_link(6080) + website_link = sandbox.get_preview_link(8080) + vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link) + website_url = website_link.url if hasattr(website_link, 'url') else str(website_link) + + # Cache the URLs + self.sandbox_link[sandbox_id] = { + "vnc": str(vnc_url), + "website": str(website_url) + } + logger.info(f"Cached new URLs - VNC: {vnc_url}, Website: {website_url}") + + logger.info(f"VNC URL: {vnc_url}") + logger.info(f"Website URL: {website_url}") + else: + # 创建新沙箱 + if password: + sandbox = create_sandbox(password=password) + else: + raise ValueError("Sandbox ID or password must be provided") + vnc_link = sandbox.get_preview_link(6080) + website_link = sandbox.get_preview_link(8080) + vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link) + website_url = website_link.url if hasattr(website_link, 'url') else str(website_link) + + # Get the actual sandbox_id from the created sandbox + actual_sandbox_id = sandbox.id if hasattr(sandbox, 'id') else 'new_sandbox' + if not self.sandbox_link: + self.sandbox_link = {} + self.sandbox_link[actual_sandbox_id] = { + "vnc": vnc_url, + "website": website_url, + } + logger.info(f"VNC URL: {vnc_url}") + logger.info(f"Website URL: {website_url}") + computer_tool = SandboxBrowserTool.create_with_sandbox(sandbox) + self.available_tools.add_tools(computer_tool) + + except Exception as e: + logger.error(f"Error initializing sandbox tools: {e}") + raise async def initialize_mcp_servers(self) -> None: """Initialize connections to configured MCP servers.""" diff --git a/app/config.py b/app/config.py index fd2c8fdfe..3a360bef2 100644 --- a/app/config.py +++ b/app/config.py @@ -111,6 +111,8 @@ class DaytonaSettings(BaseModel): daytona_target: Optional[str] = Field("us", description="enum ['asia', 'eu', 'us']") sandbox_image_name: Optional[str]= Field("kortix/suna:0.1.3", description="") sandbox_entrypoint: Optional[str]= Field("/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf", description="") + sandbox_id: Optional[str] = Field(None, description="ID of the daytona sandbox to use, if any") + VNC_password: Optional[str] = Field("123456", description="VNC password for the vnc service in sandbox") class MCPServerConfig(BaseModel): """Configuration for a single MCP server""" diff --git a/config/config.example-daytona.toml b/config/config.example-daytona.toml new file mode 100644 index 000000000..de065eb48 --- /dev/null +++ b/config/config.example-daytona.toml @@ -0,0 +1,115 @@ +# Global LLM configuration +[llm] +model = "claude-3-7-sonnet-20250219" # The LLM model to use +base_url = "https://api.anthropic.com/v1/" # API endpoint URL +api_key = "YOUR_API_KEY" # Your API key +max_tokens = 8192 # Maximum number of tokens in the response +temperature = 0.0 # Controls randomness + +# [llm] # Amazon Bedrock +# api_type = "aws" # Required +# model = "us.anthropic.claude-3-7-sonnet-20250219-v1:0" # Bedrock supported modelID +# base_url = "bedrock-runtime.us-west-2.amazonaws.com" # Not used now +# max_tokens = 8192 +# temperature = 1.0 +# api_key = "bear" # Required but not used for Bedrock + +# [llm] #AZURE OPENAI: +# api_type= 'azure' +# model = "YOUR_MODEL_NAME" #"gpt-4o-mini" +# base_url = "{YOUR_AZURE_ENDPOINT.rstrip('/')}/openai/deployments/{AZURE_DEPLOYMENT_ID}" +# api_key = "AZURE API KEY" +# max_tokens = 8096 +# temperature = 0.0 +# api_version="AZURE API VERSION" #"2024-08-01-preview" + +# [llm] #OLLAMA: +# api_type = 'ollama' +# model = "llama3.2" +# base_url = "http://localhost:11434/v1" +# api_key = "ollama" +# max_tokens = 4096 +# temperature = 0.0 + +# Optional configuration for specific LLM models +[llm.vision] +model = "claude-3-7-sonnet-20250219" # The vision model to use +base_url = "https://api.anthropic.com/v1/" # API endpoint URL for vision model +api_key = "YOUR_API_KEY" # Your API key for vision model +max_tokens = 8192 # Maximum number of tokens in the response +temperature = 0.0 # Controls randomness for vision model + +# [llm.vision] #OLLAMA VISION: +# api_type = 'ollama' +# model = "llama3.2-vision" +# base_url = "http://localhost:11434/v1" +# api_key = "ollama" +# max_tokens = 4096 +# temperature = 0.0 + +# Optional configuration for specific browser configuration +# [browser] +# Whether to run browser in headless mode (default: false) +#headless = false +# Disable browser security features (default: true) +#disable_security = true +# Extra arguments to pass to the browser +#extra_chromium_args = [] +# Path to a Chrome instance to use to connect to your normal browser +# e.g. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +#chrome_instance_path = "" +# Connect to a browser instance via WebSocket +#wss_url = "" +# Connect to a browser instance via CDP +#cdp_url = "" + +# Optional configuration, Proxy settings for the browser +# [browser.proxy] +# server = "http://proxy-server:port" +# username = "proxy-username" +# password = "proxy-password" + +# Optional configuration, Search settings. +# [search] +# Search engine for agent to use. Default is "Google", can be set to "Baidu" or "DuckDuckGo" or "Bing". +#engine = "Google" +# Fallback engine order. Default is ["DuckDuckGo", "Baidu", "Bing"] - will try in this order after primary engine fails. +#fallback_engines = ["DuckDuckGo", "Baidu", "Bing"] +# Seconds to wait before retrying all engines again when they all fail due to rate limits. Default is 60. +#retry_delay = 60 +# Maximum number of times to retry all engines when all fail. Default is 3. +#max_retries = 3 +# Language code for search results. Options: "en" (English), "zh" (Chinese), etc. +#lang = "en" +# Country code for search results. Options: "us" (United States), "cn" (China), etc. +#country = "us" + + +## Sandbox configuration +#[sandbox] +#use_sandbox = false +#image = "python:3.12-slim" +#work_dir = "/workspace" +#memory_limit = "1g" # 512m +#cpu_limit = 2.0 +#timeout = 300 +#network_enabled = true + +# Daytona configuration +#[daytona] +#daytona_api_key = "" +#daytona_server_url = "https://app.daytona.io/api" +#daytona_target = "us" +#sandbox_image_name = "kortix/suna:0.1.3" +#sandbox_entrypoint = +#sandbox_id = +#VNC_password = + +# MCP (Model Context Protocol) configuration +[mcp] +server_reference = "app.mcp.server" # default server module reference + +# Optional Runflow configuration +# Your can add additional agents into run-flow workflow to solve different-type tasks. +[runflow] +use_data_analysis_agent = false # The Data Analysi Agent to solve various data analysis tasks diff --git a/config/config.example.toml b/config/config.example.toml index 0b9e13058..7693ee8df 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -95,14 +95,6 @@ temperature = 0.0 # Controls randomness for vision mode #timeout = 300 #network_enabled = true -# Daytona configuration -[daytona] -daytona_api_key = "" -daytona_server_url = "https://app.daytona.io/api" -daytona_target = "us" -#sandbox_image_name = -#sandbox_entrypoint = - # MCP (Model Context Protocol) configuration [mcp] server_reference = "app.mcp.server" # default server module reference From 024651349546b622f47f1dc74bf96cff8e4f735e Mon Sep 17 00:00:00 2001 From: cyb1278588254 <1278588254@qq.com> Date: Sun, 20 Jul 2025 17:26:15 +0800 Subject: [PATCH 17/33] debug sb_files_tool & sb_vision_tool --- app/agent/sandbox_agent.py | 15 +++++++++-- app/services/llm.py | 4 +-- app/tool/sb_files_tool.py | 19 +++++++------- app/tool/sb_vision_tool.py | 18 ++++++++++---- tests/daytona/test_sb_files_tool.py | 37 ++++++++++++++++++++++++++++ tests/daytona/test_sb_vision_tool.py | 34 +++++++++++++++++++++++++ 6 files changed, 109 insertions(+), 18 deletions(-) create mode 100644 tests/daytona/test_sb_files_tool.py create mode 100644 tests/daytona/test_sb_vision_tool.py diff --git a/app/agent/sandbox_agent.py b/app/agent/sandbox_agent.py index 6eccb3cc8..cc4072fc2 100644 --- a/app/agent/sandbox_agent.py +++ b/app/agent/sandbox_agent.py @@ -14,6 +14,9 @@ from app.tool.browser_use_tool import BrowserUseTool from app.tool.mcp import MCPClients, MCPClientTool from app.tool.python_execute import PythonExecute +from app.tool.sb_files_tool import SandboxFilesTool +from app.tool.sb_shell_tool import SandboxShellTool +from app.tool.sb_vision_tool import SandboxVisionTool from app.tool.str_replace_editor import StrReplaceEditor from app.tool.sb_browser_tool import SandboxBrowserTool @@ -122,8 +125,16 @@ async def initialize_sandbox_tools(self, sandbox_id: str = config.daytona.sandbo } logger.info(f"VNC URL: {vnc_url}") logger.info(f"Website URL: {website_url}") - computer_tool = SandboxBrowserTool.create_with_sandbox(sandbox) - self.available_tools.add_tools(computer_tool) + + sb_tools = [ + SandboxBrowserTool(sandbox), + SandboxFilesTool(sandbox), + SandboxShellTool(sandbox), + SandboxVisionTool(sandbox) + ] + # for sb_tool in sb_tools: + # self.available_tools.add_tool(sb_tool) + self.available_tools.add_tools(*sb_tools) except Exception as e: logger.error(f"Error initializing sandbox tools: {e}") diff --git a/app/services/llm.py b/app/services/llm.py index c74917045..f525f244a 100644 --- a/app/services/llm.py +++ b/app/services/llm.py @@ -16,8 +16,8 @@ import asyncio from openai import OpenAIError import litellm -from utils.logger import logger -from utils.config import config +# from utils.logger import logger +# from utils.config import config # litellm.set_verbose=True litellm.modify_params=True diff --git a/app/tool/sb_files_tool.py b/app/tool/sb_files_tool.py index b6c54b89c..a9601f67a 100644 --- a/app/tool/sb_files_tool.py +++ b/app/tool/sb_files_tool.py @@ -1,9 +1,9 @@ -from dataclasses import Field +from pydantic import Field from typing import Optional, TypeVar from app.tool.base import ToolResult from app.daytona.tool_base import SandboxToolsBase, Sandbox from app.utils.files_utils import should_exclude_file, clean_path -from app.agentpress.thread_manager import ThreadManager +# from app.agentpress.thread_manager import ThreadManager from app.utils.logger import logger import os import asyncio @@ -70,13 +70,14 @@ class SandboxFilesTool(SandboxToolsBase): }, } SNIPPET_LINES: int = Field(default=4, exclude=True) - workspace_path: str = Field(default="/workspace", exclude=True) - sandbox: Optional[Sandbox] = Field(default=None, exclude=True) + # workspace_path: str = Field(default="/workspace", exclude=True) + # sandbox: Optional[Sandbox] = Field(default=None, exclude=True) - def __init__(self, project_id: str, thread_manager: ThreadManager): - super().__init__(project_id, thread_manager) - self.SNIPPET_LINES = 4 # Number of context lines to show around edits - self.workspace_path = "/workspace" # Ensure we're always operating in /workspace + def __init__(self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data): + """Initialize with optional sandbox and thread_id.""" + super().__init__(**data) + if sandbox is not None: + self._sandbox = sandbox def clean_path(self, path: str) -> str: """Clean and normalize a path to be relative to /workspace""" @@ -151,7 +152,7 @@ async def execute( Returns: ToolResult with the operation's output or error """ - async with self.lock: + async with asyncio.Lock(): try: # File creation if action == "create_file": diff --git a/app/tool/sb_vision_tool.py b/app/tool/sb_vision_tool.py index 9266f623c..7b45c5054 100644 --- a/app/tool/sb_vision_tool.py +++ b/app/tool/sb_vision_tool.py @@ -1,3 +1,4 @@ +from pydantic import Field import os import base64 import mimetypes @@ -7,8 +8,7 @@ from PIL import Image from app.tool.base import ToolResult -from app.agentpress.thread_manager import ThreadManager -from app.daytona.tool_base import SandboxToolsBase +from app.daytona.tool_base import SandboxToolsBase, Sandbox, ThreadMessage # 最大文件大小(原图10MB,压缩后5MB) MAX_IMAGE_SIZE = 10 * 1024 * 1024 @@ -54,6 +54,13 @@ class SandboxVisionTool(SandboxToolsBase): # self.thread_id = thread_id # self.thread_manager = thread_manager + vision_message: Optional[ThreadMessage] = Field(default=None, exclude=True) + def __init__(self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data): + """Initialize with optional sandbox and thread_id.""" + super().__init__(**data) + if sandbox is not None: + self._sandbox = sandbox + def compress_image(self, image_bytes: bytes, mime_type: str, file_path: str): """压缩图片,保持合理质量。""" try: @@ -132,12 +139,13 @@ async def execute(self, action: str, file_path: Optional[str] = None, **kwargs) "original_size": file_info.size, "compressed_size": len(compressed_bytes) } - await self.thread_manager.add_message( - thread_id=self.thread_id, + message = ThreadMessage( type="image_context", content=image_context_data, is_llm_message=False ) - return self.success_response(f"成功加载并压缩图片 '{cleaned_path}' (由 {file_info.size / 1024:.1f}KB 压缩到 {len(compressed_bytes) / 1024:.1f}KB)。") + self.vision_message = message + # return self.success_response(f"成功加载并压缩图片 '{cleaned_path}' (由 {file_info.size / 1024:.1f}KB 压缩到 {len(compressed_bytes) / 1024:.1f}KB)。") + return self.success_response(f"成功加载并压缩图片 '{cleaned_path}',压缩后的内容为:{base64_image}") except Exception as e: return self.fail_response(f"see_image 执行异常: {str(e)}") \ No newline at end of file diff --git a/tests/daytona/test_sb_files_tool.py b/tests/daytona/test_sb_files_tool.py new file mode 100644 index 000000000..ccf2ed247 --- /dev/null +++ b/tests/daytona/test_sb_files_tool.py @@ -0,0 +1,37 @@ +from app.tool.sb_files_tool import SandboxFilesTool +from app.daytona.sandbox import create_sandbox +import asyncio + +async def main(): + # 创建沙箱和工具 + sandbox = create_sandbox(password="123456") + # base_url=sandbox.get_preview_link(8000) + # print(f"Sandbox base URL: {base_url}") + + print(f"Sandbox ID: {sandbox.id}") + + vnc_link = sandbox.get_preview_link(6080) + website_link = sandbox.get_preview_link(8080) + print(f"VNC Link: {vnc_link}") + print(f"Website Link: {website_link}") + + sb_files_tool = SandboxFilesTool(sandbox) + + + # 执行创建文件操作 + result = await sb_files_tool.execute(action="create_file", file_path="src/a.txt", file_contents="aaaaa1111") + print(result) + # 执行字符串替换操作 + # result = await sb_files_tool.execute(action="str_replace", file_path="src/a.txt", old_str="aaaaa", new_str="bbbbb") + # print(result) + # 执行全文件重写操作 + result = await sb_files_tool.execute(action="full_file_rewrite", file_path="src/a.txt", file_contents="1234567") + print(result) + # 执行删除文件操作 + # result = await sb_files_tool.execute(action="delete_file", file_path="src/a.txt") + # print(result) + # 清理资源(可选) + # await sb_shell_tool.cleanup() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/tests/daytona/test_sb_vision_tool.py b/tests/daytona/test_sb_vision_tool.py new file mode 100644 index 000000000..99151256d --- /dev/null +++ b/tests/daytona/test_sb_vision_tool.py @@ -0,0 +1,34 @@ +from app.tool.sb_shell_tool import SandboxShellTool +from app.tool.sb_vision_tool import SandboxVisionTool +from app.daytona.sandbox import create_sandbox +import asyncio + + + +async def main(): + # 创建沙箱和工具 + sandbox = create_sandbox(password="123456") + # base_url=sandbox.get_preview_link(8000) + # print(f"Sandbox base URL: {base_url}") + + print(f"Sandbox ID: {sandbox.id}") + + vnc_link = sandbox.get_preview_link(6080) + website_link = sandbox.get_preview_link(8080) + print(f"VNC Link: {vnc_link}") + print(f"Website Link: {website_link}") + sb_shell_tool = SandboxShellTool(sandbox) + sb_vision_tool = SandboxVisionTool(sandbox) + + # 执行终端命令 + result = await sb_shell_tool.execute(action="execute_command", command="curl -O http://img.netbian.com/file/2025/0716/091412RIFD9.jpg") + print(result) + # 执行see_image操作 + result = await sb_vision_tool.execute(action="see_image", file_path="091412RIFD9.jpg") + print(result) + + # 清理资源(可选) + # await sb_shell_tool.cleanup() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file From 4a28a7912c3436cdb4b6168df1bc6895783ddb3d Mon Sep 17 00:00:00 2001 From: SNHuan <2776382362@qq.com> Date: Tue, 22 Jul 2025 14:29:16 +0800 Subject: [PATCH 18/33] add Crawl4aiTool --- app/tool/__init__.py | 2 + app/tool/crawl4ai.py | 249 +++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 3 files changed, 252 insertions(+) create mode 100644 app/tool/crawl4ai.py diff --git a/app/tool/__init__.py b/app/tool/__init__.py index b1d25e24c..df15a0aba 100644 --- a/app/tool/__init__.py +++ b/app/tool/__init__.py @@ -7,6 +7,7 @@ from app.tool.terminate import Terminate from app.tool.tool_collection import ToolCollection from app.tool.web_search import WebSearch +from app.tool.crawl4ai import Crawl4aiTool __all__ = [ @@ -19,4 +20,5 @@ "ToolCollection", "CreateChatCompletion", "PlanningTool", + "Crawl4aiTool" ] diff --git a/app/tool/crawl4ai.py b/app/tool/crawl4ai.py new file mode 100644 index 000000000..71987492f --- /dev/null +++ b/app/tool/crawl4ai.py @@ -0,0 +1,249 @@ + +""" +Crawl4AI Web Crawler Tool for OpenManus + +This tool integrates Crawl4AI, a high-performance web crawler designed for LLMs and AI agents, +providing fast, precise, and AI-ready data extraction with clean Markdown generation. +""" + +import asyncio +from typing import Union, List +from urllib.parse import urlparse + +from app.logger import logger +from app.tool.base import BaseTool, ToolResult + + +class Crawl4aiTool(BaseTool): + """ + Web crawler tool powered by Crawl4AI. + + Provides clean markdown extraction optimized for AI processing. + """ + + name: str = "crawl4ai" + description: str = """Web crawler that extracts clean, AI-ready content from web pages. + + Features: + - Extracts clean markdown content optimized for LLMs + - Handles JavaScript-heavy sites and dynamic content + - Supports multiple URLs in a single request + - Fast and reliable with built-in error handling + + Perfect for content analysis, research, and feeding web content to AI models.""" + + parameters: dict = { + "type": "object", + "properties": { + "urls": { + "type": "array", + "items": {"type": "string"}, + "description": "(required) List of URLs to crawl. Can be a single URL or multiple URLs.", + "minItems": 1 + }, + "timeout": { + "type": "integer", + "description": "(optional) Timeout in seconds for each URL. Default is 30.", + "default": 30, + "minimum": 5, + "maximum": 120 + }, + "bypass_cache": { + "type": "boolean", + "description": "(optional) Whether to bypass cache and fetch fresh content. Default is false.", + "default": False + }, + "word_count_threshold": { + "type": "integer", + "description": "(optional) Minimum word count for content blocks. Default is 10.", + "default": 10, + "minimum": 1 + } + }, + "required": ["urls"] + } + + async def execute( + self, + urls: Union[str, List[str]], + timeout: int = 30, + bypass_cache: bool = False, + word_count_threshold: int = 10, + ) -> ToolResult: + """ + Execute web crawling for the specified URLs. + + Args: + urls: Single URL string or list of URLs to crawl + timeout: Timeout in seconds for each URL + bypass_cache: Whether to bypass cache + word_count_threshold: Minimum word count for content blocks + + Returns: + ToolResult with crawl results + """ + # Normalize URLs to list + if isinstance(urls, str): + url_list = [urls] + else: + url_list = urls + + # Validate URLs + valid_urls = [] + for url in url_list: + if self._is_valid_url(url): + valid_urls.append(url) + else: + logger.warning(f"Invalid URL skipped: {url}") + + if not valid_urls: + return ToolResult(error="No valid URLs provided") + + try: + # Import crawl4ai components + from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode + + # Configure browser settings + browser_config = BrowserConfig( + headless=True, + verbose=False, + browser_type="chromium", + ignore_https_errors=True, + java_script_enabled=True + ) + + # Configure crawler settings + run_config = CrawlerRunConfig( + cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED, + word_count_threshold=word_count_threshold, + process_iframes=True, + remove_overlay_elements=True, + excluded_tags=['script', 'style'], + page_timeout=timeout * 1000, # Convert to milliseconds + verbose=False, + wait_until="domcontentloaded" + ) + + results = [] + successful_count = 0 + failed_count = 0 + + # Process each URL + async with AsyncWebCrawler(config=browser_config) as crawler: + for url in valid_urls: + try: + logger.info(f"🕷️ Crawling URL: {url}") + start_time = asyncio.get_event_loop().time() + + result = await crawler.arun(url=url, config=run_config) + + end_time = asyncio.get_event_loop().time() + execution_time = end_time - start_time + + if result.success: + # Count words in markdown + word_count = 0 + if hasattr(result, 'markdown') and result.markdown: + word_count = len(result.markdown.split()) + + # Count links + links_count = 0 + if hasattr(result, 'links') and result.links: + internal_links = result.links.get('internal', []) + external_links = result.links.get('external', []) + links_count = len(internal_links) + len(external_links) + + # Count images + images_count = 0 + if hasattr(result, 'media') and result.media: + images = result.media.get('images', []) + images_count = len(images) + + results.append({ + 'url': url, + 'success': True, + 'status_code': getattr(result, 'status_code', 200), + 'title': result.metadata.get('title') if result.metadata else None, + 'markdown': result.markdown if hasattr(result, 'markdown') else None, + 'word_count': word_count, + 'links_count': links_count, + 'images_count': images_count, + 'execution_time': execution_time + }) + successful_count += 1 + logger.info(f"✅ Successfully crawled {url} in {execution_time:.2f}s") + + else: + results.append({ + 'url': url, + 'success': False, + 'error_message': getattr(result, 'error_message', 'Unknown error'), + 'execution_time': execution_time + }) + failed_count += 1 + logger.warning(f"❌ Failed to crawl {url}") + + except Exception as e: + error_msg = f"Error crawling {url}: {str(e)}" + logger.error(error_msg) + results.append({ + 'url': url, + 'success': False, + 'error_message': error_msg + }) + failed_count += 1 + + # Format output + output_lines = [f"🕷️ Crawl4AI Results Summary:"] + output_lines.append(f"📊 Total URLs: {len(valid_urls)}") + output_lines.append(f"✅ Successful: {successful_count}") + output_lines.append(f"❌ Failed: {failed_count}") + output_lines.append("") + + for i, result in enumerate(results, 1): + output_lines.append(f"{i}. {result['url']}") + + if result['success']: + output_lines.append(f" ✅ Status: Success (HTTP {result.get('status_code', 'N/A')})") + if result.get('title'): + output_lines.append(f" 📄 Title: {result['title']}") + + if result.get('markdown'): + # Show first 300 characters of markdown content + content_preview = result['markdown'] + if len(result['markdown']) > 300: + content_preview += "..." + output_lines.append(f" 📝 Content: {content_preview}") + + output_lines.append(f" 📊 Stats: {result.get('word_count', 0)} words, {result.get('links_count', 0)} links, {result.get('images_count', 0)} images") + + if result.get('execution_time'): + output_lines.append(f" ⏱️ Time: {result['execution_time']:.2f}s") + else: + output_lines.append(f" ❌ Status: Failed") + if result.get('error_message'): + output_lines.append(f" 🚫 Error: {result['error_message']}") + + output_lines.append("") + + return ToolResult(output="\n".join(output_lines)) + + except ImportError as e: + error_msg = "Crawl4AI is not installed. Please install it with: pip install crawl4ai" + logger.error(error_msg) + return ToolResult(error=error_msg) + except Exception as e: + error_msg = f"Crawl4AI execution failed: {str(e)}" + logger.error(error_msg) + return ToolResult(error=error_msg) + + def _is_valid_url(self, url: str) -> bool: + """Validate if a URL is properly formatted.""" + try: + result = urlparse(url) + return all([result.scheme, result.netloc]) and result.scheme in ['http', 'https'] + except Exception: + return False + + + diff --git a/requirements.txt b/requirements.txt index 3324283fc..aa7e6dc93 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,6 +36,7 @@ boto3~=1.37.18 requests~=2.32.3 beautifulsoup4~=4.13.3 +crawl4ai~=0.6.3 huggingface-hub~=0.29.2 setuptools~=75.8.0 From 50f56f570820048cb747923e61641b553600de1d Mon Sep 17 00:00:00 2001 From: white-rm <704748696@qq.com> Date: Tue, 22 Jul 2025 23:50:13 +0800 Subject: [PATCH 19/33] eliminate sandox redundant information --- app/agentpress/context_manager.py | 298 ------- app/agentpress/thread_manager.py | 787 ------------------ app/daytona/README.md | 10 +- app/daytona/docker/README.md | 1 - app/daytona/docker/docker-compose.yml | 2 +- app/daytona/tool_base.py | 1 - app/tool/computer_use_tool.py | 670 ---------------- app/tool/sb_browser_tool.py | 1072 +------------------------ app/tool/sb_deploy_tool.py | 147 ---- app/tool/sb_expose_tool.py | 126 --- app/tool/sb_files_tool.py | 2 - app/tool/sb_shell_tool.py | 423 ---------- app/tool/sb_vision_tool.py | 1 - app/tool/web_search_tool.py | 395 --------- 14 files changed, 5 insertions(+), 3930 deletions(-) delete mode 100644 app/agentpress/context_manager.py delete mode 100644 app/agentpress/thread_manager.py delete mode 100644 app/daytona/docker/README.md delete mode 100644 app/tool/sb_deploy_tool.py delete mode 100644 app/tool/sb_expose_tool.py delete mode 100644 app/tool/web_search_tool.py diff --git a/app/agentpress/context_manager.py b/app/agentpress/context_manager.py deleted file mode 100644 index 39398dee4..000000000 --- a/app/agentpress/context_manager.py +++ /dev/null @@ -1,298 +0,0 @@ -""" -Context Management for AgentPress Threads. - -This module handles token counting and thread summarization to prevent -reaching the context window limitations of LLM models. -""" - -import json -from typing import List, Dict, Any, Optional - -from litellm import token_counter, completion_cost -from app.services.supabase import DBConnection -from app.services.llm import make_llm_api_call -from app.utils.logger import logger - -# Constants for token management -DEFAULT_TOKEN_THRESHOLD = 120000 # 80k tokens threshold for summarization -SUMMARY_TARGET_TOKENS = 10000 # Target ~10k tokens for the summary message -RESERVE_TOKENS = 5000 # Reserve tokens for new messages - -class ContextManager: - """Manages thread context including token counting and summarization.""" - - def __init__(self, token_threshold: int = DEFAULT_TOKEN_THRESHOLD): - """Initialize the ContextManager. - - Args: - token_threshold: Token count threshold to trigger summarization - """ - self.db = DBConnection() - self.token_threshold = token_threshold - - async def get_thread_token_count(self, thread_id: str) -> int: - """Get the current token count for a thread using LiteLLM. - - Args: - thread_id: ID of the thread to analyze - - Returns: - The total token count for relevant messages in the thread - """ - logger.debug(f"Getting token count for thread {thread_id}") - - try: - # Get messages for the thread - messages = await self.get_messages_for_summarization(thread_id) - - if not messages: - logger.debug(f"No messages found for thread {thread_id}") - return 0 - - # Use litellm's token_counter for accurate model-specific counting - # This is much more accurate than the SQL-based estimation - token_count = token_counter(model="gpt-4", messages=messages) - - logger.info(f"Thread {thread_id} has {token_count} tokens (calculated with litellm)") - return token_count - - except Exception as e: - logger.error(f"Error getting token count: {str(e)}") - return 0 - - async def get_messages_for_summarization(self, thread_id: str) -> List[Dict[str, Any]]: - """Get all LLM messages from the thread that need to be summarized. - - This gets messages after the most recent summary or all messages if - no summary exists. Unlike get_llm_messages, this includes ALL messages - since the last summary, even if we're generating a new summary. - - Args: - thread_id: ID of the thread to get messages from - - Returns: - List of message objects to summarize - """ - logger.debug(f"Getting messages for summarization for thread {thread_id}") - client = await self.db.client - - try: - # Find the most recent summary message - summary_result = await client.table('messages').select('created_at') \ - .eq('thread_id', thread_id) \ - .eq('type', 'summary') \ - .eq('is_llm_message', True) \ - .order('created_at', desc=True) \ - .limit(1) \ - .execute() - - # Get messages after the most recent summary or all messages if no summary - if summary_result.data and len(summary_result.data) > 0: - last_summary_time = summary_result.data[0]['created_at'] - logger.debug(f"Found last summary at {last_summary_time}") - - # Get all messages after the summary, but NOT including the summary itself - messages_result = await client.table('messages').select('*') \ - .eq('thread_id', thread_id) \ - .eq('is_llm_message', True) \ - .gt('created_at', last_summary_time) \ - .order('created_at') \ - .execute() - else: - logger.debug("No previous summary found, getting all messages") - # Get all messages - messages_result = await client.table('messages').select('*') \ - .eq('thread_id', thread_id) \ - .eq('is_llm_message', True) \ - .order('created_at') \ - .execute() - - # Parse the message content if needed - messages = [] - for msg in messages_result.data: - # Skip existing summary messages - we don't want to summarize summaries - if msg.get('type') == 'summary': - logger.debug(f"Skipping summary message from {msg.get('created_at')}") - continue - - # Parse content if it's a string - content = msg['content'] - if isinstance(content, str): - try: - content = json.loads(content) - except json.JSONDecodeError: - pass # Keep as string if not valid JSON - - # Ensure we have the proper format for the LLM - if 'role' not in content and 'type' in msg: - # Convert message type to role if needed - role = msg['type'] - if role == 'assistant' or role == 'user' or role == 'system' or role == 'tool': - content = {'role': role, 'content': content} - - messages.append(content) - - logger.info(f"Got {len(messages)} messages to summarize for thread {thread_id}") - return messages - - except Exception as e: - logger.error(f"Error getting messages for summarization: {str(e)}", exc_info=True) - return [] - - async def create_summary( - self, - thread_id: str, - messages: List[Dict[str, Any]], - model: str = "gpt-4o-mini" - ) -> Optional[Dict[str, Any]]: - """Generate a summary of conversation messages. - - Args: - thread_id: ID of the thread to summarize - messages: Messages to summarize - model: LLM model to use for summarization - - Returns: - Summary message object or None if summarization failed - """ - if not messages: - logger.warning("No messages to summarize") - return None - - logger.info(f"Creating summary for thread {thread_id} with {len(messages)} messages") - - # Create system message with summarization instructions - system_message = { - "role": "system", - "content": f"""You are a specialized summarization assistant. Your task is to create a concise but comprehensive summary of the conversation history. - -The summary should: -1. Preserve all key information including decisions, conclusions, and important context -2. Include any tools that were used and their results -3. Maintain chronological order of events -4. Be presented as a narrated list of key points with section headers -5. Include only factual information from the conversation (no new information) -6. Be concise but detailed enough that the conversation can continue with this summary as context - -VERY IMPORTANT: This summary will replace older parts of the conversation in the LLM's context window, so ensure it contains ALL key information and LATEST STATE OF THE CONVERSATION - SO WE WILL KNOW HOW TO PICK UP WHERE WE LEFT OFF. - - -THE CONVERSATION HISTORY TO SUMMARIZE IS AS FOLLOWS: -=============================================================== -==================== CONVERSATION HISTORY ==================== -{messages} -==================== END OF CONVERSATION HISTORY ==================== -=============================================================== -""" - } - - try: - # Call LLM to generate summary - response = await make_llm_api_call( - model_name=model, - messages=[system_message, {"role": "user", "content": "PLEASE PROVIDE THE SUMMARY NOW."}], - temperature=0, - max_tokens=SUMMARY_TARGET_TOKENS, - stream=False - ) - - if response and hasattr(response, 'choices') and response.choices: - summary_content = response.choices[0].message.content - - # Track token usage - try: - token_count = token_counter(model=model, messages=[{"role": "user", "content": summary_content}]) - cost = completion_cost(model=model, prompt="", completion=summary_content) - logger.info(f"Summary generated with {token_count} tokens at cost ${cost:.6f}") - except Exception as e: - logger.error(f"Error calculating token usage: {str(e)}") - - # Format the summary message with clear beginning and end markers - formatted_summary = f""" -======== CONVERSATION HISTORY SUMMARY ======== - -{summary_content} - -======== END OF SUMMARY ======== - -The above is a summary of the conversation history. The conversation continues below. -""" - - # Format the summary message - summary_message = { - "role": "user", - "content": formatted_summary - } - - return summary_message - else: - logger.error("Failed to generate summary: Invalid response") - return None - - except Exception as e: - logger.error(f"Error creating summary: {str(e)}", exc_info=True) - return None - - async def check_and_summarize_if_needed( - self, - thread_id: str, - add_message_callback, - model: str = "gpt-4o-mini", - force: bool = False - ) -> bool: - """Check if thread needs summarization and summarize if so. - - Args: - thread_id: ID of the thread to check - add_message_callback: Callback to add the summary message to the thread - model: LLM model to use for summarization - force: Whether to force summarization regardless of token count - - Returns: - True if summarization was performed, False otherwise - """ - try: - # Get token count using LiteLLM (accurate model-specific counting) - token_count = await self.get_thread_token_count(thread_id) - - # If token count is below threshold and not forcing, no summarization needed - if token_count < self.token_threshold and not force: - logger.debug(f"Thread {thread_id} has {token_count} tokens, below threshold {self.token_threshold}") - return False - - # Log reason for summarization - if force: - logger.info(f"Forced summarization of thread {thread_id} with {token_count} tokens") - else: - logger.info(f"Thread {thread_id} exceeds token threshold ({token_count} >= {self.token_threshold}), summarizing...") - - # Get messages to summarize - messages = await self.get_messages_for_summarization(thread_id) - - # If there are too few messages, don't summarize - if len(messages) < 3: - logger.info(f"Thread {thread_id} has too few messages ({len(messages)}) to summarize") - return False - - # Create summary - summary = await self.create_summary(thread_id, messages, model) - - if summary: - # Add summary message to thread - await add_message_callback( - thread_id=thread_id, - type="summary", - content=summary, - is_llm_message=True, - metadata={"token_count": token_count} - ) - - logger.info(f"Successfully added summary to thread {thread_id}") - return True - else: - logger.error(f"Failed to create summary for thread {thread_id}") - return False - - except Exception as e: - logger.error(f"Error in check_and_summarize_if_needed: {str(e)}", exc_info=True) - return False diff --git a/app/agentpress/thread_manager.py b/app/agentpress/thread_manager.py deleted file mode 100644 index addc5716b..000000000 --- a/app/agentpress/thread_manager.py +++ /dev/null @@ -1,787 +0,0 @@ -""" -Conversation thread management system for AgentPress. - -This module provides comprehensive conversation management, including: -- Thread creation and persistence -- Message handling with support for text and images -- Tool registration and execution -- LLM interaction with streaming support -- Error handling and cleanup -- Context summarization to manage token limits -""" - -import json -from typing import List, Dict, Any, Optional, Type, Union, AsyncGenerator, Literal -from app.services.llm import make_llm_api_call -from app.agentpress.tool import Tool -from app.agentpress.tool_registry import ToolRegistry -from app.agentpress.context_manager import ContextManager -from app.agentpress.response_processor import ( - ResponseProcessor, - ProcessorConfig -) -from app.services.supabase import DBConnection -from app.utils.logger import logger -from langfuse.client import StatefulGenerationClient, StatefulTraceClient -from app.services.langfuse import langfuse -import datetime -from litellm import token_counter - -# Type alias for tool choice -ToolChoice = Literal["auto", "required", "none"] - -class ThreadManager: - """Manages conversation threads with LLM models and tool execution. - - Provides comprehensive conversation management, handling message threading, - tool registration, and LLM interactions with support for both standard and - XML-based tool execution patterns. - """ - - def __init__(self, trace: Optional[StatefulTraceClient] = None, is_agent_builder: bool = False, target_agent_id: Optional[str] = None, agent_config: Optional[dict] = None): - """Initialize ThreadManager. - - Args: - trace: Optional trace client for logging - is_agent_builder: Whether this is an agent builder session - target_agent_id: ID of the agent being built (if in agent builder mode) - agent_config: Optional agent configuration with version information - """ - self.db = DBConnection() - self.tool_registry = ToolRegistry() - self.trace = trace - self.is_agent_builder = is_agent_builder - self.target_agent_id = target_agent_id - self.agent_config = agent_config - if not self.trace: - self.trace = langfuse.trace(name="anonymous:thread_manager") - self.response_processor = ResponseProcessor( - tool_registry=self.tool_registry, - add_message_callback=self.add_message, - trace=self.trace, - is_agent_builder=self.is_agent_builder, - target_agent_id=self.target_agent_id, - agent_config=self.agent_config - ) - 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, dict) and "interactive_elements" 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 - if isinstance(parsed_content, dict) and "interactive_elements" in content: return True - except (json.JSONDecodeError, TypeError): - pass - return False - - def _compress_message(self, msg_content: Union[str, dict], message_id: Optional[str] = None, max_length: int = 3000) -> Union[str, dict]: - """Compress the message content.""" - # print("max_length", max_length) - if isinstance(msg_content, str): - if len(msg_content) > max_length: - return msg_content[:max_length] + "... (truncated)" + f"\n\nmessage_id \"{message_id}\"\nUse expand-message tool to see contents" - else: - return msg_content - elif isinstance(msg_content, dict): - if len(json.dumps(msg_content)) > max_length: - return json.dumps(msg_content)[:max_length] + "... (truncated)" + f"\n\nmessage_id \"{message_id}\"\nUse expand-message tool to see contents" - else: - return msg_content - - def _safe_truncate(self, msg_content: Union[str, dict], max_length: int = 100000) -> Union[str, dict]: - """Truncate the message content safely by removing the middle portion.""" - max_length = min(max_length, 100000) - if isinstance(msg_content, str): - if len(msg_content) > max_length: - # Calculate how much to keep from start and end - keep_length = max_length - 150 # Reserve space for truncation message - start_length = keep_length // 2 - end_length = keep_length - start_length - - start_part = msg_content[:start_length] - end_part = msg_content[-end_length:] if end_length > 0 else "" - - return start_part + f"\n\n... (middle truncated) ...\n\n" + end_part + f"\n\nThis message is too long, repeat relevant information in your response to remember it" - else: - return msg_content - elif isinstance(msg_content, dict): - json_str = json.dumps(msg_content) - if len(json_str) > max_length: - # Calculate how much to keep from start and end - keep_length = max_length - 150 # Reserve space for truncation message - start_length = keep_length // 2 - end_length = keep_length - start_length - - start_part = json_str[:start_length] - end_part = json_str[-end_length:] if end_length > 0 else "" - - return start_part + f"\n\n... (middle truncated) ...\n\n" + end_part + f"\n\nThis message is too long, repeat relevant information in your response to remember it" - else: - return msg_content - - def _compress_tool_result_messages(self, messages: List[Dict[str, Any]], llm_model: str, max_tokens: Optional[int], token_threshold: Optional[int] = 1000) -> List[Dict[str, Any]]: - """Compress the tool result messages except the most recent one.""" - uncompressed_total_token_count = token_counter(model=llm_model, messages=messages) - - if uncompressed_total_token_count > (max_tokens or (100 * 1000)): - _i = 0 # Count the number of ToolResult messages - for msg in reversed(messages): # Start from the end and work backwards - 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 > token_threshold: # If the message is too long - if _i > 1: # If this is not the most recent ToolResult message - message_id = msg.get('message_id') # Get the message_id - if message_id: - msg["content"] = self._compress_message(msg["content"], message_id, token_threshold * 3) - else: - logger.warning(f"UNEXPECTED: Message has no message_id {str(msg)[:100]}") - else: - msg["content"] = self._safe_truncate(msg["content"], int(max_tokens * 2)) - return messages - - def _compress_user_messages(self, messages: List[Dict[str, Any]], llm_model: str, max_tokens: Optional[int], token_threshold: Optional[int] = 1000) -> List[Dict[str, Any]]: - """Compress the user messages except the most recent one.""" - uncompressed_total_token_count = token_counter(model=llm_model, messages=messages) - - if uncompressed_total_token_count > (max_tokens or (100 * 1000)): - _i = 0 # Count the number of User messages - for msg in reversed(messages): # Start from the end and work backwards - if msg.get('role') == 'user': # Only compress User messages - _i += 1 # Count the number of User messages - msg_token_count = token_counter(messages=[msg]) # Count the number of tokens in the message - if msg_token_count > token_threshold: # If the message is too long - if _i > 1: # If this is not the most recent User message - message_id = msg.get('message_id') # Get the message_id - if message_id: - msg["content"] = self._compress_message(msg["content"], message_id, token_threshold * 3) - else: - logger.warning(f"UNEXPECTED: Message has no message_id {str(msg)[:100]}") - else: - msg["content"] = self._safe_truncate(msg["content"], int(max_tokens * 2)) - return messages - - def _compress_assistant_messages(self, messages: List[Dict[str, Any]], llm_model: str, max_tokens: Optional[int], token_threshold: Optional[int] = 1000) -> List[Dict[str, Any]]: - """Compress the assistant messages except the most recent one.""" - uncompressed_total_token_count = token_counter(model=llm_model, messages=messages) - if uncompressed_total_token_count > (max_tokens or (100 * 1000)): - _i = 0 # Count the number of Assistant messages - for msg in reversed(messages): # Start from the end and work backwards - if msg.get('role') == 'assistant': # Only compress Assistant messages - _i += 1 # Count the number of Assistant messages - msg_token_count = token_counter(messages=[msg]) # Count the number of tokens in the message - if msg_token_count > token_threshold: # If the message is too long - if _i > 1: # If this is not the most recent Assistant message - message_id = msg.get('message_id') # Get the message_id - if message_id: - msg["content"] = self._compress_message(msg["content"], message_id, token_threshold * 3) - else: - logger.warning(f"UNEXPECTED: Message has no message_id {str(msg)[:100]}") - else: - msg["content"] = self._safe_truncate(msg["content"], int(max_tokens * 2)) - - return messages - - - def _remove_meta_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Remove meta messages from the messages.""" - result: List[Dict[str, Any]] = [] - for msg in messages: - msg_content = msg.get('content') - # Try to parse msg_content as JSON if it's a string - if isinstance(msg_content, str): - try: msg_content = json.loads(msg_content) - except json.JSONDecodeError: pass - if isinstance(msg_content, dict): - # Create a copy to avoid modifying the original - msg_content_copy = msg_content.copy() - if "tool_execution" in msg_content_copy: - tool_execution = msg_content_copy["tool_execution"].copy() - if "arguments" in tool_execution: - del tool_execution["arguments"] - msg_content_copy["tool_execution"] = tool_execution - # Create a new message dict with the modified content - new_msg = msg.copy() - new_msg["content"] = json.dumps(msg_content_copy) - result.append(new_msg) - else: - result.append(msg) - return result - - def _compress_messages(self, messages: List[Dict[str, Any]], llm_model: str, max_tokens: Optional[int] = 41000, token_threshold: Optional[int] = 4096, max_iterations: int = 5) -> List[Dict[str, Any]]: - """Compress the messages. - token_threshold: must be a power of 2 - """ - - if 'sonnet' in llm_model.lower(): - max_tokens = 200 * 1000 - 64000 - 28000 - elif 'gpt' in llm_model.lower(): - max_tokens = 128 * 1000 - 28000 - elif 'gemini' in llm_model.lower(): - max_tokens = 1000 * 1000 - 300000 - elif 'deepseek' in llm_model.lower(): - max_tokens = 128 * 1000 - 28000 - else: - max_tokens = 41 * 1000 - 10000 - - result = messages - result = self._remove_meta_messages(result) - - uncompressed_total_token_count = token_counter(model=llm_model, messages=result) - - result = self._compress_tool_result_messages(result, llm_model, max_tokens, token_threshold) - result = self._compress_user_messages(result, llm_model, max_tokens, token_threshold) - result = self._compress_assistant_messages(result, llm_model, max_tokens, token_threshold) - - compressed_token_count = token_counter(model=llm_model, messages=result) - - logger.info(f"_compress_messages: {uncompressed_total_token_count} -> {compressed_token_count}") # Log the token compression for debugging later - - if max_iterations <= 0: - logger.warning(f"_compress_messages: Max iterations reached, omitting messages") - result = self._compress_messages_by_omitting_messages(messages, llm_model, max_tokens) - return result - - if (compressed_token_count > max_tokens): - logger.warning(f"Further token compression is needed: {compressed_token_count} > {max_tokens}") - result = self._compress_messages(messages, llm_model, max_tokens, int(token_threshold / 2), max_iterations - 1) - - return self._middle_out_messages(result) - - def _compress_messages_by_omitting_messages( - self, - messages: List[Dict[str, Any]], - llm_model: str, - max_tokens: Optional[int] = 41000, - removal_batch_size: int = 10, - min_messages_to_keep: int = 10 - ) -> List[Dict[str, Any]]: - """Compress the messages by omitting messages from the middle. - - Args: - messages: List of messages to compress - llm_model: Model name for token counting - max_tokens: Maximum allowed tokens - removal_batch_size: Number of messages to remove per iteration - min_messages_to_keep: Minimum number of messages to preserve - """ - if not messages: - return messages - - result = messages - result = self._remove_meta_messages(result) - - # Early exit if no compression needed - initial_token_count = token_counter(model=llm_model, messages=result) - max_allowed_tokens = max_tokens or (100 * 1000) - - if initial_token_count <= max_allowed_tokens: - return result - - # Separate system message (assumed to be first) from conversation messages - system_message = messages[0] if messages and messages[0].get('role') == 'system' else None - conversation_messages = result[1:] if system_message else result - - safety_limit = 500 - current_token_count = initial_token_count - - while current_token_count > max_allowed_tokens and safety_limit > 0: - safety_limit -= 1 - - if len(conversation_messages) <= min_messages_to_keep: - logger.warning(f"Cannot compress further: only {len(conversation_messages)} messages remain (min: {min_messages_to_keep})") - break - - # Calculate removal strategy based on current message count - if len(conversation_messages) > (removal_batch_size * 2): - # Remove from middle, keeping recent and early context - middle_start = len(conversation_messages) // 2 - (removal_batch_size // 2) - middle_end = middle_start + removal_batch_size - conversation_messages = conversation_messages[:middle_start] + conversation_messages[middle_end:] - else: - # Remove from earlier messages, preserving recent context - messages_to_remove = min(removal_batch_size, len(conversation_messages) // 2) - if messages_to_remove > 0: - conversation_messages = conversation_messages[messages_to_remove:] - else: - # Can't remove any more messages - break - - # Recalculate token count - messages_to_count = ([system_message] + conversation_messages) if system_message else conversation_messages - current_token_count = token_counter(model=llm_model, messages=messages_to_count) - - # Prepare final result - final_messages = ([system_message] + conversation_messages) if system_message else conversation_messages - final_token_count = token_counter(model=llm_model, messages=final_messages) - - logger.info(f"_compress_messages_by_omitting_messages: {initial_token_count} -> {final_token_count} tokens ({len(messages)} -> {len(final_messages)} messages)") - - return final_messages - - def _middle_out_messages(self, messages: List[Dict[str, Any]], max_messages: int = 320) -> List[Dict[str, Any]]: - """Remove messages from the middle of the list, keeping max_messages total.""" - if len(messages) <= max_messages: - return messages - - # Keep half from the beginning and half from the end - keep_start = max_messages // 2 - keep_end = max_messages - keep_start - - return messages[:keep_start] + messages[-keep_end:] - - - 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) - - async def add_message( - self, - thread_id: str, - type: str, - content: Union[Dict[str, Any], List[Any], str], - is_llm_message: bool = False, - metadata: Optional[Dict[str, Any]] = None, - agent_id: Optional[str] = None, - agent_version_id: Optional[str] = None - ): - """Add a message to the thread in the database. - - Args: - thread_id: The ID of the thread to add the message to. - type: The type of the message (e.g., 'text', 'image_url', 'tool_call', 'tool', 'user', 'assistant'). - content: The content of the message. Can be a dictionary, list, or string. - It will be stored as JSONB in the database. - is_llm_message: Flag indicating if the message originated from the LLM. - Defaults to False (user message). - metadata: Optional dictionary for additional message metadata. - Defaults to None, stored as an empty JSONB object if None. - agent_id: Optional ID of the agent associated with this message. - agent_version_id: Optional ID of the specific agent version used. - """ - logger.debug(f"Adding message of type '{type}' to thread {thread_id} (agent: {agent_id}, version: {agent_version_id})") - client = await self.db.client - - # Prepare data for insertion - data_to_insert = { - 'thread_id': thread_id, - 'type': type, - 'content': content, - 'is_llm_message': is_llm_message, - 'metadata': metadata or {}, - } - - # Add agent information if provided - if agent_id: - data_to_insert['agent_id'] = agent_id - if agent_version_id: - data_to_insert['agent_version_id'] = agent_version_id - - try: - # Add returning='representation' to get the inserted row data including the id - result = await client.table('messages').insert(data_to_insert, returning='representation').execute() - logger.info(f"Successfully added message to thread {thread_id}") - - if result.data and len(result.data) > 0 and isinstance(result.data[0], dict) and 'message_id' in result.data[0]: - return result.data[0] - else: - logger.error(f"Insert operation failed or did not return expected data structure for thread {thread_id}. Result data: {result.data}") - return None - except Exception as e: - logger.error(f"Failed to add message to thread {thread_id}: {str(e)}", exc_info=True) - raise - - async def get_llm_messages(self, thread_id: str) -> List[Dict[str, Any]]: - """Get all messages for a thread. - - This method uses the SQL function which handles context truncation - by considering summary messages. - - Args: - thread_id: The ID of the thread to get messages for. - - Returns: - List of message objects. - """ - logger.debug(f"Getting messages for thread {thread_id}") - client = await self.db.client - - try: - # result = await client.rpc('get_llm_formatted_messages', {'p_thread_id': thread_id}).execute() - - # Fetch messages in batches of 1000 to avoid overloading the database - all_messages = [] - batch_size = 1000 - offset = 0 - - while True: - result = await client.table('messages').select('message_id, content').eq('thread_id', thread_id).eq('is_llm_message', True).order('created_at').range(offset, offset + batch_size - 1).execute() - - if not result.data or len(result.data) == 0: - break - - all_messages.extend(result.data) - - # If we got fewer than batch_size records, we've reached the end - if len(result.data) < batch_size: - break - - offset += batch_size - - # Use all_messages instead of result.data in the rest of the method - result_data = all_messages - - # Parse the returned data which might be stringified JSON - if not result_data: - return [] - - # Return properly parsed JSON objects - messages = [] - for item in result_data: - if isinstance(item['content'], str): - try: - parsed_item = json.loads(item['content']) - parsed_item['message_id'] = item['message_id'] - messages.append(parsed_item) - except json.JSONDecodeError: - logger.error(f"Failed to parse message: {item['content']}") - else: - content = item['content'] - content['message_id'] = item['message_id'] - messages.append(content) - - return messages - - except Exception as e: - logger.error(f"Failed to get messages for thread {thread_id}: {str(e)}", exc_info=True) - return [] - - async def run_thread( - self, - thread_id: str, - system_prompt: Dict[str, Any], - stream: bool = True, - temporary_message: Optional[Dict[str, Any]] = None, - llm_model: str = "gpt-4o", - llm_temperature: float = 0, - llm_max_tokens: Optional[int] = None, - processor_config: Optional[ProcessorConfig] = None, - tool_choice: ToolChoice = "auto", - native_max_auto_continues: int = 25, - max_xml_tool_calls: int = 0, - include_xml_examples: bool = False, - enable_thinking: Optional[bool] = False, - reasoning_effort: Optional[str] = 'low', - enable_context_manager: bool = True, - generation: Optional[StatefulGenerationClient] = None, - ) -> Union[Dict[str, Any], AsyncGenerator]: - """Run a conversation thread with LLM integration and tool execution. - - Args: - thread_id: The ID of the thread to run - system_prompt: System message to set the assistant's behavior - stream: Use streaming API for the LLM response - temporary_message: Optional temporary user message for this run only - llm_model: The name of the LLM model to use - llm_temperature: Temperature parameter for response randomness (0-1) - llm_max_tokens: Maximum tokens in the LLM response - processor_config: Configuration for the response processor - tool_choice: Tool choice preference ("auto", "required", "none") - native_max_auto_continues: Maximum number of automatic continuations when - finish_reason="tool_calls" (0 disables auto-continue) - max_xml_tool_calls: Maximum number of XML tool calls to allow (0 = no limit) - include_xml_examples: Whether to include XML tool examples in the system prompt - enable_thinking: Whether to enable thinking before making a decision - reasoning_effort: The effort level for reasoning - enable_context_manager: Whether to enable automatic context summarization. - - Returns: - An async generator yielding response chunks or error dict - """ - - logger.info(f"Starting thread execution for thread {thread_id}") - logger.info(f"Using model: {llm_model}") - # Log parameters - logger.info(f"Parameters: model={llm_model}, temperature={llm_temperature}, max_tokens={llm_max_tokens}") - logger.info(f"Auto-continue: max={native_max_auto_continues}, XML tool limit={max_xml_tool_calls}") - - # Log model info - logger.info(f"🤖 Thread {thread_id}: Using model {llm_model}") - - # Apply max_xml_tool_calls if specified and not already set in config - if max_xml_tool_calls > 0 and not processor_config.max_xml_tool_calls: - processor_config.max_xml_tool_calls = max_xml_tool_calls - - # Create a working copy of the system prompt to potentially modify - working_system_prompt = system_prompt.copy() - - # Add XML examples to system prompt if requested, do this only ONCE before the loop - if include_xml_examples and processor_config.xml_tool_calling: - xml_examples = self.tool_registry.get_xml_examples() - if xml_examples: - examples_content = """ ---- XML TOOL CALLING --- - -In this environment you have access to a set of tools you can use to answer the user's question. The tools are specified in XML format. -Format your tool calls using the specified XML tags. Place parameters marked as 'attribute' within the opening tag (e.g., ``). Place parameters marked as 'content' between the opening and closing tags. Place parameters marked as 'element' within their own child tags (e.g., `value`). Refer to the examples provided below for the exact structure of each tool. -String and scalar parameters should be specified as attributes, while content goes between tags. -Note that spaces for string values are not stripped. The output is parsed with regular expressions. - -Here are the XML tools available with examples: -""" - for tag_name, example in xml_examples.items(): - examples_content += f"<{tag_name}> Example: {example}\\n" - - # # Save examples content to a file - # try: - # with open('xml_examples.txt', 'w') as f: - # f.write(examples_content) - # logger.debug("Saved XML examples to xml_examples.txt") - # except Exception as e: - # logger.error(f"Failed to save XML examples to file: {e}") - - system_content = working_system_prompt.get('content') - - if isinstance(system_content, str): - working_system_prompt['content'] += examples_content - logger.debug("Appended XML examples to string system prompt content.") - elif isinstance(system_content, list): - appended = False - for item in working_system_prompt['content']: # Modify the copy - if isinstance(item, dict) and item.get('type') == 'text' and 'text' in item: - item['text'] += examples_content - logger.debug("Appended XML examples to the first text block in list system prompt content.") - appended = True - break - if not appended: - logger.warning("System prompt content is a list but no text block found to append XML examples.") - else: - logger.warning(f"System prompt content is of unexpected type ({type(system_content)}), cannot add XML examples.") - # Control whether we need to auto-continue due to tool_calls finish reason - auto_continue = True - auto_continue_count = 0 - - # Define inner function to handle a single run - async def _run_once(temp_msg=None): - try: - # Ensure processor_config is available in this scope - nonlocal processor_config - # Note: processor_config is now guaranteed to exist due to check above - - # 1. Get messages from thread for LLM call - messages = await self.get_llm_messages(thread_id) - - # 2. Check token count before proceeding - token_count = 0 - try: - # Use the potentially modified working_system_prompt for token counting - token_count = token_counter(model=llm_model, messages=[working_system_prompt] + messages) - token_threshold = self.context_manager.token_threshold - logger.info(f"Thread {thread_id} token count: {token_count}/{token_threshold} ({(token_count/token_threshold)*100:.1f}%)") - - # if token_count >= token_threshold and enable_context_manager: - # logger.info(f"Thread token count ({token_count}) exceeds threshold ({token_threshold}), summarizing...") - # summarized = await self.context_manager.check_and_summarize_if_needed( - # thread_id=thread_id, - # add_message_callback=self.add_message, - # model=llm_model, - # force=True - # ) - # if summarized: - # logger.info("Summarization complete, fetching updated messages with summary") - # messages = await self.get_llm_messages(thread_id) - # # Recount tokens after summarization, using the modified prompt - # new_token_count = token_counter(model=llm_model, messages=[working_system_prompt] + messages) - # logger.info(f"After summarization: token count reduced from {token_count} to {new_token_count}") - # else: - # logger.warning("Summarization failed or wasn't needed - proceeding with original messages") - # elif not enable_context_manager: - # logger.info("Automatic summarization disabled. Skipping token count check and summarization.") - - except Exception as e: - logger.error(f"Error counting tokens or summarizing: {str(e)}") - - # 3. Prepare messages for LLM call + add temporary message if it exists - # Use the working_system_prompt which may contain the XML examples - prepared_messages = [working_system_prompt] - - # Find the last user message index - last_user_index = -1 - for i, msg in enumerate(messages): - if msg.get('role') == 'user': - last_user_index = i - - # Insert temporary message before the last user message if it exists - if temp_msg and last_user_index >= 0: - prepared_messages.extend(messages[:last_user_index]) - prepared_messages.append(temp_msg) - prepared_messages.extend(messages[last_user_index:]) - logger.debug("Added temporary message before the last user message") - else: - # If no user message or no temporary message, just add all messages - prepared_messages.extend(messages) - if temp_msg: - prepared_messages.append(temp_msg) - logger.debug("Added temporary message to the end of prepared messages") - - # 4. Prepare tools for LLM call - openapi_tool_schemas = None - if processor_config.native_tool_calling: - openapi_tool_schemas = self.tool_registry.get_openapi_schemas() - logger.debug(f"Retrieved {len(openapi_tool_schemas) if openapi_tool_schemas else 0} OpenAPI tool schemas") - - prepared_messages = self._compress_messages(prepared_messages, llm_model) - - # 5. Make LLM API call - logger.debug("Making LLM API call") - try: - if generation: - generation.update( - input=prepared_messages, - start_time=datetime.datetime.now(datetime.timezone.utc), - model=llm_model, - model_parameters={ - "max_tokens": llm_max_tokens, - "temperature": llm_temperature, - "enable_thinking": enable_thinking, - "reasoning_effort": reasoning_effort, - "tool_choice": tool_choice, - "tools": openapi_tool_schemas, - } - ) - llm_response = await make_llm_api_call( - prepared_messages, # Pass the potentially modified messages - llm_model, - temperature=llm_temperature, - max_tokens=llm_max_tokens, - tools=openapi_tool_schemas, - tool_choice=tool_choice if processor_config.native_tool_calling else None, - stream=stream, - enable_thinking=enable_thinking, - reasoning_effort=reasoning_effort - ) - logger.debug("Successfully received raw LLM API response stream/object") - - except Exception as e: - logger.error(f"Failed to make LLM API call: {str(e)}", exc_info=True) - raise - - # 6. Process LLM response using the ResponseProcessor - if stream: - logger.debug("Processing streaming response") - response_generator = self.response_processor.process_streaming_response( - llm_response=llm_response, - thread_id=thread_id, - config=processor_config, - prompt_messages=prepared_messages, - llm_model=llm_model, - ) - - return response_generator - else: - logger.debug("Processing non-streaming response") - # Pass through the response generator without try/except to let errors propagate up - response_generator = self.response_processor.process_non_streaming_response( - llm_response=llm_response, - thread_id=thread_id, - config=processor_config, - prompt_messages=prepared_messages, - llm_model=llm_model, - ) - return response_generator # Return the generator - - except Exception as e: - 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) - } - - # Define a wrapper generator that handles auto-continue logic - async def auto_continue_wrapper(): - nonlocal auto_continue, auto_continue_count - - while auto_continue and (native_max_auto_continues == 0 or auto_continue_count < native_max_auto_continues): - # Reset auto_continue for this iteration - auto_continue = False - - # Run the thread once, passing the potentially modified system prompt - # Pass temp_msg only on the first iteration - try: - response_gen = await _run_once(temporary_message if auto_continue_count == 0 else None) - - # Handle error responses - if isinstance(response_gen, dict) and "status" in response_gen and response_gen["status"] == "error": - logger.error(f"Error in auto_continue_wrapper: {response_gen.get('message', 'Unknown error')}") - yield response_gen - return # Exit the generator on error - - # Process each chunk - try: - async for chunk in response_gen: - # Check if this is a finish reason chunk with tool_calls or xml_tool_limit_reached - if chunk.get('type') == 'finish': - if chunk.get('finish_reason') == 'tool_calls': - # Only auto-continue if enabled (max > 0) - if native_max_auto_continues > 0: - logger.info(f"Detected finish_reason='tool_calls', auto-continuing ({auto_continue_count + 1}/{native_max_auto_continues})") - auto_continue = True - auto_continue_count += 1 - # Don't yield the finish chunk to avoid confusing the client - continue - elif chunk.get('finish_reason') == 'xml_tool_limit_reached': - # Don't auto-continue if XML tool limit was reached - logger.info(f"Detected finish_reason='xml_tool_limit_reached', stopping auto-continue") - auto_continue = False - # Still yield the chunk to inform the client - - # Otherwise just yield the chunk normally - yield chunk - - # If not auto-continuing, we're done - if not auto_continue: - break - except Exception as e: - # If there's an exception, log it, yield an error status, and stop execution - logger.error(f"Error in auto_continue_wrapper generator: {str(e)}", exc_info=True) - yield { - "type": "status", - "status": "error", - "message": f"Error in thread processing: {str(e)}" - } - return # Exit the generator on any error - except Exception as outer_e: - # Catch exceptions from _run_once itself - logger.error(f"Error executing thread: {str(outer_e)}", exc_info=True) - yield { - "type": "status", - "status": "error", - "message": f"Error executing thread: {str(outer_e)}" - } - return # Exit immediately on exception from _run_once - - # If we've reached the max auto-continues, log a warning - if auto_continue and auto_continue_count >= native_max_auto_continues: - logger.warning(f"Reached maximum auto-continue limit ({native_max_auto_continues}), stopping.") - yield { - "type": "content", - "content": f"\n[Agent reached maximum auto-continue limit of {native_max_auto_continues}]" - } - - # If auto-continue is disabled (max=0), just run once - if native_max_auto_continues == 0: - logger.info("Auto-continue is disabled (native_max_auto_continues=0)") - # Pass the potentially modified system prompt and temp message - return await _run_once(temporary_message) - - # Otherwise return the auto-continue wrapper generator - return auto_continue_wrapper() diff --git a/app/daytona/README.md b/app/daytona/README.md index 565a770f2..93d1eb713 100644 --- a/app/daytona/README.md +++ b/app/daytona/README.md @@ -7,7 +7,6 @@ This directory contains the agent sandbox implementation - a Docker-based virtua The sandbox provides a complete containerized Linux environment with: - Chrome browser for web interactions - VNC server for accessing the Web User -- Web server for serving content (port 8080) -> loading html files from the /workspace directory - Full file system access - Full sudo access @@ -18,9 +17,8 @@ You can modify the sandbox environment for development or to add new capabilitie 1. Edit files in the `docker/` directory 2. Build a custom image: ``` - cd backend/sandbox/docker - docker compose build - docker push kortix/suna:0.1.3 + cd daytona/docker + docker build ``` 3. Test your changes locally using docker-compose @@ -36,10 +34,6 @@ To use your custom sandbox image: When publishing a new version of the sandbox: -1. Update the version number in `docker-compose.yml` (e.g., from `0.1.2` to `0.1.3`) -2. Build the new image: `docker compose build` -3. Push the new version: `docker push kortix/suna:0.1.3` 4. Update all references to the image version in: - - `backend/utils/config.py` - Daytona images - Any other services using this image \ No newline at end of file diff --git a/app/daytona/docker/README.md b/app/daytona/docker/README.md deleted file mode 100644 index b8122d61f..000000000 --- a/app/daytona/docker/README.md +++ /dev/null @@ -1 +0,0 @@ -# Sandbox diff --git a/app/daytona/docker/docker-compose.yml b/app/daytona/docker/docker-compose.yml index 7b6646ab9..55ae72533 100644 --- a/app/daytona/docker/docker-compose.yml +++ b/app/daytona/docker/docker-compose.yml @@ -6,7 +6,7 @@ services: dockerfile: ${DOCKERFILE:-Dockerfile} args: TARGETPLATFORM: ${TARGETPLATFORM:-linux/amd64} - image: kortix/suna:0.1.3 + image: daytona_sanbox:1.0 ports: - "6080:6080" # noVNC web interface - "5901:5901" # VNC port diff --git a/app/daytona/tool_base.py b/app/daytona/tool_base.py index 6db3fb816..f0612ae5f 100644 --- a/app/daytona/tool_base.py +++ b/app/daytona/tool_base.py @@ -24,7 +24,6 @@ class ThreadMessage: def to_dict(self) -> Dict[str, Any]: """Convert the message to a dictionary for API calls""" return { - "thread_id": self.thread_id, "type": self.type, "content": self.content, "is_llm_message": self.is_llm_message, diff --git a/app/tool/computer_use_tool.py b/app/tool/computer_use_tool.py index 38688c293..fd979e229 100644 --- a/app/tool/computer_use_tool.py +++ b/app/tool/computer_use_tool.py @@ -7,662 +7,10 @@ from typing import Optional, Dict,Literal import os from pydantic import Field -# from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema from app.daytona.tool_base import SandboxToolsBase, Sandbox from app.tool.base import ToolResult -KEYBOARD_KEYS = [ - 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', - 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', - 'enter', 'esc', 'backspace', 'tab', 'space', 'delete', - 'ctrl', 'alt', 'shift', 'win', - 'up', 'down', 'left', 'right', - 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', - 'ctrl+c', 'ctrl+v', 'ctrl+x', 'ctrl+z', 'ctrl+a', 'ctrl+s', - 'alt+tab', 'alt+f4', 'ctrl+alt+delete' -] - -# class ComputerUseTool(SandboxToolsBase): -# """Computer automation tool for controlling the sandbox browser and GUI.""" - -# def __init__(self, sandbox: Sandbox): -# """Initialize automation tool with sandbox connection.""" -# super().__init__(sandbox) -# self.session = None -# self.mouse_x = 0 # Track current mouse position -# self.mouse_y = 0 -# # Get automation service URL using port 8000 -# self.api_base_url = self.sandbox.get_preview_link(8000) -# logging.info(f"Initialized Computer Use Tool with API URL: {self.api_base_url}") - -# async def _get_session(self) -> aiohttp.ClientSession: -# """Get or create aiohttp session for API requests.""" -# if self.session is None or self.session.closed: -# self.session = aiohttp.ClientSession() -# return self.session - -# async def _api_request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Dict: -# """Send request to automation service API.""" -# try: -# session = await self._get_session() -# url = f"{self.api_base_url}/api{endpoint}" - -# logging.debug(f"API request: {method} {url} {data}") - -# if method.upper() == "GET": -# async with session.get(url) as response: -# result = await response.json() -# else: # POST -# async with session.post(url, json=data) as response: -# result = await response.json() - -# logging.debug(f"API response: {result}") -# return result - -# except Exception as e: -# logging.error(f"API request failed: {str(e)}") -# return {"success": False, "error": str(e)} - -# async def cleanup(self): -# """Clean up resources.""" -# if self.session and not self.session.closed: -# await self.session.close() -# self.session = None - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "move_to", -# "description": "Move cursor to specified position", -# "parameters": { -# "type": "object", -# "properties": { -# "x": { -# "type": "number", -# "description": "X coordinate" -# }, -# "y": { -# "type": "number", -# "description": "Y coordinate" -# } -# }, -# "required": ["x", "y"] -# } -# } -# }) -# @xml_schema( -# tag_name="move-to", -# mappings=[ -# {"param_name": "x", "node_type": "attribute", "path": "."}, -# {"param_name": "y", "node_type": "attribute", "path": "."} -# ], -# example=''' -# -# -# 100 -# 200 -# -# -# ''' -# ) -# async def move_to(self, x: float, y: float) -> ToolResult: -# """Move cursor to specified position.""" -# try: -# x_int = int(round(float(x))) -# y_int = int(round(float(y))) - -# result = await self._api_request("POST", "/automation/mouse/move", { -# "x": x_int, -# "y": y_int -# }) - -# if result.get("success", False): -# self.mouse_x = x_int -# self.mouse_y = y_int -# return ToolResult(success=True, output=f"Moved to ({x_int}, {y_int})") -# else: -# return ToolResult(success=False, output=f"Failed to move: {result.get('error', 'Unknown error')}") - -# except Exception as e: -# return ToolResult(success=False, output=f"Failed to move: {str(e)}") - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "click", -# "description": "Click at current or specified position", -# "parameters": { -# "type": "object", -# "properties": { -# "button": { -# "type": "string", -# "description": "Mouse button to click", -# "enum": ["left", "right", "middle"], -# "default": "left" -# }, -# "x": { -# "type": "number", -# "description": "Optional X coordinate" -# }, -# "y": { -# "type": "number", -# "description": "Optional Y coordinate" -# }, -# "num_clicks": { -# "type": "integer", -# "description": "Number of clicks", -# "enum": [1, 2, 3], -# "default": 1 -# } -# } -# } -# } -# }) -# @xml_schema( -# tag_name="click", -# mappings=[ -# {"param_name": "x", "node_type": "attribute", "path": "x"}, -# {"param_name": "y", "node_type": "attribute", "path": "y"}, -# {"param_name": "button", "node_type": "attribute", "path": "button"}, -# {"param_name": "num_clicks", "node_type": "attribute", "path": "num_clicks"} -# ], -# example=''' -# -# -# 100 -# 200 -# left -# 1 -# -# -# ''' -# ) -# async def click(self, x: Optional[float] = None, y: Optional[float] = None, -# button: str = "left", num_clicks: int = 1) -> ToolResult: -# """Click at current or specified position.""" -# try: -# x_val = x if x is not None else self.mouse_x -# y_val = y if y is not None else self.mouse_y - -# x_int = int(round(float(x_val))) -# y_int = int(round(float(y_val))) -# num_clicks = int(num_clicks) - -# result = await self._api_request("POST", "/automation/mouse/click", { -# "x": x_int, -# "y": y_int, -# "clicks": num_clicks, -# "button": button.lower() -# }) - -# if result.get("success", False): -# self.mouse_x = x_int -# self.mouse_y = y_int -# return ToolResult(success=True, -# output=f"{num_clicks} {button} click(s) performed at ({x_int}, {y_int})") -# else: -# return ToolResult(success=False, output=f"Failed to click: {result.get('error', 'Unknown error')}") -# except Exception as e: -# return ToolResult(success=False, output=f"Failed to click: {str(e)}") - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "scroll", -# "description": "Scroll the mouse wheel at current position", -# "parameters": { -# "type": "object", -# "properties": { -# "amount": { -# "type": "integer", -# "description": "Scroll amount (positive for up, negative for down)", -# "minimum": -10, -# "maximum": 10 -# } -# }, -# "required": ["amount"] -# } -# } -# }) -# @xml_schema( -# tag_name="scroll", -# mappings=[ -# {"param_name": "amount", "node_type": "attribute", "path": "amount"} -# ], -# example=''' -# -# -# -3 -# -# -# ''' -# ) -# async def scroll(self, amount: int) -> ToolResult: -# """ -# Scroll the mouse wheel at current position. -# Positive values scroll up, negative values scroll down. -# """ -# try: -# amount = int(float(amount)) -# amount = max(-10, min(10, amount)) - -# result = await self._api_request("POST", "/automation/mouse/scroll", { -# "clicks": amount, -# "x": self.mouse_x, -# "y": self.mouse_y -# }) - -# if result.get("success", False): -# direction = "up" if amount > 0 else "down" -# steps = abs(amount) -# return ToolResult(success=True, -# output=f"Scrolled {direction} {steps} step(s) at position ({self.mouse_x}, {self.mouse_y})") -# else: -# return ToolResult(success=False, output=f"Failed to scroll: {result.get('error', 'Unknown error')}") -# except Exception as e: -# return ToolResult(success=False, output=f"Failed to scroll: {str(e)}") - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "typing", -# "description": "Type specified text", -# "parameters": { -# "type": "object", -# "properties": { -# "text": { -# "type": "string", -# "description": "Text to type" -# } -# }, -# "required": ["text"] -# } -# } -# }) -# @xml_schema( -# tag_name="typing", -# mappings=[ -# {"param_name": "text", "node_type": "content", "path": "text"} -# ], -# example=''' -# -# -# Hello World! -# -# -# ''' -# ) -# async def typing(self, text: str) -> ToolResult: -# """Type specified text.""" -# try: -# text = str(text) - -# result = await self._api_request("POST", "/automation/keyboard/write", { -# "message": text, -# "interval": 0.01 -# }) - -# if result.get("success", False): -# return ToolResult(success=True, output=f"Typed: {text}") -# else: -# return ToolResult(success=False, output=f"Failed to type: {result.get('error', 'Unknown error')}") -# except Exception as e: -# return ToolResult(success=False, output=f"Failed to type: {str(e)}") - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "press", -# "description": "Press and release a key", -# "parameters": { -# "type": "object", -# "properties": { -# "key": { -# "type": "string", -# "description": "Key to press", -# "enum": KEYBOARD_KEYS -# } -# }, -# "required": ["key"] -# } -# } -# }) -# @xml_schema( -# tag_name="press", -# mappings=[ -# {"param_name": "key", "node_type": "attribute", "path": "key"} -# ], -# example=''' -# -# -# enter -# -# -# ''' -# ) -# async def press(self, key: str) -> ToolResult: -# """Press and release a key.""" -# try: -# key = str(key).lower() - -# result = await self._api_request("POST", "/automation/keyboard/press", { -# "keys": key, -# "presses": 1 -# }) - -# if result.get("success", False): -# return ToolResult(success=True, output=f"Pressed key: {key}") -# else: -# return ToolResult(success=False, output=f"Failed to press key: {result.get('error', 'Unknown error')}") -# except Exception as e: -# return ToolResult(success=False, output=f"Failed to press key: {str(e)}") - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "wait", -# "description": "Wait for specified duration", -# "parameters": { -# "type": "object", -# "properties": { -# "duration": { -# "type": "number", -# "description": "Duration in seconds", -# "default": 0.5 -# } -# } -# } -# } -# }) -# @xml_schema( -# tag_name="wait", -# mappings=[ -# {"param_name": "duration", "node_type": "attribute", "path": "duration"} -# ], -# example=''' -# -# -# 1.5 -# -# -# ''' -# ) -# async def wait(self, duration: float = 0.5) -> ToolResult: -# """Wait for specified duration.""" -# try: -# duration = float(duration) -# duration = max(0, min(10, duration)) -# await asyncio.sleep(duration) -# return ToolResult(success=True, output=f"Waited {duration} seconds") -# except Exception as e: -# return ToolResult(success=False, output=f"Failed to wait: {str(e)}") - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "mouse_down", -# "description": "Press a mouse button", -# "parameters": { -# "type": "object", -# "properties": { -# "button": { -# "type": "string", -# "description": "Mouse button to press", -# "enum": ["left", "right", "middle"], -# "default": "left" -# } -# } -# } -# } -# }) -# @xml_schema( -# tag_name="mouse-down", -# mappings=[ -# {"param_name": "button", "node_type": "attribute", "path": "button"} -# ], -# example=''' -# -# -# left -# -# -# ''' -# ) -# async def mouse_down(self, button: str = "left", x: Optional[float] = None, y: Optional[float] = None) -> ToolResult: -# """Press a mouse button at current or specified position.""" -# try: -# x_val = x if x is not None else self.mouse_x -# y_val = y if y is not None else self.mouse_y - -# x_int = int(round(float(x_val))) -# y_int = int(round(float(y_val))) - -# result = await self._api_request("POST", "/automation/mouse/down", { -# "x": x_int, -# "y": y_int, -# "button": button.lower() -# }) - -# if result.get("success", False): -# self.mouse_x = x_int -# self.mouse_y = y_int -# return ToolResult(success=True, output=f"{button} button pressed at ({x_int}, {y_int})") -# else: -# return ToolResult(success=False, output=f"Failed to press button: {result.get('error', 'Unknown error')}") -# except Exception as e: -# return ToolResult(success=False, output=f"Failed to press button: {str(e)}") - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "mouse_up", -# "description": "Release a mouse button", -# "parameters": { -# "type": "object", -# "properties": { -# "button": { -# "type": "string", -# "description": "Mouse button to release", -# "enum": ["left", "right", "middle"], -# "default": "left" -# } -# } -# } -# } -# }) -# @xml_schema( -# tag_name="mouse-up", -# mappings=[ -# {"param_name": "button", "node_type": "attribute", "path": "button"} -# ], -# example=''' -# -# -# left -# -# -# ''' -# ) -# async def mouse_up(self, button: str = "left", x: Optional[float] = None, y: Optional[float] = None) -> ToolResult: -# """Release a mouse button at current or specified position.""" -# try: -# x_val = x if x is not None else self.mouse_x -# y_val = y if y is not None else self.mouse_y - -# x_int = int(round(float(x_val))) -# y_int = int(round(float(y_val))) - -# result = await self._api_request("POST", "/automation/mouse/up", { -# "x": x_int, -# "y": y_int, -# "button": button.lower() -# }) - -# if result.get("success", False): -# self.mouse_x = x_int -# self.mouse_y = y_int -# return ToolResult(success=True, output=f"{button} button released at ({x_int}, {y_int})") -# else: -# return ToolResult(success=False, output=f"Failed to release button: {result.get('error', 'Unknown error')}") -# except Exception as e: -# return ToolResult(success=False, output=f"Failed to release button: {str(e)}") - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "drag_to", -# "description": "Drag cursor to specified position", -# "parameters": { -# "type": "object", -# "properties": { -# "x": { -# "type": "number", -# "description": "Target X coordinate" -# }, -# "y": { -# "type": "number", -# "description": "Target Y coordinate" -# } -# }, -# "required": ["x", "y"] -# } -# } -# }) -# @xml_schema( -# tag_name="drag-to", -# mappings=[ -# {"param_name": "x", "node_type": "attribute", "path": "x"}, -# {"param_name": "y", "node_type": "attribute", "path": "y"} -# ], -# example=''' -# -# -# 500 -# 50 -# -# -# ''' -# ) -# async def drag_to(self, x: float, y: float) -> ToolResult: -# """Click and drag from current position to target position.""" -# try: -# target_x = int(round(float(x))) -# target_y = int(round(float(y))) -# start_x = self.mouse_x -# start_y = self.mouse_y - -# result = await self._api_request("POST", "/automation/mouse/drag", { -# "x": target_x, -# "y": target_y, -# "duration": 0.3, -# "button": "left" -# }) - -# if result.get("success", False): -# self.mouse_x = target_x -# self.mouse_y = target_y -# return ToolResult(success=True, -# output=f"Dragged from ({start_x}, {start_y}) to ({target_x}, {target_y})") -# else: -# return ToolResult(success=False, output=f"Failed to drag: {result.get('error', 'Unknown error')}") -# except Exception as e: -# return ToolResult(success=False, output=f"Failed to drag: {str(e)}") - -# async def get_screenshot_base64(self) -> Optional[dict]: -# """Capture screen and return as base64 encoded image.""" -# try: -# result = await self._api_request("POST", "/automation/screenshot") - -# if "image" in result: -# base64_str = result["image"] -# timestamp = time.strftime("%Y%m%d_%H%M%S") - -# # Save screenshot to file -# screenshots_dir = "screenshots" -# if not os.path.exists(screenshots_dir): -# os.makedirs(screenshots_dir) - -# timestamped_filename = os.path.join(screenshots_dir, f"screenshot_{timestamp}.png") -# latest_filename = "latest_screenshot.png" - -# # Decode base64 string and save to file -# img_data = base64.b64decode(base64_str) -# with open(timestamped_filename, 'wb') as f: -# f.write(img_data) - -# # Save a copy as the latest screenshot -# with open(latest_filename, 'wb') as f: -# f.write(img_data) - -# return { -# "content_type": "image/png", -# "base64": base64_str, -# "timestamp": timestamp, -# "filename": timestamped_filename -# } -# else: -# return None - -# except Exception as e: -# print(f"[Screenshot] Error during screenshot process: {str(e)}") -# return None - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "hotkey", -# "description": "Press a key combination", -# "parameters": { -# "type": "object", -# "properties": { -# "keys": { -# "type": "string", -# "description": "Key combination to press", -# "enum": KEYBOARD_KEYS -# } -# }, -# "required": ["keys"] -# } -# } -# }) -# @xml_schema( -# tag_name="hotkey", -# mappings=[ -# {"param_name": "keys", "node_type": "attribute", "path": "keys"} -# ], -# example=''' -# -# -# ctrl+a -# -# -# ''' -# ) -# async def hotkey(self, keys: str) -> ToolResult: -# """Press a key combination.""" -# try: -# keys = str(keys).lower().strip() -# key_sequence = keys.split('+') - -# result = await self._api_request("POST", "/automation/keyboard/hotkey", { -# "keys": key_sequence, -# "interval": 0.01 -# }) - -# if result.get("success", False): -# return ToolResult(success=True, output=f"Pressed key combination: {keys}") -# else: -# return ToolResult(success=False, output=f"Failed to press keys: {result.get('error', 'Unknown error')}") -# except Exception as e: -# return ToolResult(success=False, output=f"Failed to press keys: {str(e)}") - -# if __name__ == "__main__": -# print("This module should be imported, not run directly.") - - - KEYBOARD_KEYS = [ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', @@ -1030,21 +378,3 @@ def __del__(self): loop = asyncio.new_event_loop() loop.run_until_complete(self.cleanup()) loop.close() - # def __del__(self): - # """Ensure cleanup when object is destroyed.""" - # if self.session is not None: - # try: - # asyncio.run(self.cleanup()) - # except RuntimeError: - # loop = asyncio.new_event_loop() - # loop.run_until_complete(self.cleanup()) - # loop.close() - # @classmethod - # def create_with_sandbox(cls, sandbox: Sandbox) -> "ComputerUseTool": - # """Factory method to create a ComputerUseTool with a sandbox connection.""" - # tool = cls() - # tool.sandbox = sandbox - # tool.api_base_url = sandbox.get_preview_link(8000) - # logging.info(f"Initialized Computer Use Tool with API URL: {tool.api_base_url}") - # return tool - diff --git a/app/tool/sb_browser_tool.py b/app/tool/sb_browser_tool.py index e3f47ee96..bbb22907e 100644 --- a/app/tool/sb_browser_tool.py +++ b/app/tool/sb_browser_tool.py @@ -3,21 +3,14 @@ import base64 import io from PIL import Image -import asyncio -from typing import Optional # Add this import for Optional -from browser_use.browser.context import BrowserContext -from browser_use.browser.views import BrowserState, TabInfo -from daytona.common.process import ExecuteResponse +from typing import Optional # Add this import for Optional from pydantic import Field - from app.daytona.tool_base import Sandbox, ThreadMessage # Ensure Sandbox is imported correctly -# from app.agentpress.tool import ToolResult, openapi_schema, xml_schema from app.tool.base import ToolResult -# from app.agentpress.thread_manager import ThreadManager from app.daytona.tool_base import SandboxToolsBase from app.utils.logger import logger -#from app.utils.s3_upload_utils import upload_base64_image + # Context = TypeVar("Context") @@ -136,8 +129,6 @@ def __init__(self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = super().__init__(**data) if sandbox is not None: self._sandbox = sandbox # Directly set the base class private attribute - # self.api_base_url = sandbox.get_preview_link(8003) # Set API base URL for browser automation - # logger.info(f"Initialized SandboxBrowserTool with API URL: {self.api_base_url}") def _validate_base64_image(self, base64_string: str, max_size_mb: int = 10) -> tuple[bool, str]: """ @@ -380,17 +371,6 @@ async def get_current_state( "pixels_above": getattr(state, "pixels_above", 0), "pixels_below": getattr(state, "pixels_below", 0), "help": "[0], [1], [2], etc., represent clickable indices corresponding to the elements listed. Clicking on these indices will navigate to or interact with the respective content behind them.", - # "interactive_elements": ( - # state.get("element_tree").clickable_elements_to_string() - # if state.get("element_tree") - # else "" - # ), - # "scroll_info": { - # "pixels_above": getattr(state, "pixels_above", 0), - # "pixels_below": getattr(state, "pixels_below", 0), - # "total_height": getattr(state, "pixels_above", 0) - # + getattr(state, "pixels_below", 0) - # } } return ToolResult( @@ -399,1054 +379,6 @@ async def get_current_state( ) except Exception as e: return ToolResult(error=f"Failed to get browser state: {str(e)}") - # async def cleanup(self): - # """Clean up sandbox resources.""" - # pass - # @classmethod - # def create_with_context(cls, context: Context) -> "SandboxBrowserTool[Context]": - # """Factory method to create a SandboxBrowserTool with a specific context.""" - # raise NotImplementedError("create_with_context not implemented for SandboxBrowserTool") - - -# class SandboxBrowserTool(SandboxToolsBase): -# """Tool for executing tasks in a Daytona sandbox with browser-use capabilities.""" - -# def __init__(self, project_id: str, thread_id: str, thread_manager: ThreadManager): -# super().__init__(project_id, thread_manager) -# self.thread_id = thread_id - -# def _validate_base64_image(self, base64_string: str, max_size_mb: int = 10) -> tuple[bool, str]: -# """ -# Comprehensive validation of base64 image data - -# Args: -# base64_string (str): The base64 encoded image data -# max_size_mb (int): Maximum allowed image size in megabytes - -# Returns: -# tuple[bool, str]: (is_valid, error_message) -# """ -# try: -# # Check if data exists and has reasonable length -# if not base64_string or len(base64_string) < 10: -# return False, "Base64 string is empty or too short" - -# # Remove data URL prefix if present (data:image/jpeg;base64,...) -# if base64_string.startswith('data:'): -# try: -# base64_string = base64_string.split(',', 1)[1] -# except (IndexError, ValueError): -# return False, "Invalid data URL format" - -# # Check if string contains only valid base64 characters -# # Base64 alphabet: A-Z, a-z, 0-9, +, /, = (padding) -# import re -# if not re.match(r'^[A-Za-z0-9+/]*={0,2}$', base64_string): -# return False, "Invalid base64 characters detected" - -# # Check if base64 string length is valid (must be multiple of 4) -# if len(base64_string) % 4 != 0: -# return False, "Invalid base64 string length" - -# # Attempt to decode base64 -# try: -# image_data = base64.b64decode(base64_string, validate=True) -# except Exception as e: -# return False, f"Base64 decoding failed: {str(e)}" - -# # Check decoded data size -# if len(image_data) == 0: -# return False, "Decoded image data is empty" - -# # Check if decoded data size exceeds limit -# max_size_bytes = max_size_mb * 1024 * 1024 -# if len(image_data) > max_size_bytes: -# return False, f"Image size ({len(image_data)} bytes) exceeds limit ({max_size_bytes} bytes)" - -# # Validate that decoded data is actually a valid image using PIL -# try: -# image_stream = io.BytesIO(image_data) -# with Image.open(image_stream) as img: -# # Verify the image by attempting to load it -# img.verify() - -# # Check if image format is supported -# supported_formats = {'JPEG', 'PNG', 'GIF', 'BMP', 'WEBP', 'TIFF'} -# if img.format not in supported_formats: -# return False, f"Unsupported image format: {img.format}" - -# # Re-open for dimension checks (verify() closes the image) -# image_stream.seek(0) -# with Image.open(image_stream) as img_check: -# width, height = img_check.size - -# # Check reasonable dimension limits -# max_dimension = 8192 # 8K resolution limit -# if width > max_dimension or height > max_dimension: -# return False, f"Image dimensions ({width}x{height}) exceed limit ({max_dimension}x{max_dimension})" - -# # Check minimum dimensions -# if width < 1 or height < 1: -# return False, f"Invalid image dimensions: {width}x{height}" - -# logger.debug(f"Valid image detected: {img.format}, {width}x{height}, {len(image_data)} bytes") - -# except Exception as e: -# return False, f"Invalid image data: {str(e)}" - -# return True, "Valid image" - -# except Exception as e: -# logger.error(f"Unexpected error during base64 image validation: {e}") -# return False, f"Validation error: {str(e)}" - -# async def _execute_browser_action(self, endpoint: str, params: dict = None, method: str = "POST") -> ToolResult: -# """Execute a browser automation action through the API - -# Args: -# endpoint (str): The API endpoint to call -# params (dict, optional): Parameters to send. Defaults to None. -# method (str, optional): HTTP method to use. Defaults to "POST". - -# Returns: -# ToolResult: Result of the execution -# """ -# try: -# # Ensure sandbox is initialized -# await self._ensure_sandbox() - -# # Build the curl command -# url = f"http://localhost:8003/api/automation/{endpoint}" - -# if method == "GET" and params: -# query_params = "&".join([f"{k}={v}" for k, v in params.items()]) -# url = f"{url}?{query_params}" -# curl_cmd = f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'" -# else: -# curl_cmd = f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'" -# if params: -# json_data = json.dumps(params) -# curl_cmd += f" -d '{json_data}'" - -# logger.debug("\033[95mExecuting curl command:\033[0m") -# logger.debug(f"{curl_cmd}") - -# response = self.sandbox.process.exec(curl_cmd, timeout=30) - -# if response.exit_code == 0: -# try: -# result = json.loads(response.result) - -# if not "content" in result: -# result["content"] = "" - -# if not "role" in result: -# result["role"] = "assistant" - -# logger.info("Browser automation request completed successfully") - -# if "screenshot_base64" in result: -# try: -# # Comprehensive validation of the base64 image data -# screenshot_data = result["screenshot_base64"] -# is_valid, validation_message = self._validate_base64_image(screenshot_data) - -# if is_valid: -# logger.debug(f"Screenshot validation passed: {validation_message}") -# image_url = await upload_base64_image(screenshot_data) -# result["image_url"] = image_url -# logger.debug(f"Uploaded screenshot to {image_url}") -# else: -# logger.warning(f"Screenshot validation failed: {validation_message}") -# result["image_validation_error"] = validation_message - -# # Remove base64 data from result to keep it clean -# del result["screenshot_base64"] - -# except Exception as e: -# logger.error(f"Failed to process screenshot: {e}") -# result["image_upload_error"] = str(e) - -# added_message = await self.thread_manager.add_message( -# thread_id=self.thread_id, -# type="browser_state", -# content=result, -# is_llm_message=False -# ) - -# success_response = {} - -# if result.get("success"): -# success_response["success"] = result["success"] -# success_response["message"] = result.get("message", "Browser action completed successfully") -# else: -# success_response["success"] = False -# success_response["message"] = result.get("message", "Browser action failed") - -# if added_message and 'message_id' in added_message: -# success_response['message_id'] = added_message['message_id'] -# if result.get("url"): -# success_response["url"] = result["url"] -# if result.get("title"): -# success_response["title"] = result["title"] -# if result.get("element_count"): -# success_response["elements_found"] = result["element_count"] -# if result.get("pixels_below"): -# success_response["scrollable_content"] = result["pixels_below"] > 0 -# if result.get("ocr_text"): -# success_response["ocr_text"] = result["ocr_text"] -# if result.get("image_url"): -# success_response["image_url"] = result["image_url"] - -# if success_response.get("success"): -# return self.success_response(success_response) -# else: -# return self.fail_response(success_response) - -# except json.JSONDecodeError as e: -# logger.error(f"Failed to parse response JSON: {response.result} {e}") -# return self.fail_response(f"Failed to parse response JSON: {response.result} {e}") -# else: -# logger.error(f"Browser automation request failed 2: {response}") -# return self.fail_response(f"Browser automation request failed 2: {response}") - -# except Exception as e: -# logger.error(f"Error executing browser action: {e}") -# logger.debug(traceback.format_exc()) -# return self.fail_response(f"Error executing browser action: {e}") - - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_navigate_to", -# "description": "Navigate to a specific url", -# "parameters": { -# "type": "object", -# "properties": { -# "url": { -# "type": "string", -# "description": "The url to navigate to" -# } -# }, -# "required": ["url"] -# } -# } -# }) -# @xml_schema( -# tag_name="browser-navigate-to", -# mappings=[ -# {"param_name": "url", "node_type": "content", "path": "."} -# ], -# example=''' -# -# -# https://example.com -# -# -# ''' -# ) -# async def browser_navigate_to(self, url: str) -> ToolResult: -# """Navigate to a specific url - -# Args: -# url (str): The url to navigate to - -# Returns: -# dict: Result of the execution -# """ -# return await self._execute_browser_action("navigate_to", {"url": url}) - -# # @openapi_schema({ -# # "type": "function", -# # "function": { -# # "name": "browser_search_google", -# # "description": "Search Google with the provided query", -# # "parameters": { -# # "type": "object", -# # "properties": { -# # "query": { -# # "type": "string", -# # "description": "The search query to use" -# # } -# # }, -# # "required": ["query"] -# # } -# # } -# # }) -# # @xml_schema( -# # tag_name="browser-search-google", -# # mappings=[ -# # {"param_name": "query", "node_type": "content", "path": "."} -# # ], -# # example=''' -# # -# # artificial intelligence news -# # -# # ''' -# # ) -# # async def browser_search_google(self, query: str) -> ToolResult: -# # """Search Google with the provided query - -# # Args: -# # query (str): The search query to use - -# # Returns: -# # dict: Result of the execution -# # """ -# # logger.debug(f"\033[95mSearching Google for: {query}\033[0m") -# # return await self._execute_browser_action("search_google", {"query": query}) - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_go_back", -# "description": "Navigate back in browser history", -# "parameters": { -# "type": "object", -# "properties": {} -# } -# } -# }) -# @xml_schema( -# tag_name="browser-go-back", -# mappings=[], -# example=''' -# -# -# -# -# ''' -# ) -# async def browser_go_back(self) -> ToolResult: -# """Navigate back in browser history - -# Returns: -# dict: Result of the execution -# """ -# logger.debug(f"\033[95mNavigating back in browser history\033[0m") -# return await self._execute_browser_action("go_back", {}) - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_wait", -# "description": "Wait for the specified number of seconds", -# "parameters": { -# "type": "object", -# "properties": { -# "seconds": { -# "type": "integer", -# "description": "Number of seconds to wait (default: 3)" -# } -# } -# } -# } -# }) -# @xml_schema( -# tag_name="browser-wait", -# mappings=[ -# {"param_name": "seconds", "node_type": "content", "path": "."} -# ], -# example=''' -# -# -# 5 -# -# -# ''' -# ) -# async def browser_wait(self, seconds: int = 3) -> ToolResult: -# """Wait for the specified number of seconds - -# Args: -# seconds (int, optional): Number of seconds to wait. Defaults to 3. - -# Returns: -# dict: Result of the execution -# """ -# logger.debug(f"\033[95mWaiting for {seconds} seconds\033[0m") -# return await self._execute_browser_action("wait", {"seconds": seconds}) - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_click_element", -# "description": "Click on an element by index", -# "parameters": { -# "type": "object", -# "properties": { -# "index": { -# "type": "integer", -# "description": "The index of the element to click" -# } -# }, -# "required": ["index"] -# } -# } -# }) -# @xml_schema( -# tag_name="browser-click-element", -# mappings=[ -# {"param_name": "index", "node_type": "content", "path": "."} -# ], -# example=''' -# -# -# 2 -# -# -# ''' -# ) -# async def browser_click_element(self, index: int) -> ToolResult: -# """Click on an element by index - -# Args: -# index (int): The index of the element to click - -# Returns: -# dict: Result of the execution -# """ -# logger.debug(f"\033[95mClicking element with index: {index}\033[0m") -# return await self._execute_browser_action("click_element", {"index": index}) - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_input_text", -# "description": "Input text into an element", -# "parameters": { -# "type": "object", -# "properties": { -# "index": { -# "type": "integer", -# "description": "The index of the element to input text into" -# }, -# "text": { -# "type": "string", -# "description": "The text to input" -# } -# }, -# "required": ["index", "text"] -# } -# } -# }) -# @xml_schema( -# tag_name="browser-input-text", -# mappings=[ -# {"param_name": "index", "node_type": "attribute", "path": "."}, -# {"param_name": "text", "node_type": "content", "path": "."} -# ], -# example=''' -# -# -# 2 -# Hello, world! -# -# -# ''' -# ) -# async def browser_input_text(self, index: int, text: str) -> ToolResult: -# """Input text into an element - -# Args: -# index (int): The index of the element to input text into -# text (str): The text to input - -# Returns: -# dict: Result of the execution -# """ -# logger.debug(f"\033[95mInputting text into element {index}: {text}\033[0m") -# return await self._execute_browser_action("input_text", {"index": index, "text": text}) - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_send_keys", -# "description": "Send keyboard keys such as Enter, Escape, or keyboard shortcuts", -# "parameters": { -# "type": "object", -# "properties": { -# "keys": { -# "type": "string", -# "description": "The keys to send (e.g., 'Enter', 'Escape', 'Control+a')" -# } -# }, -# "required": ["keys"] -# } -# } -# }) -# @xml_schema( -# tag_name="browser-send-keys", -# mappings=[ -# {"param_name": "keys", "node_type": "content", "path": "."} -# ], -# example=''' -# -# -# Enter -# -# -# ''' -# ) -# async def browser_send_keys(self, keys: str) -> ToolResult: -# """Send keyboard keys - -# Args: -# keys (str): The keys to send (e.g., 'Enter', 'Escape', 'Control+a') - -# Returns: -# dict: Result of the execution -# """ -# logger.debug(f"\033[95mSending keys: {keys}\033[0m") -# return await self._execute_browser_action("send_keys", {"keys": keys}) - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_switch_tab", -# "description": "Switch to a different browser tab", -# "parameters": { -# "type": "object", -# "properties": { -# "page_id": { -# "type": "integer", -# "description": "The ID of the tab to switch to" -# } -# }, -# "required": ["page_id"] -# } -# } -# }) -# @xml_schema( -# tag_name="browser-switch-tab", -# mappings=[ -# {"param_name": "page_id", "node_type": "content", "path": "."} -# ], -# example=''' -# -# -# 1 -# -# -# ''' -# ) -# async def browser_switch_tab(self, page_id: int) -> ToolResult: -# """Switch to a different browser tab - -# Args: -# page_id (int): The ID of the tab to switch to - -# Returns: -# dict: Result of the execution -# """ -# logger.debug(f"\033[95mSwitching to tab: {page_id}\033[0m") -# return await self._execute_browser_action("switch_tab", {"page_id": page_id}) - -# # @openapi_schema({ -# # "type": "function", -# # "function": { -# # "name": "browser_open_tab", -# # "description": "Open a new browser tab with the specified URL", -# # "parameters": { -# # "type": "object", -# # "properties": { -# # "url": { -# # "type": "string", -# # "description": "The URL to open in the new tab" -# # } -# # }, -# # "required": ["url"] -# # } -# # } -# # }) -# # @xml_schema( -# # tag_name="browser-open-tab", -# # mappings=[ -# # {"param_name": "url", "node_type": "content", "path": "."} -# # ], -# # example=''' -# # -# # https://example.com -# # -# # ''' -# # ) -# # async def browser_open_tab(self, url: str) -> ToolResult: -# # """Open a new browser tab with the specified URL - -# # Args: -# # url (str): The URL to open in the new tab - -# # Returns: -# # dict: Result of the execution -# # """ -# # logger.debug(f"\033[95mOpening new tab with URL: {url}\033[0m") -# # return await self._execute_browser_action("open_tab", {"url": url}) - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_close_tab", -# "description": "Close a browser tab", -# "parameters": { -# "type": "object", -# "properties": { -# "page_id": { -# "type": "integer", -# "description": "The ID of the tab to close" -# } -# }, -# "required": ["page_id"] -# } -# } -# }) -# @xml_schema( -# tag_name="browser-close-tab", -# mappings=[ -# {"param_name": "page_id", "node_type": "content", "path": "."} -# ], -# example=''' -# -# -# 1 -# -# -# ''' -# ) -# async def browser_close_tab(self, page_id: int) -> ToolResult: -# """Close a browser tab - -# Args: -# page_id (int): The ID of the tab to close - -# Returns: -# dict: Result of the execution -# """ -# logger.debug(f"\033[95mClosing tab: {page_id}\033[0m") -# return await self._execute_browser_action("close_tab", {"page_id": page_id}) - -# # @openapi_schema({ -# # "type": "function", -# # "function": { -# # "name": "browser_extract_content", -# # "description": "Extract content from the current page based on the provided goal", -# # "parameters": { -# # "type": "object", -# # "properties": { -# # "goal": { -# # "type": "string", -# # "description": "The extraction goal (e.g., 'extract all links', 'find product information')" -# # } -# # }, -# # "required": ["goal"] -# # } -# # } -# # }) -# # @xml_schema( -# # tag_name="browser-extract-content", -# # mappings=[ -# # {"param_name": "goal", "node_type": "content", "path": "."} -# # ], -# # example=''' -# # -# # Extract all links on the page -# # -# # ''' -# # ) -# # async def browser_extract_content(self, goal: str) -> ToolResult: -# # """Extract content from the current page based on the provided goal - -# # Args: -# # goal (str): The extraction goal - -# # Returns: -# # dict: Result of the execution -# # """ -# # logger.debug(f"\033[95mExtracting content with goal: {goal}\033[0m") -# # result = await self._execute_browser_action("extract_content", {"goal": goal}) - -# # # Format content for better readability -# # if result.get("success"): -# # logger.debug(f"\033[92mContent extraction successful\033[0m") -# # content = result.data.get("content", "") -# # url = result.data.get("url", "") -# # title = result.data.get("title", "") - -# # if content: -# # content_preview = content[:200] + "..." if len(content) > 200 else content -# # logger.debug(f"\033[95mExtracted content from {title} ({url}):\033[0m") -# # logger.debug(f"\033[96m{content_preview}\033[0m") -# # logger.debug(f"\033[95mTotal content length: {len(content)} characters\033[0m") -# # else: -# # logger.debug(f"\033[93mNo content extracted from {url}\033[0m") -# # else: -# # logger.debug(f"\033[91mFailed to extract content: {result.data.get('error', 'Unknown error')}\033[0m") - -# # return result - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_scroll_down", -# "description": "Scroll down the page", -# "parameters": { -# "type": "object", -# "properties": { -# "amount": { -# "type": "integer", -# "description": "Pixel amount to scroll (if not specified, scrolls one page)" -# } -# } -# } -# } -# }) -# @xml_schema( -# tag_name="browser-scroll-down", -# mappings=[ -# {"param_name": "amount", "node_type": "content", "path": "."} -# ], -# example=''' -# -# -# 500 -# -# -# ''' -# ) -# async def browser_scroll_down(self, amount: int = None) -> ToolResult: -# """Scroll down the page - -# Args: -# amount (int, optional): Pixel amount to scroll. If None, scrolls one page. - -# Returns: -# dict: Result of the execution -# """ -# params = {} -# if amount is not None: -# params["amount"] = amount -# logger.debug(f"\033[95mScrolling down by {amount} pixels\033[0m") -# else: -# logger.debug(f"\033[95mScrolling down one page\033[0m") - -# return await self._execute_browser_action("scroll_down", params) - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_scroll_up", -# "description": "Scroll up the page", -# "parameters": { -# "type": "object", -# "properties": { -# "amount": { -# "type": "integer", -# "description": "Pixel amount to scroll (if not specified, scrolls one page)" -# } -# } -# } -# } -# }) -# @xml_schema( -# tag_name="browser-scroll-up", -# mappings=[ -# {"param_name": "amount", "node_type": "content", "path": "."} -# ], -# example=''' -# -# -# 500 -# -# -# ''' -# ) -# async def browser_scroll_up(self, amount: int = None) -> ToolResult: -# """Scroll up the page - -# Args: -# amount (int, optional): Pixel amount to scroll. If None, scrolls one page. - -# Returns: -# dict: Result of the execution -# """ -# params = {} -# if amount is not None: -# params["amount"] = amount -# logger.debug(f"\033[95mScrolling up by {amount} pixels\033[0m") -# else: -# logger.debug(f"\033[95mScrolling up one page\033[0m") - -# return await self._execute_browser_action("scroll_up", params) - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_scroll_to_text", -# "description": "Scroll to specific text on the page", -# "parameters": { -# "type": "object", -# "properties": { -# "text": { -# "type": "string", -# "description": "The text to scroll to" -# } -# }, -# "required": ["text"] -# } -# } -# }) -# @xml_schema( -# tag_name="browser-scroll-to-text", -# mappings=[ -# {"param_name": "text", "node_type": "content", "path": "."} -# ], -# example=''' -# -# -# Contact Us -# -# -# ''' -# ) -# async def browser_scroll_to_text(self, text: str) -> ToolResult: -# """Scroll to specific text on the page - -# Args: -# text (str): The text to scroll to - -# Returns: -# dict: Result of the execution -# """ -# logger.debug(f"\033[95mScrolling to text: {text}\033[0m") -# return await self._execute_browser_action("scroll_to_text", {"text": text}) - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_get_dropdown_options", -# "description": "Get all options from a dropdown element", -# "parameters": { -# "type": "object", -# "properties": { -# "index": { -# "type": "integer", -# "description": "The index of the dropdown element" -# } -# }, -# "required": ["index"] -# } -# } -# }) -# @xml_schema( -# tag_name="browser-get-dropdown-options", -# mappings=[ -# {"param_name": "index", "node_type": "content", "path": "."} -# ], -# example=''' -# -# -# 2 -# -# -# ''' -# ) -# async def browser_get_dropdown_options(self, index: int) -> ToolResult: -# """Get all options from a dropdown element - -# Args: -# index (int): The index of the dropdown element - -# Returns: -# dict: Result of the execution with the dropdown options -# """ -# logger.debug(f"\033[95mGetting options from dropdown with index: {index}\033[0m") -# return await self._execute_browser_action("get_dropdown_options", {"index": index}) - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_select_dropdown_option", -# "description": "Select an option from a dropdown by text", -# "parameters": { -# "type": "object", -# "properties": { -# "index": { -# "type": "integer", -# "description": "The index of the dropdown element" -# }, -# "text": { -# "type": "string", -# "description": "The text of the option to select" -# } -# }, -# "required": ["index", "text"] -# } -# } -# }) -# @xml_schema( -# tag_name="browser-select-dropdown-option", -# mappings=[ -# {"param_name": "index", "node_type": "attribute", "path": "."}, -# {"param_name": "text", "node_type": "content", "path": "."} -# ], -# example=''' -# -# -# 2 -# Option 1 -# -# -# ''' -# ) -# async def browser_select_dropdown_option(self, index: int, text: str) -> ToolResult: -# """Select an option from a dropdown by text - -# Args: -# index (int): The index of the dropdown element -# text (str): The text of the option to select - -# Returns: -# dict: Result of the execution -# """ -# logger.debug(f"\033[95mSelecting option '{text}' from dropdown with index: {index}\033[0m") -# return await self._execute_browser_action("select_dropdown_option", {"index": index, "text": text}) - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_drag_drop", -# "description": "Perform drag and drop operation between elements or coordinates", -# "parameters": { -# "type": "object", -# "properties": { -# "element_source": { -# "type": "string", -# "description": "The source element selector" -# }, -# "element_target": { -# "type": "string", -# "description": "The target element selector" -# }, -# "coord_source_x": { -# "type": "integer", -# "description": "The source X coordinate" -# }, -# "coord_source_y": { -# "type": "integer", -# "description": "The source Y coordinate" -# }, -# "coord_target_x": { -# "type": "integer", -# "description": "The target X coordinate" -# }, -# "coord_target_y": { -# "type": "integer", -# "description": "The target Y coordinate" -# } -# } -# } -# } -# }) -# @xml_schema( -# tag_name="browser-drag-drop", -# mappings=[ -# {"param_name": "element_source", "node_type": "attribute", "path": "."}, -# {"param_name": "element_target", "node_type": "attribute", "path": "."}, -# {"param_name": "coord_source_x", "node_type": "attribute", "path": "."}, -# {"param_name": "coord_source_y", "node_type": "attribute", "path": "."}, -# {"param_name": "coord_target_x", "node_type": "attribute", "path": "."}, -# {"param_name": "coord_target_y", "node_type": "attribute", "path": "."} -# ], -# example=''' -# -# -# #draggable -# #droppable -# -# -# ''' -# ) -# async def browser_drag_drop(self, element_source: str = None, element_target: str = None, -# coord_source_x: int = None, coord_source_y: int = None, -# coord_target_x: int = None, coord_target_y: int = None) -> ToolResult: -# """Perform drag and drop operation between elements or coordinates - -# Args: -# element_source (str, optional): The source element selector -# element_target (str, optional): The target element selector -# coord_source_x (int, optional): The source X coordinate -# coord_source_y (int, optional): The source Y coordinate -# coord_target_x (int, optional): The target X coordinate -# coord_target_y (int, optional): The target Y coordinate - -# Returns: -# dict: Result of the execution -# """ -# params = {} - -# if element_source and element_target: -# params["element_source"] = element_source -# params["element_target"] = element_target -# logger.debug(f"\033[95mDragging from element '{element_source}' to '{element_target}'\033[0m") -# elif all(coord is not None for coord in [coord_source_x, coord_source_y, coord_target_x, coord_target_y]): -# params["coord_source_x"] = coord_source_x -# params["coord_source_y"] = coord_source_y -# params["coord_target_x"] = coord_target_x -# params["coord_target_y"] = coord_target_y -# logger.debug(f"\033[95mDragging from coordinates ({coord_source_x}, {coord_source_y}) to ({coord_target_x}, {coord_target_y})\033[0m") -# else: -# return self.fail_response("Must provide either element selectors or coordinates for drag and drop") - -# return await self._execute_browser_action("drag_drop", params) - -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "browser_click_coordinates", -# "description": "Click at specific X,Y coordinates on the page", -# "parameters": { -# "type": "object", -# "properties": { -# "x": { -# "type": "integer", -# "description": "The X coordinate to click" -# }, -# "y": { -# "type": "integer", -# "description": "The Y coordinate to click" -# } -# }, -# "required": ["x", "y"] -# } -# } -# }) -# @xml_schema( -# tag_name="browser-click-coordinates", -# mappings=[ -# {"param_name": "x", "node_type": "attribute", "path": "."}, -# {"param_name": "y", "node_type": "attribute", "path": "."} -# ], -# example=''' -# -# -# 100 -# 200 -# -# -# ''' -# ) -# async def browser_click_coordinates(self, x: int, y: int) -> ToolResult: -# """Click at specific X,Y coordinates on the page - -# Args: -# x (int): The X coordinate to click -# y (int): The Y coordinate to click - -# Returns: -# dict: Result of the execution -# """ -# logger.debug(f"\033[95mClicking at coordinates: ({x}, {y})\033[0m") -# return await self._execute_browser_action("click_coordinates", {"x": x, "y": y}) @classmethod def create_with_sandbox(cls, sandbox: Sandbox) -> "SandboxBrowserTool": diff --git a/app/tool/sb_deploy_tool.py b/app/tool/sb_deploy_tool.py deleted file mode 100644 index 7be0929fa..000000000 --- a/app/tool/sb_deploy_tool.py +++ /dev/null @@ -1,147 +0,0 @@ -import os -from dotenv import load_dotenv -from agentpress.tool import ToolResult, openapi_schema, xml_schema -from daytona.tool_base import SandboxToolsBase -from utils.files_utils import clean_path -from agentpress.thread_manager import ThreadManager - -# Load environment variables -load_dotenv() - -class SandboxDeployTool(SandboxToolsBase): - """Tool for deploying static websites from a Daytona sandbox to Cloudflare Pages.""" - - def __init__(self, project_id: str, thread_manager: ThreadManager): - super().__init__(project_id, thread_manager) - self.workspace_path = "/workspace" # Ensure we're always operating in /workspace - self.cloudflare_api_token = os.getenv("CLOUDFLARE_API_TOKEN") - - def clean_path(self, path: str) -> str: - """Clean and normalize a path to be relative to /workspace""" - return clean_path(path, self.workspace_path) - - @openapi_schema({ - "type": "function", - "function": { - "name": "deploy", - "description": "Deploy a static website (HTML+CSS+JS) from a directory in the sandbox to Cloudflare Pages. Only use this tool when permanent deployment to a production environment is needed. The directory path must be relative to /workspace. The website will be deployed to {name}.kortix.cloud.", - "parameters": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name for the deployment, will be used in the URL as {name}.kortix.cloud" - }, - "directory_path": { - "type": "string", - "description": "Path to the directory containing the static website files to deploy, relative to /workspace (e.g., 'build')" - } - }, - "required": ["name", "directory_path"] - } - } - }) - @xml_schema( - tag_name="deploy", - mappings=[ - {"param_name": "name", "node_type": "attribute", "path": "name"}, - {"param_name": "directory_path", "node_type": "attribute", "path": "directory_path"} - ], - example=''' - - - - - my-site - website - - - ''' - ) - async def deploy(self, name: str, directory_path: str) -> ToolResult: - """ - Deploy a static website (HTML+CSS+JS) from the sandbox to Cloudflare Pages. - Only use this tool when permanent deployment to a production environment is needed. - - Args: - name: Name for the deployment, will be used in the URL as {name}.kortix.cloud - directory_path: Path to the directory to deploy, relative to /workspace - - Returns: - ToolResult containing: - - Success: Deployment information including URL - - Failure: Error message if deployment fails - """ - try: - # Ensure sandbox is initialized - await self._ensure_sandbox() - - directory_path = self.clean_path(directory_path) - full_path = f"{self.workspace_path}/{directory_path}" - - # Verify the directory exists - try: - dir_info = self.sandbox.fs.get_file_info(full_path) - if not dir_info.is_dir: - return self.fail_response(f"'{directory_path}' is not a directory") - except Exception as e: - return self.fail_response(f"Directory '{directory_path}' does not exist: {str(e)}") - - # Deploy to Cloudflare Pages directly from the container - try: - # Get Cloudflare API token from environment - if not self.cloudflare_api_token: - return self.fail_response("CLOUDFLARE_API_TOKEN environment variable not set") - - # Single command that creates the project if it doesn't exist and then deploys - project_name = f"{self.sandbox_id}-{name}" - deploy_cmd = f'''cd {self.workspace_path} && export CLOUDFLARE_API_TOKEN={self.cloudflare_api_token} && - (npx wrangler pages deploy {full_path} --project-name {project_name} || - (npx wrangler pages project create {project_name} --production-branch production && - 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(f"/bin/sh -c \"{deploy_cmd}\"", - timeout=300) - - print(f"Deployment command output: {response.result}") - - if response.exit_code == 0: - return self.success_response({ - "message": f"Website deployed successfully", - "output": response.result - }) - else: - return self.fail_response(f"Deployment failed with exit code {response.exit_code}: {response.result}") - except Exception as e: - return self.fail_response(f"Error during deployment: {str(e)}") - except Exception as e: - return self.fail_response(f"Error deploying website: {str(e)}") - -if __name__ == "__main__": - import asyncio - import sys - - async def test_deploy(): - # Replace these with actual values for testing - sandbox_id = "sandbox-ccb30b35" - password = "test-password" - - # Initialize the deploy tool - deploy_tool = SandboxDeployTool(sandbox_id, password) - - # Test deployment - replace with actual directory path and site name - result = await deploy_tool.deploy( - name="test-site-1x", - directory_path="website" # Directory containing static site files - ) - print(f"Deployment result: {result}") - - asyncio.run(test_deploy()) - diff --git a/app/tool/sb_expose_tool.py b/app/tool/sb_expose_tool.py deleted file mode 100644 index dc64a6f2b..000000000 --- a/app/tool/sb_expose_tool.py +++ /dev/null @@ -1,126 +0,0 @@ -from agentpress.tool import ToolResult, openapi_schema, xml_schema -from daytona.tool_base import SandboxToolsBase -from agentpress.thread_manager import ThreadManager -import asyncio -import time - -class SandboxExposeTool(SandboxToolsBase): - """Tool for exposing and retrieving preview URLs for sandbox ports.""" - - def __init__(self, project_id: str, thread_manager: ThreadManager): - super().__init__(project_id, thread_manager) - - async def _wait_for_sandbox_services(self, timeout: int = 30) -> bool: - """Wait for sandbox services to be fully started before exposing ports.""" - start_time = time.time() - - while time.time() - start_time < timeout: - try: - # Check if supervisord is running and managing services - result = self.sandbox.process.exec("supervisorctl status", timeout=10) - - if result.exit_code == 0: - # Check if key services are running - status_output = result.output - if "http_server" in status_output and "RUNNING" in status_output: - return True - - # If services aren't ready, wait a bit - await asyncio.sleep(2) - - except Exception as e: - # If we can't check status, wait a bit and try again - await asyncio.sleep(2) - - return False - - @openapi_schema({ - "type": "function", - "function": { - "name": "expose_port", - "description": "Expose a port from the agent's sandbox environment to the public internet and get its preview URL. This is essential for making services running in the sandbox accessible to users, such as web applications, APIs, or other network services. The exposed URL can be shared with users to allow them to interact with the sandbox environment.", - "parameters": { - "type": "object", - "properties": { - "port": { - "type": "integer", - "description": "The port number to expose. Must be a valid port number between 1 and 65535.", - "minimum": 1, - "maximum": 65535 - } - }, - "required": ["port"] - } - } - }) - @xml_schema( - tag_name="expose-port", - mappings=[ - {"param_name": "port", "node_type": "content", "path": "."} - ], - example=''' - - - - 8000 - - - - - - - 3000 - - - - - - - 5173 - - - ''' - ) - async def expose_port(self, port: int) -> ToolResult: - try: - # Ensure sandbox is initialized - await self._ensure_sandbox() - - # Convert port to integer if it's a string - port = int(port) - - # Validate port number - if not 1 <= port <= 65535: - return self.fail_response(f"Invalid port number: {port}. Must be between 1 and 65535.") - - # Wait for sandbox services to be ready (especially important for workflows) - services_ready = await self._wait_for_sandbox_services() - if not services_ready: - return self.fail_response(f"Sandbox services are not fully started yet. Please wait a moment and try again, or ensure a service is running on port {port}.") - - # Check if something is actually listening on the port (for custom ports) - if port not in [6080, 8080, 8003]: # Skip check for known sandbox ports - try: - port_check = self.sandbox.process.exec(f"netstat -tlnp | grep :{port}", timeout=5) - if port_check.exit_code != 0: - return self.fail_response(f"No service is currently listening on port {port}. Please start a service on this port first.") - except Exception: - # If we can't check, proceed anyway - the user might be starting a service - pass - - # Get the preview link for the specified port - preview_link = self.sandbox.get_preview_link(port) - - # Extract the actual URL from the preview link object - url = preview_link.url if hasattr(preview_link, 'url') else str(preview_link) - - return self.success_response({ - "url": url, - "port": port, - "message": f"Successfully exposed port {port} to the public. Users can now access this service at: {url}" - }) - - except ValueError: - return self.fail_response(f"Invalid port number: {port}. Must be a valid integer between 1 and 65535.") - except Exception as e: - return self.fail_response(f"Error exposing port {port}: {str(e)}") diff --git a/app/tool/sb_files_tool.py b/app/tool/sb_files_tool.py index a9601f67a..f603814c6 100644 --- a/app/tool/sb_files_tool.py +++ b/app/tool/sb_files_tool.py @@ -3,9 +3,7 @@ from app.tool.base import ToolResult from app.daytona.tool_base import SandboxToolsBase, Sandbox from app.utils.files_utils import should_exclude_file, clean_path -# from app.agentpress.thread_manager import ThreadManager from app.utils.logger import logger -import os import asyncio Context = TypeVar("Context") diff --git a/app/tool/sb_shell_tool.py b/app/tool/sb_shell_tool.py index 291bce4c3..135e7f5c9 100644 --- a/app/tool/sb_shell_tool.py +++ b/app/tool/sb_shell_tool.py @@ -377,426 +377,3 @@ async def cleanup(self): except Exception as e: logger.error(f"Error shell box cleanup action: {e}") pass -# from typing import Optional, Dict, Any -# import time -# from uuid import uuid4 -# from agentpress.tool import ToolResult, openapi_schema, xml_schema -# from daytona.tool_base import SandboxToolsBase -# from agentpress.thread_manager import ThreadManager -# -# class SandboxShellTool(SandboxToolsBase): -# """Tool for executing tasks in a Daytona sandbox with browser-use capabilities. -# Uses sessions for maintaining state between commands and provides comprehensive process management.""" -# -# def __init__(self, project_id: str, thread_manager: ThreadManager): -# super().__init__(project_id, thread_manager) -# self._sessions: Dict[str, str] = {} # Maps session names to session IDs -# self.workspace_path = "/workspace" # Ensure we're always operating in /workspace -# -# async def _ensure_session(self, session_name: str = "default") -> str: -# """Ensure a session exists and return its ID.""" -# if session_name not in self._sessions: -# session_id = str(uuid4()) -# try: -# await self._ensure_sandbox() # Ensure sandbox is initialized -# self.sandbox.process.create_session(session_id) -# self._sessions[session_name] = session_id -# except Exception as e: -# raise RuntimeError(f"Failed to create session: {str(e)}") -# return self._sessions[session_name] -# -# async def _cleanup_session(self, session_name: str): -# """Clean up a session if it exists.""" -# if session_name in self._sessions: -# try: -# await self._ensure_sandbox() # Ensure sandbox is initialized -# self.sandbox.process.delete_session(self._sessions[session_name]) -# del self._sessions[session_name] -# except Exception as e: -# print(f"Warning: Failed to cleanup session {session_name}: {str(e)}") -# -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "execute_command", -# "description": "Execute a shell command in the workspace directory. IMPORTANT: Commands are non-blocking by default and run in a tmux session. This is ideal for long-running operations like starting servers or build processes. Uses sessions to maintain state between commands. This tool is essential for running CLI tools, installing packages, and managing system operations.", -# "parameters": { -# "type": "object", -# "properties": { -# "command": { -# "type": "string", -# "description": "The shell command to execute. Use this for running CLI tools, installing packages, or system operations. Commands can be chained using &&, ||, and | operators." -# }, -# "folder": { -# "type": "string", -# "description": "Optional relative path to a subdirectory of /workspace where the command should be executed. Example: 'data/pdfs'" -# }, -# "session_name": { -# "type": "string", -# "description": "Optional name of the tmux session to use. Use named sessions for related commands that need to maintain state. Defaults to a random session name.", -# }, -# "blocking": { -# "type": "boolean", -# "description": "Whether to wait for the command to complete. Defaults to false for non-blocking execution.", -# "default": False -# }, -# "timeout": { -# "type": "integer", -# "description": "Optional timeout in seconds for blocking commands. Defaults to 60. Ignored for non-blocking commands.", -# "default": 60 -# } -# }, -# "required": ["command"] -# } -# } -# }) -# @xml_schema( -# tag_name="execute-command", -# mappings=[ -# {"param_name": "command", "node_type": "content", "path": "."}, -# {"param_name": "folder", "node_type": "attribute", "path": ".", "required": False}, -# {"param_name": "session_name", "node_type": "attribute", "path": ".", "required": False}, -# {"param_name": "blocking", "node_type": "attribute", "path": ".", "required": False}, -# {"param_name": "timeout", "node_type": "attribute", "path": ".", "required": False} -# ], -# example=''' -# -# -# npm run dev -# dev_server -# -# -# -# -# -# -# npm run build -# frontend -# build_process -# -# -# -# -# -# -# npm install -# true -# 300 -# -# -# ''' -# ) -# async def execute_command( -# self, -# command: str, -# folder: Optional[str] = None, -# session_name: Optional[str] = None, -# blocking: bool = False, -# timeout: int = 60 -# ) -> ToolResult: -# try: -# # Ensure sandbox is initialized -# await self._ensure_sandbox() -# -# # Set up working directory -# cwd = self.workspace_path -# if folder: -# folder = folder.strip('/') -# cwd = f"{self.workspace_path}/{folder}" -# -# # Generate a session name if not provided -# if not session_name: -# session_name = f"session_{str(uuid4())[:8]}" -# -# # Check if tmux session already exists -# check_session = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") -# session_exists = "not_exists" not in check_session.get("output", "") -# -# if not session_exists: -# # Create a new tmux session -# await self._execute_raw_command(f"tmux new-session -d -s {session_name}") -# -# # Ensure we're in the correct directory and send command to tmux -# full_command = f"cd {cwd} && {command}" -# wrapped_command = full_command.replace('"', '\\"') # Escape double quotes -# -# # Send command to tmux session -# await self._execute_raw_command(f'tmux send-keys -t {session_name} "{wrapped_command}" Enter') -# -# if blocking: -# # For blocking execution, wait and capture output -# start_time = time.time() -# while (time.time() - start_time) < timeout: -# # Wait a bit before checking -# time.sleep(2) -# -# # Check if session still exists (command might have exited) -# check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'ended'") -# if "ended" in check_result.get("output", ""): -# break -# -# # Get current output and check for common completion indicators -# output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") -# current_output = output_result.get("output", "") -# -# # Check for prompt indicators that suggest command completion -# last_lines = current_output.split('\n')[-3:] -# completion_indicators = ['$', '#', '>', 'Done', 'Completed', 'Finished', '✓'] -# if any(indicator in line for indicator in completion_indicators for line in last_lines): -# break -# -# # Capture final output -# output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") -# final_output = output_result.get("output", "") -# -# # Kill the session after capture -# await self._execute_raw_command(f"tmux kill-session -t {session_name}") -# -# return self.success_response({ -# "output": final_output, -# "session_name": session_name, -# "cwd": cwd, -# "completed": True -# }) -# else: -# # For non-blocking, just return immediately -# return self.success_response({ -# "session_name": session_name, -# "cwd": cwd, -# "message": f"Command sent to tmux session '{session_name}'. Use check_command_output to view results.", -# "completed": False -# }) -# -# except Exception as e: -# # Attempt to clean up session in case of error -# if session_name: -# try: -# await self._execute_raw_command(f"tmux kill-session -t {session_name}") -# except: -# pass -# return self.fail_response(f"Error executing command: {str(e)}") -# -# async def _execute_raw_command(self, command: str) -> Dict[str, Any]: -# """Execute a raw command directly in the sandbox.""" -# # Ensure session exists for raw commands -# session_id = await self._ensure_session("raw_commands") -# -# # Execute command in session -# from sandbox.sandbox import SessionExecuteRequest -# req = SessionExecuteRequest( -# command=command, -# var_async=False, -# cwd=self.workspace_path -# ) -# -# response = self.sandbox.process.execute_session_command( -# session_id=session_id, -# req=req, -# timeout=30 # Short timeout for utility commands -# ) -# -# logs = self.sandbox.process.get_session_command_logs( -# session_id=session_id, -# command_id=response.cmd_id -# ) -# -# return { -# "output": logs, -# "exit_code": response.exit_code -# } -# -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "check_command_output", -# "description": "Check the output of a previously executed command in a tmux session. Use this to monitor the progress or results of non-blocking commands.", -# "parameters": { -# "type": "object", -# "properties": { -# "session_name": { -# "type": "string", -# "description": "The name of the tmux session to check." -# }, -# "kill_session": { -# "type": "boolean", -# "description": "Whether to terminate the tmux session after checking. Set to true when you're done with the command.", -# "default": False -# } -# }, -# "required": ["session_name"] -# } -# } -# }) -# @xml_schema( -# tag_name="check-command-output", -# mappings=[ -# {"param_name": "session_name", "node_type": "attribute", "path": ".", "required": True}, -# {"param_name": "kill_session", "node_type": "attribute", "path": ".", "required": False} -# ], -# example=''' -# -# -# dev_server -# -# -# -# -# -# -# build_process -# true -# -# -# ''' -# ) -# async def check_command_output( -# self, -# session_name: str, -# kill_session: bool = False -# ) -> ToolResult: -# try: -# # Ensure sandbox is initialized -# await self._ensure_sandbox() -# -# # Check if session exists -# check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") -# if "not_exists" in check_result.get("output", ""): -# return self.fail_response(f"Tmux session '{session_name}' does not exist.") -# -# # Get output from tmux pane -# output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") -# output = output_result.get("output", "") -# -# # Kill session if requested -# if kill_session: -# await self._execute_raw_command(f"tmux kill-session -t {session_name}") -# termination_status = "Session terminated." -# else: -# termination_status = "Session still running." -# -# return self.success_response({ -# "output": output, -# "session_name": session_name, -# "status": termination_status -# }) -# -# except Exception as e: -# return self.fail_response(f"Error checking command output: {str(e)}") -# -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "terminate_command", -# "description": "Terminate a running command by killing its tmux session.", -# "parameters": { -# "type": "object", -# "properties": { -# "session_name": { -# "type": "string", -# "description": "The name of the tmux session to terminate." -# } -# }, -# "required": ["session_name"] -# } -# } -# }) -# @xml_schema( -# tag_name="terminate-command", -# mappings=[ -# {"param_name": "session_name", "node_type": "attribute", "path": ".", "required": True} -# ], -# example=''' -# -# -# dev_server -# -# -# ''' -# ) -# async def terminate_command( -# self, -# session_name: str -# ) -> ToolResult: -# try: -# # Ensure sandbox is initialized -# await self._ensure_sandbox() -# -# # Check if session exists -# check_result = await self._execute_raw_command(f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") -# if "not_exists" in check_result.get("output", ""): -# return self.fail_response(f"Tmux session '{session_name}' does not exist.") -# -# # Kill the session -# await self._execute_raw_command(f"tmux kill-session -t {session_name}") -# -# return self.success_response({ -# "message": f"Tmux session '{session_name}' terminated successfully." -# }) -# -# except Exception as e: -# return self.fail_response(f"Error terminating command: {str(e)}") -# -# @openapi_schema({ -# "type": "function", -# "function": { -# "name": "list_commands", -# "description": "List all running tmux sessions and their status.", -# "parameters": { -# "type": "object", -# "properties": {} -# } -# } -# }) -# @xml_schema( -# tag_name="list-commands", -# mappings=[], -# example=''' -# -# -# -# -# ''' -# ) -# async def list_commands(self) -> ToolResult: -# try: -# # Ensure sandbox is initialized -# await self._ensure_sandbox() -# -# # List all tmux sessions -# result = await self._execute_raw_command("tmux list-sessions 2>/dev/null || echo 'No sessions'") -# output = result.get("output", "") -# -# if "No sessions" in output or not output.strip(): -# return self.success_response({ -# "message": "No active tmux sessions found.", -# "sessions": [] -# }) -# -# # Parse session list -# sessions = [] -# for line in output.split('\n'): -# if line.strip(): -# parts = line.split(':') -# if parts: -# session_name = parts[0].strip() -# sessions.append(session_name) -# -# return self.success_response({ -# "message": f"Found {len(sessions)} active sessions.", -# "sessions": sessions -# }) -# -# except Exception as e: -# return self.fail_response(f"Error listing commands: {str(e)}") -# -# async def cleanup(self): -# """Clean up all sessions.""" -# for session_name in list(self._sessions.keys()): -# await self._cleanup_session(session_name) -# -# # Also clean up any tmux sessions -# try: -# await self._ensure_sandbox() -# await self._execute_raw_command("tmux kill-server 2>/dev/null || true") -# except: -# pass diff --git a/app/tool/sb_vision_tool.py b/app/tool/sb_vision_tool.py index 7b45c5054..a7b902241 100644 --- a/app/tool/sb_vision_tool.py +++ b/app/tool/sb_vision_tool.py @@ -2,7 +2,6 @@ import os import base64 import mimetypes -import json from typing import Optional from io import BytesIO from PIL import Image diff --git a/app/tool/web_search_tool.py b/app/tool/web_search_tool.py deleted file mode 100644 index 3f1e9a3e3..000000000 --- a/app/tool/web_search_tool.py +++ /dev/null @@ -1,395 +0,0 @@ -from tavily import AsyncTavilyClient -import httpx -from dotenv import load_dotenv -from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema -from utils.config import config -from daytona.tool_base import SandboxToolsBase -from agentpress.thread_manager import ThreadManager -import json -import os -import datetime -import asyncio -import logging - -# TODO: add subpages, etc... in filters as sometimes its necessary - -class SandboxWebSearchTool(SandboxToolsBase): - """Tool for performing web searches using Tavily API and web scraping using Firecrawl.""" - - def __init__(self, project_id: str, thread_manager: ThreadManager): - super().__init__(project_id, thread_manager) - # Load environment variables - load_dotenv() - # Use API keys from config - self.tavily_api_key = config.TAVILY_API_KEY - self.firecrawl_api_key = config.FIRECRAWL_API_KEY - self.firecrawl_url = config.FIRECRAWL_URL - - if not self.tavily_api_key: - raise ValueError("TAVILY_API_KEY not found in configuration") - if not self.firecrawl_api_key: - raise ValueError("FIRECRAWL_API_KEY not found in configuration") - - # Tavily asynchronous search client - self.tavily_client = AsyncTavilyClient(api_key=self.tavily_api_key) - - @openapi_schema({ - "type": "function", - "function": { - "name": "web_search", - "description": "Search the web for up-to-date information on a specific topic using the Tavily API. This tool allows you to gather real-time information from the internet to answer user queries, research topics, validate facts, and find recent developments. Results include titles, URLs, and publication dates. Use this tool for discovering relevant web pages before potentially crawling them for complete content.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "The search query to find relevant web pages. Be specific and include key terms to improve search accuracy. For best results, use natural language questions or keyword combinations that precisely describe what you're looking for." - }, - "num_results": { - "type": "integer", - "description": "The number of search results to return. Increase for more comprehensive research or decrease for focused, high-relevance results.", - "default": 20 - } - }, - "required": ["query"] - } - } - }) - @xml_schema( - tag_name="web-search", - mappings=[ - {"param_name": "query", "node_type": "attribute", "path": "."}, - {"param_name": "num_results", "node_type": "attribute", "path": "."} - ], - example=''' - - - what is Kortix AI and what are they building? - 20 - - - - - - - latest AI research on transformer models - 20 - - - ''' - ) - async def web_search( - self, - query: str, - num_results: int = 20 - ) -> ToolResult: - """ - Search the web using the Tavily API to find relevant and up-to-date information. - """ - try: - # Ensure we have a valid query - if not query or not isinstance(query, str): - return self.fail_response("A valid search query is required.") - - # Normalize num_results - if num_results is None: - num_results = 20 - elif isinstance(num_results, int): - num_results = max(1, min(num_results, 50)) - elif isinstance(num_results, str): - try: - num_results = max(1, min(int(num_results), 50)) - except ValueError: - num_results = 20 - else: - num_results = 20 - - # Execute the search with Tavily - logging.info(f"Executing web search for query: '{query}' with {num_results} results") - search_response = await self.tavily_client.search( - query=query, - max_results=num_results, - include_images=True, - include_answer="advanced", - search_depth="advanced", - ) - - # Check if we have actual results or an answer - results = search_response.get('results', []) - answer = search_response.get('answer', '') - - # Return the complete Tavily response - # This includes the query, answer, results, images and more - logging.info(f"Retrieved search results for query: '{query}' with answer and {len(results)} results") - - # Consider search successful if we have either results OR an answer - if len(results) > 0 or (answer and answer.strip()): - return ToolResult( - success=True, - output=json.dumps(search_response, ensure_ascii=False) - ) - else: - # No results or answer found - logging.warning(f"No search results or answer found for query: '{query}'") - return ToolResult( - success=False, - output=json.dumps(search_response, ensure_ascii=False) - ) - - except Exception as e: - error_message = str(e) - logging.error(f"Error performing web search for '{query}': {error_message}") - simplified_message = f"Error performing web search: {error_message[:200]}" - if len(error_message) > 200: - simplified_message += "..." - return self.fail_response(simplified_message) - - @openapi_schema({ - "type": "function", - "function": { - "name": "scrape_webpage", - "description": "Extract full text content from multiple webpages in a single operation. IMPORTANT: You should ALWAYS collect multiple relevant URLs from web-search results and scrape them all in a single call for efficiency. This tool saves time by processing multiple pages simultaneously rather than one at a time. The extracted text includes the main content of each page without HTML markup.", - "parameters": { - "type": "object", - "properties": { - "urls": { - "type": "string", - "description": "Multiple URLs to scrape, separated by commas. You should ALWAYS include several URLs when possible for efficiency. Example: 'https://example.com/page1,https://example.com/page2,https://example.com/page3'" - } - }, - "required": ["urls"] - } - } - }) - @xml_schema( - tag_name="scrape-webpage", - mappings=[ - {"param_name": "urls", "node_type": "attribute", "path": "."} - ], - example=''' - - - https://www.kortix.ai/,https://github.com/kortix-ai/suna - - - ''' - ) - async def scrape_webpage( - self, - urls: str - ) -> ToolResult: - """ - Retrieve the complete text content of multiple webpages in a single efficient operation. - - ALWAYS collect multiple relevant URLs from search results and scrape them all at once - rather than making separate calls for each URL. This is much more efficient. - - Parameters: - - urls: Multiple URLs to scrape, separated by commas - """ - try: - logging.info(f"Starting to scrape webpages: {urls}") - - # Ensure sandbox is initialized - await self._ensure_sandbox() - - # Parse the URLs parameter - if not urls: - logging.warning("Scrape attempt with empty URLs") - return self.fail_response("Valid URLs are required.") - - # Split the URLs string into a list - url_list = [url.strip() for url in urls.split(',') if url.strip()] - - if not url_list: - logging.warning("No valid URLs found in the input") - return self.fail_response("No valid URLs provided.") - - if len(url_list) == 1: - logging.warning("Only a single URL provided - for efficiency you should scrape multiple URLs at once") - - logging.info(f"Processing {len(url_list)} URLs: {url_list}") - - # Process each URL and collect results - results = [] - for url in url_list: - try: - # Add protocol if missing - if not (url.startswith('http://') or url.startswith('https://')): - url = 'https://' + url - logging.info(f"Added https:// protocol to URL: {url}") - - # Scrape this URL - result = await self._scrape_single_url(url) - results.append(result) - - except Exception as e: - logging.error(f"Error processing URL {url}: {str(e)}") - results.append({ - "url": url, - "success": False, - "error": str(e) - }) - - # Summarize results - successful = sum(1 for r in results if r.get("success", False)) - failed = len(results) - successful - - # Create success/failure message - if successful == len(results): - message = f"Successfully scraped all {len(results)} URLs. Results saved to:" - for r in results: - if r.get("file_path"): - message += f"\n- {r.get('file_path')}" - elif successful > 0: - message = f"Scraped {successful} URLs successfully and {failed} failed. Results saved to:" - for r in results: - if r.get("success", False) and r.get("file_path"): - message += f"\n- {r.get('file_path')}" - message += "\n\nFailed URLs:" - for r in results: - if not r.get("success", False): - message += f"\n- {r.get('url')}: {r.get('error', 'Unknown error')}" - else: - error_details = "; ".join([f"{r.get('url')}: {r.get('error', 'Unknown error')}" for r in results]) - return self.fail_response(f"Failed to scrape all {len(results)} URLs. Errors: {error_details}") - - return ToolResult( - success=True, - output=message - ) - - except Exception as e: - error_message = str(e) - logging.error(f"Error in scrape_webpage: {error_message}") - return self.fail_response(f"Error processing scrape request: {error_message[:200]}") - - async def _scrape_single_url(self, url: str) -> dict: - """ - Helper function to scrape a single URL and return the result information. - """ - logging.info(f"Scraping single URL: {url}") - - try: - # ---------- Firecrawl scrape endpoint ---------- - logging.info(f"Sending request to Firecrawl for URL: {url}") - async with httpx.AsyncClient() as client: - headers = { - "Authorization": f"Bearer {self.firecrawl_api_key}", - "Content-Type": "application/json", - } - payload = { - "url": url, - "formats": ["markdown"] - } - - # Use longer timeout and retry logic for more reliability - max_retries = 3 - timeout_seconds = 120 - retry_count = 0 - - while retry_count < max_retries: - try: - logging.info(f"Sending request to Firecrawl (attempt {retry_count + 1}/{max_retries})") - response = await client.post( - f"{self.firecrawl_url}/v1/scrape", - json=payload, - headers=headers, - timeout=timeout_seconds, - ) - response.raise_for_status() - data = response.json() - logging.info(f"Successfully received response from Firecrawl for {url}") - break - except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.ReadError) as timeout_err: - retry_count += 1 - logging.warning(f"Request timed out (attempt {retry_count}/{max_retries}): {str(timeout_err)}") - if retry_count >= max_retries: - raise Exception(f"Request timed out after {max_retries} attempts with {timeout_seconds}s timeout") - # Exponential backoff - logging.info(f"Waiting {2 ** retry_count}s before retry") - await asyncio.sleep(2 ** retry_count) - except Exception as e: - # Don't retry on non-timeout errors - logging.error(f"Error during scraping: {str(e)}") - raise e - - # Format the response - title = data.get("data", {}).get("metadata", {}).get("title", "") - markdown_content = data.get("data", {}).get("markdown", "") - logging.info(f"Extracted content from {url}: title='{title}', content length={len(markdown_content)}") - - formatted_result = { - "title": title, - "url": url, - "text": markdown_content - } - - # Add metadata if available - if "metadata" in data.get("data", {}): - formatted_result["metadata"] = data["data"]["metadata"] - logging.info(f"Added metadata: {data['data']['metadata'].keys()}") - - # Create a simple filename from the URL domain and date - timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - - # Extract domain from URL for the filename - from urllib.parse import urlparse - parsed_url = urlparse(url) - domain = parsed_url.netloc.replace("www.", "") - - # Clean up domain for filename - domain = "".join([c if c.isalnum() else "_" for c in domain]) - safe_filename = f"{timestamp}_{domain}.json" - - logging.info(f"Generated filename: {safe_filename}") - - # Save results to a file in the /workspace/scrape directory - scrape_dir = f"{self.workspace_path}/scrape" - self.sandbox.fs.create_folder(scrape_dir, "755") - - results_file_path = f"{scrape_dir}/{safe_filename}" - json_content = json.dumps(formatted_result, ensure_ascii=False, indent=2) - logging.info(f"Saving content to file: {results_file_path}, size: {len(json_content)} bytes") - - self.sandbox.fs.upload_file( - json_content.encode(), - results_file_path, - ) - - return { - "url": url, - "success": True, - "title": title, - "file_path": results_file_path, - "content_length": len(markdown_content) - } - - except Exception as e: - error_message = str(e) - logging.error(f"Error scraping URL '{url}': {error_message}") - - # Create an error result - return { - "url": url, - "success": False, - "error": error_message - } - -if __name__ == "__main__": - async def test_web_search(): - """Test function for the web search tool""" - # This test function is not compatible with the sandbox version - print("Test function needs to be updated for sandbox version") - - async def test_scrape_webpage(): - """Test function for the webpage scrape tool""" - # This test function is not compatible with the sandbox version - print("Test function needs to be updated for sandbox version") - - async def run_tests(): - """Run all test functions""" - await test_web_search() - await test_scrape_webpage() - - asyncio.run(run_tests()) From 08cd0443b6e0927f26ddf12996f8f59709a94c9d Mon Sep 17 00:00:00 2001 From: SNHuan <2776382362@qq.com> Date: Wed, 23 Jul 2025 17:36:53 +0800 Subject: [PATCH 20/33] Format crawl4ai.py with pre-commit hooks --- .pre-commit-config.yaml | 28 ++++----- app/tool/crawl4ai.py | 130 +++++++++++++++++++++++----------------- 2 files changed, 88 insertions(+), 70 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b6c1ba46..84be37205 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,24 +16,22 @@ repos: rev: v2.0.1 hooks: - id: autoflake - args: [ - --remove-all-unused-imports, - --ignore-init-module-imports, - --expand-star-imports, - --remove-duplicate-keys, - --remove-unused-variables, - --recursive, - --in-place, - --exclude=__init__.py, - ] + args: + [ + --remove-all-unused-imports, + --ignore-init-module-imports, + --expand-star-imports, + --remove-duplicate-keys, + --remove-unused-variables, + --recursive, + --in-place, + --exclude=__init__.py, + ] files: \.py$ - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: - id: isort - args: [ - "--profile", "black", - "--filter-files", - "--lines-after-imports=2", - ] + args: + ["--profile", "black", "--filter-files", "--lines-after-imports=2"] diff --git a/app/tool/crawl4ai.py b/app/tool/crawl4ai.py index 71987492f..ebd30c706 100644 --- a/app/tool/crawl4ai.py +++ b/app/tool/crawl4ai.py @@ -1,4 +1,3 @@ - """ Crawl4AI Web Crawler Tool for OpenManus @@ -7,7 +6,7 @@ """ import asyncio -from typing import Union, List +from typing import List, Union from urllib.parse import urlparse from app.logger import logger @@ -39,28 +38,28 @@ class Crawl4aiTool(BaseTool): "type": "array", "items": {"type": "string"}, "description": "(required) List of URLs to crawl. Can be a single URL or multiple URLs.", - "minItems": 1 + "minItems": 1, }, "timeout": { "type": "integer", "description": "(optional) Timeout in seconds for each URL. Default is 30.", "default": 30, "minimum": 5, - "maximum": 120 + "maximum": 120, }, "bypass_cache": { "type": "boolean", "description": "(optional) Whether to bypass cache and fetch fresh content. Default is false.", - "default": False + "default": False, }, "word_count_threshold": { "type": "integer", "description": "(optional) Minimum word count for content blocks. Default is 10.", "default": 10, - "minimum": 1 - } + "minimum": 1, + }, }, - "required": ["urls"] + "required": ["urls"], } async def execute( @@ -101,7 +100,12 @@ async def execute( try: # Import crawl4ai components - from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode + from crawl4ai import ( + AsyncWebCrawler, + BrowserConfig, + CacheMode, + CrawlerRunConfig, + ) # Configure browser settings browser_config = BrowserConfig( @@ -109,7 +113,7 @@ async def execute( verbose=False, browser_type="chromium", ignore_https_errors=True, - java_script_enabled=True + java_script_enabled=True, ) # Configure crawler settings @@ -118,10 +122,10 @@ async def execute( word_count_threshold=word_count_threshold, process_iframes=True, remove_overlay_elements=True, - excluded_tags=['script', 'style'], + excluded_tags=["script", "style"], page_timeout=timeout * 1000, # Convert to milliseconds verbose=False, - wait_until="domcontentloaded" + wait_until="domcontentloaded", ) results = [] @@ -143,54 +147,64 @@ async def execute( if result.success: # Count words in markdown word_count = 0 - if hasattr(result, 'markdown') and result.markdown: + if hasattr(result, "markdown") and result.markdown: word_count = len(result.markdown.split()) # Count links links_count = 0 - if hasattr(result, 'links') and result.links: - internal_links = result.links.get('internal', []) - external_links = result.links.get('external', []) + if hasattr(result, "links") and result.links: + internal_links = result.links.get("internal", []) + external_links = result.links.get("external", []) links_count = len(internal_links) + len(external_links) # Count images images_count = 0 - if hasattr(result, 'media') and result.media: - images = result.media.get('images', []) + if hasattr(result, "media") and result.media: + images = result.media.get("images", []) images_count = len(images) - results.append({ - 'url': url, - 'success': True, - 'status_code': getattr(result, 'status_code', 200), - 'title': result.metadata.get('title') if result.metadata else None, - 'markdown': result.markdown if hasattr(result, 'markdown') else None, - 'word_count': word_count, - 'links_count': links_count, - 'images_count': images_count, - 'execution_time': execution_time - }) + results.append( + { + "url": url, + "success": True, + "status_code": getattr(result, "status_code", 200), + "title": result.metadata.get("title") + if result.metadata + else None, + "markdown": result.markdown + if hasattr(result, "markdown") + else None, + "word_count": word_count, + "links_count": links_count, + "images_count": images_count, + "execution_time": execution_time, + } + ) successful_count += 1 - logger.info(f"✅ Successfully crawled {url} in {execution_time:.2f}s") + logger.info( + f"✅ Successfully crawled {url} in {execution_time:.2f}s" + ) else: - results.append({ - 'url': url, - 'success': False, - 'error_message': getattr(result, 'error_message', 'Unknown error'), - 'execution_time': execution_time - }) + results.append( + { + "url": url, + "success": False, + "error_message": getattr( + result, "error_message", "Unknown error" + ), + "execution_time": execution_time, + } + ) failed_count += 1 logger.warning(f"❌ Failed to crawl {url}") except Exception as e: error_msg = f"Error crawling {url}: {str(e)}" logger.error(error_msg) - results.append({ - 'url': url, - 'success': False, - 'error_message': error_msg - }) + results.append( + {"url": url, "success": False, "error_message": error_msg} + ) failed_count += 1 # Format output @@ -203,25 +217,31 @@ async def execute( for i, result in enumerate(results, 1): output_lines.append(f"{i}. {result['url']}") - if result['success']: - output_lines.append(f" ✅ Status: Success (HTTP {result.get('status_code', 'N/A')})") - if result.get('title'): + if result["success"]: + output_lines.append( + f" ✅ Status: Success (HTTP {result.get('status_code', 'N/A')})" + ) + if result.get("title"): output_lines.append(f" 📄 Title: {result['title']}") - if result.get('markdown'): + if result.get("markdown"): # Show first 300 characters of markdown content - content_preview = result['markdown'] - if len(result['markdown']) > 300: + content_preview = result["markdown"] + if len(result["markdown"]) > 300: content_preview += "..." output_lines.append(f" 📝 Content: {content_preview}") - output_lines.append(f" 📊 Stats: {result.get('word_count', 0)} words, {result.get('links_count', 0)} links, {result.get('images_count', 0)} images") + output_lines.append( + f" 📊 Stats: {result.get('word_count', 0)} words, {result.get('links_count', 0)} links, {result.get('images_count', 0)} images" + ) - if result.get('execution_time'): - output_lines.append(f" ⏱️ Time: {result['execution_time']:.2f}s") + if result.get("execution_time"): + output_lines.append( + f" ⏱️ Time: {result['execution_time']:.2f}s" + ) else: output_lines.append(f" ❌ Status: Failed") - if result.get('error_message'): + if result.get("error_message"): output_lines.append(f" 🚫 Error: {result['error_message']}") output_lines.append("") @@ -241,9 +261,9 @@ def _is_valid_url(self, url: str) -> bool: """Validate if a URL is properly formatted.""" try: result = urlparse(url) - return all([result.scheme, result.netloc]) and result.scheme in ['http', 'https'] + return all([result.scheme, result.netloc]) and result.scheme in [ + "http", + "https", + ] except Exception: return False - - - From f99dfac5bb37e145a795f099cf711cb98e4f7b94 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Sun, 27 Jul 2025 18:36:27 +0800 Subject: [PATCH 21/33] add README and delete sandbox_id --- app/agent/sandbox_agent.py | 110 +++++++++-------- app/config.py | 23 +++- app/daytona/README.md | 69 +++++++---- app/daytona/requirements.txt | 3 - app/daytona/sandbox.py | 61 ++++++---- app/daytona/tool_base.py | 122 +++++++++++-------- app/tool/sb_browser_tool.py | 169 ++++++++++++++++++--------- config/config.example-daytona.toml | 13 +-- tests/daytona/test_daytona.py | 55 ++++----- tests/daytona/test_sb_browser_use.py | 32 +++-- 10 files changed, 403 insertions(+), 254 deletions(-) delete mode 100644 app/daytona/requirements.txt diff --git a/app/agent/sandbox_agent.py b/app/agent/sandbox_agent.py index cc4072fc2..7480f8797 100644 --- a/app/agent/sandbox_agent.py +++ b/app/agent/sandbox_agent.py @@ -1,11 +1,14 @@ import json -from typing import Dict, List, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, List, Optional +import daytona from pydantic import Field, model_validator from app.agent.browser import BrowserContextHelper from app.agent.toolcall import ToolCallAgent from app.config import config +from app.daytona.sandbox import create_sandbox, delete_sandbox, get_or_start_sandbox +from app.daytona.tool_base import SandboxToolsBase from app.logger import logger from app.prompt.manus import NEXT_STEP_PROMPT, SYSTEM_PROMPT from app.schema import Message @@ -14,20 +17,20 @@ from app.tool.browser_use_tool import BrowserUseTool from app.tool.mcp import MCPClients, MCPClientTool from app.tool.python_execute import PythonExecute +from app.tool.sb_browser_tool import SandboxBrowserTool from app.tool.sb_files_tool import SandboxFilesTool from app.tool.sb_shell_tool import SandboxShellTool from app.tool.sb_vision_tool import SandboxVisionTool from app.tool.str_replace_editor import StrReplaceEditor -from app.tool.sb_browser_tool import SandboxBrowserTool -from app.daytona.sandbox import create_sandbox,get_or_start_sandbox -import daytona class SandboxManus(ToolCallAgent): """A versatile general-purpose agent with support for both local and MCP tools.""" name: str = "SandboxManus" - description: str = "A versatile agent that can solve various tasks using multiple sandbox-tools including MCP-based tools" + description: str = ( + "A versatile agent that can solve various tasks using multiple sandbox-tools including MCP-based tools" + ) system_prompt: str = SYSTEM_PROMPT.format(directory=config.workspace_root) next_step_prompt: str = NEXT_STEP_PROMPT @@ -74,66 +77,41 @@ async def create(cls, **kwargs) -> "SandboxManus": instance._initialized = True return instance - async def initialize_sandbox_tools(self, sandbox_id: str = config.daytona.sandbox_id, password:str = config.daytona.VNC_password) -> None: + async def initialize_sandbox_tools( + self, + password: str = config.daytona.VNC_password, + ) -> None: try: - if sandbox_id: - sandbox = await get_or_start_sandbox(sandbox_id) - - # Initialize sandbox_link if not exists - if not self.sandbox_link: - self.sandbox_link = {} - - # Check if URLs are already cached - if sandbox_id in self.sandbox_link: - vnc_url = self.sandbox_link[sandbox_id]["vnc"] - website_url = self.sandbox_link[sandbox_id]["website"] - logger.info(f"Using cached URLs - VNC: {vnc_url}, Website: {website_url}") - else: - # Get URLs from sandbox and cache them - vnc_link = sandbox.get_preview_link(6080) - website_link = sandbox.get_preview_link(8080) - vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link) - website_url = website_link.url if hasattr(website_link, 'url') else str(website_link) - - # Cache the URLs - self.sandbox_link[sandbox_id] = { - "vnc": str(vnc_url), - "website": str(website_url) - } - logger.info(f"Cached new URLs - VNC: {vnc_url}, Website: {website_url}") - - logger.info(f"VNC URL: {vnc_url}") - logger.info(f"Website URL: {website_url}") + # 创建新沙箱 + if password: + sandbox = create_sandbox(password=password) + self.sandbox = sandbox else: - # 创建新沙箱 - if password: - sandbox = create_sandbox(password=password) - else: - raise ValueError("Sandbox ID or password must be provided") - vnc_link = sandbox.get_preview_link(6080) - website_link = sandbox.get_preview_link(8080) - vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link) - website_url = website_link.url if hasattr(website_link, 'url') else str(website_link) - - # Get the actual sandbox_id from the created sandbox - actual_sandbox_id = sandbox.id if hasattr(sandbox, 'id') else 'new_sandbox' - if not self.sandbox_link: - self.sandbox_link = {} - self.sandbox_link[actual_sandbox_id] = { - "vnc": vnc_url, - "website": website_url, - } - logger.info(f"VNC URL: {vnc_url}") - logger.info(f"Website URL: {website_url}") + raise ValueError("password must be provided") + vnc_link = sandbox.get_preview_link(6080) + website_link = sandbox.get_preview_link(8080) + vnc_url = vnc_link.url if hasattr(vnc_link, "url") else str(vnc_link) + website_url = ( + website_link.url if hasattr(website_link, "url") else str(website_link) + ) + # Get the actual sandbox_id from the created sandbox + actual_sandbox_id = sandbox.id if hasattr(sandbox, "id") else "new_sandbox" + if not self.sandbox_link: + self.sandbox_link = {} + self.sandbox_link[actual_sandbox_id] = { + "vnc": vnc_url, + "website": website_url, + } + logger.info(f"VNC URL: {vnc_url}") + logger.info(f"Website URL: {website_url}") + SandboxToolsBase._urls_printed = True sb_tools = [ SandboxBrowserTool(sandbox), SandboxFilesTool(sandbox), SandboxShellTool(sandbox), - SandboxVisionTool(sandbox) + SandboxVisionTool(sandbox), ] - # for sb_tool in sb_tools: - # self.available_tools.add_tool(sb_tool) self.available_tools.add_tools(*sb_tools) except Exception as e: @@ -204,6 +182,17 @@ async def disconnect_mcp_server(self, server_id: str = "") -> None: self.available_tools = ToolCollection(*base_tools) self.available_tools.add_tools(*self.mcp_clients.tools) + async def delete_sandbox(self, sandbox_id: str) -> None: + """Delete a sandbox by ID.""" + try: + await delete_sandbox(sandbox_id) + logger.info(f"Sandbox {sandbox_id} deleted successfully") + if sandbox_id in self.sandbox_link: + del self.sandbox_link[sandbox_id] + except Exception as e: + logger.error(f"Error deleting sandbox {sandbox_id}: {e}") + raise e + async def cleanup(self): """Clean up Manus agent resources.""" if self.browser_context_helper: @@ -211,6 +200,7 @@ async def cleanup(self): # Disconnect from all MCP servers only if we were initialized if self._initialized: await self.disconnect_mcp_server() + await self.delete_sandbox(self.sandbox.id if self.sandbox else "unknown") self._initialized = False async def think(self) -> bool: @@ -239,3 +229,9 @@ async def think(self) -> bool: self.next_step_prompt = original_prompt return result + return result + return result + return result + return result + return result + return result diff --git a/app/config.py b/app/config.py index 3a360bef2..a881e2a5e 100644 --- a/app/config.py +++ b/app/config.py @@ -107,12 +107,22 @@ class SandboxSettings(BaseModel): class DaytonaSettings(BaseModel): daytona_api_key: str - daytona_server_url: Optional[str] = Field("https://app.daytona.io/api", description="") - daytona_target: Optional[str] = Field("us", description="enum ['asia', 'eu', 'us']") - sandbox_image_name: Optional[str]= Field("kortix/suna:0.1.3", description="") - sandbox_entrypoint: Optional[str]= Field("/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf", description="") - sandbox_id: Optional[str] = Field(None, description="ID of the daytona sandbox to use, if any") - VNC_password: Optional[str] = Field("123456", description="VNC password for the vnc service in sandbox") + daytona_server_url: Optional[str] = Field( + "https://app.daytona.io/api", description="" + ) + daytona_target: Optional[str] = Field("us", description="enum ['eu', 'us']") + sandbox_image_name: Optional[str] = Field("whitezxj/sandbox:0.1.0", description="") + sandbox_entrypoint: Optional[str] = Field( + "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf", + description="", + ) + # sandbox_id: Optional[str] = Field( + # None, description="ID of the daytona sandbox to use, if any" + # ) + VNC_password: Optional[str] = Field( + "123456", description="VNC password for the vnc service in sandbox" + ) + class MCPServerConfig(BaseModel): """Configuration for a single MCP server""" @@ -325,6 +335,7 @@ def llm(self) -> Dict[str, LLMSettings]: @property def sandbox(self) -> SandboxSettings: return self._config.sandbox + @property def daytona(self) -> DaytonaSettings: return self._config.daytona_config diff --git a/app/daytona/README.md b/app/daytona/README.md index 93d1eb713..9a707b9d7 100644 --- a/app/daytona/README.md +++ b/app/daytona/README.md @@ -1,39 +1,58 @@ -# Agent Sandbox +# Agent with Daytona sandbox -This directory contains the agent sandbox implementation - a Docker-based virtual environment that agents use as their own computer to execute tasks, access the web, and manipulate files. -## Overview -The sandbox provides a complete containerized Linux environment with: -- Chrome browser for web interactions -- VNC server for accessing the Web User -- Full file system access -- Full sudo access -## Customizing the Sandbox +## Prerequisites +- conda activate 'Your OpenManus python env' +- pip install daytona==0.21.8 structlog==25.4.0 -You can modify the sandbox environment for development or to add new capabilities: -1. Edit files in the `docker/` directory -2. Build a custom image: + +## Setup & Running + +1. daytona config : + ```bash + cd OpenManus + cp config/config.example-daytona.toml config/config.toml ``` - cd daytona/docker - docker build +2. get daytona apikey : + goto https://app.daytona.io/dashboard/keys and create your apikey + +3. set your apikey in config.toml + ```toml + # daytona config + [daytona] + daytona_api_key = "" + #daytona_server_url = "https://app.daytona.io/api" + #daytona_target = "us" #Daytona is currently available in the following regions:United States (us)、Europe (eu) + #sandbox_image_name = "whitezxj/sandbox:0.1.0" #If you don't use this default image,sandbox tools may be useless + #sandbox_entrypoint = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf" #If you change this entrypoint,server in sandbox may be useless + #VNC_password = #The password you set to log in sandbox by VNC,it will be 123456 if you don't set + ``` +2. Run : + + ```bash + cd OpenManus + python sandbox_main.py ``` -3. Test your changes locally using docker-compose -## Using a Custom Image +3. Send tasks to Agent + You can sent tasks to Agent by terminate,agent will use sandbox tools to handle your tasks. + +4. See results + If agent use sb_browser_use tool, you can see the operations by VNC link, The VNC link will print in the termination,e.g.:https://6080-sandbox-123456.h7890.daytona.work. + If agent use sb_shell tool, you can see the results by terminate of sandbox in https://app.daytona.io/dashboard/sandboxes. + Agent can use sb_files tool to operate files to sandbox. + + +## Example -To use your custom sandbox image: + You can send task e.g.:"帮我在https://hk.trip.com/travel-guide/guidebook/nanjing-9696/?ishideheader=true&isHideNavBar=YES&disableFontScaling=1&catalogId=514634&locale=zh-HK查询相关信息上制定一份南京旅游攻略,并在工作区保存为index.html" -1. Change the `image` parameter in `docker-compose.yml` (that defines the image name `kortix/suna:___`) -2. Update the same image name in `backend/sandbox/sandbox.py` in the `create_sandbox` function -3. If using Daytona for deployment, update the image reference there as well + Then you can see the agent's browser action in VNC link(https://6080-sandbox-123456.h7890.proxy.daytona.work) and you can see the html made by agent in Website URL(https://8080-sandbox-123456.h7890.proxy.daytona.work). -## Publishing New Versions +## Learn More -When publishing a new version of the sandbox: +- [Daytona Documentation](https://www.daytona.io/docs/) -4. Update all references to the image version in: - - Daytona images - - Any other services using this image \ No newline at end of file diff --git a/app/daytona/requirements.txt b/app/daytona/requirements.txt deleted file mode 100644 index b2a148607..000000000 --- a/app/daytona/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -structlog -daytona -litellm diff --git a/app/daytona/sandbox.py b/app/daytona/sandbox.py index 51372940a..a801a7fb6 100644 --- a/app/daytona/sandbox.py +++ b/app/daytona/sandbox.py @@ -1,36 +1,44 @@ -from daytona import Daytona, DaytonaConfig, CreateSandboxFromImageParams, Sandbox, SessionExecuteRequest, Resources, SandboxState +from daytona import ( + CreateSandboxFromImageParams, + Daytona, + DaytonaConfig, + Resources, + Sandbox, + SandboxState, + SessionExecuteRequest, +) from dotenv import load_dotenv -from app.utils.logger import logger -# from app.utils.config import config -# from utils.config import config -# from app.utils.config import Configuration + from app.config import config +from app.utils.logger import logger + # load_dotenv() -daytona_settings=config.daytona -logger.debug("Initializing Daytona sandbox configuration") +daytona_settings = config.daytona +logger.info("Initializing Daytona sandbox configuration") daytona_config = DaytonaConfig( api_key=daytona_settings.daytona_api_key, server_url=daytona_settings.daytona_server_url, - target=daytona_settings.daytona_target + target=daytona_settings.daytona_target, ) if daytona_config.api_key: - logger.debug("Daytona API key configured successfully") + logger.info("Daytona API key configured successfully") else: logger.warning("No Daytona API key found in environment variables") if daytona_config.server_url: - logger.debug(f"Daytona server URL set to: {daytona_config.server_url}") + logger.info(f"Daytona server URL set to: {daytona_config.server_url}") else: logger.warning("No Daytona server URL found in environment variables") if daytona_config.target: - logger.debug(f"Daytona target set to: {daytona_config.target}") + logger.info(f"Daytona target set to: {daytona_config.target}") else: logger.warning("No Daytona target found in environment variables") daytona = Daytona(daytona_config) -logger.debug("Daytona client initialized") +logger.info("Daytona client initialized") + async def get_or_start_sandbox(sandbox_id: str): """Retrieve a sandbox by ID, check its state, and start it if needed.""" @@ -41,7 +49,10 @@ async def get_or_start_sandbox(sandbox_id: str): sandbox = daytona.get(sandbox_id) # Check if sandbox needs to be started - if sandbox.state == SandboxState.ARCHIVED or sandbox.state == SandboxState.STOPPED: + if ( + sandbox.state == SandboxState.ARCHIVED + or sandbox.state == SandboxState.STOPPED + ): logger.info(f"Sandbox is in {sandbox.state} state. Starting...") try: daytona.start(sandbox) @@ -63,6 +74,7 @@ async def get_or_start_sandbox(sandbox_id: str): logger.error(f"Error retrieving or starting sandbox: {str(e)}") raise e + def start_supervisord_session(sandbox: Sandbox): """Start supervisord in a session.""" session_id = "supervisord-session" @@ -71,25 +83,29 @@ def start_supervisord_session(sandbox: Sandbox): sandbox.process.create_session(session_id) # Execute supervisord command - sandbox.process.execute_session_command(session_id, SessionExecuteRequest( - command="exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf", - var_async=True - )) + sandbox.process.execute_session_command( + session_id, + SessionExecuteRequest( + command="exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf", + var_async=True, + ), + ) logger.info(f"Supervisord started in session {session_id}") except Exception as e: logger.error(f"Error starting supervisord session: {str(e)}") raise e + def create_sandbox(password: str, project_id: str = None): """Create a new sandbox with all required services configured and running.""" - logger.debug("Creating new Daytona sandbox environment") - logger.debug("Configuring sandbox with browser-use image and environment variables") + logger.info("Creating new Daytona sandbox environment") + logger.info("Configuring sandbox with browser-use image and environment variables") labels = None if project_id: - logger.debug(f"Using sandbox_id as label: {project_id}") - labels = {'id': project_id} + logger.info(f"Using sandbox_id as label: {project_id}") + labels = {"id": project_id} params = CreateSandboxFromImageParams( image=daytona_settings.sandbox_image_name, @@ -106,7 +122,7 @@ def create_sandbox(password: str, project_id: str = None): "CHROME_USER_DATA": "", "CHROME_DEBUGGING_PORT": "9222", "CHROME_DEBUGGING_HOST": "localhost", - "CHROME_CDP": "" + "CHROME_CDP": "", }, resources=Resources( cpu=2, @@ -127,6 +143,7 @@ def create_sandbox(password: str, project_id: str = None): logger.debug(f"Sandbox environment successfully initialized") return sandbox + async def delete_sandbox(sandbox_id: str): """Delete a sandbox by its ID.""" logger.info(f"Deleting sandbox with ID: {sandbox_id}") diff --git a/app/daytona/tool_base.py b/app/daytona/tool_base.py index f0612ae5f..6a69b067c 100644 --- a/app/daytona/tool_base.py +++ b/app/daytona/tool_base.py @@ -1,25 +1,46 @@ import asyncio - -from dataclasses import field, dataclass +from dataclasses import dataclass, field from datetime import datetime -from typing import Optional, ClassVar, Dict, Any +from typing import Any, ClassVar, Dict, Optional + +from daytona import Daytona, DaytonaConfig, Sandbox, SandboxState from pydantic import Field + +from app.config import config +from app.daytona.sandbox import ( + create_sandbox, + get_or_start_sandbox, + start_supervisord_session, +) + # from app.agentpress.thread_manager import ThreadManager from app.tool.base import BaseTool, ToolResult -from daytona import Sandbox -from app.utils.logger import logger from app.utils.files_utils import clean_path +from app.utils.logger import logger + +# load_dotenv() +daytona_settings = config.daytona +daytona_config = DaytonaConfig( + api_key=daytona_settings.daytona_api_key, + server_url=daytona_settings.daytona_server_url, + target=daytona_settings.daytona_target, +) +daytona = Daytona(daytona_config) + @dataclass class ThreadMessage: """ Represents a message to be added to a thread. """ + type: str content: Dict[str, Any] is_llm_message: bool = False metadata: Optional[Dict[str, Any]] = None - timestamp: Optional[float] = field(default_factory=lambda: datetime.now().timestamp()) + timestamp: Optional[float] = field( + default_factory=lambda: datetime.now().timestamp() + ) def to_dict(self) -> Dict[str, Any]: """Convert the message to a dictionary for API calls""" @@ -28,9 +49,10 @@ def to_dict(self) -> Dict[str, Any]: "content": self.content, "is_llm_message": self.is_llm_message, "metadata": self.metadata or {}, - "timestamp": self.timestamp + "timestamp": self.timestamp, } + class SandboxToolsBase(BaseTool): """Base class for all sandbox tools that provides project-based sandbox access.""" @@ -48,53 +70,55 @@ class SandboxToolsBase(BaseTool): workspace_path: str = Field(default="/workspace", exclude=True) _sessions: dict[str, str] = {} - class Config: arbitrary_types_allowed = True # Allow non-pydantic types like ThreadManager underscore_attrs_are_private = True + async def _ensure_sandbox(self) -> Sandbox: """Ensure we have a valid sandbox instance, retrieving it from the project if needed.""" if self._sandbox is None: - # try: - # # Get database client - # client = await self.thread_manager.db.client - # - # # Get project data - # project = await client.table('projects').select('*').eq('project_id', self.project_id).execute() - # if not project.data or len(project.data) == 0: - # raise ValueError(f"Project {self.project_id} not found") - # - # project_data = project.data[0] - # sandbox_info = project_data.get('sandbox', {}) - # - # if not sandbox_info.get('id'): - # raise ValueError(f"No sandbox found for project {self.project_id}") - # - # # Store sandbox info - # self._sandbox_id = sandbox_info['id'] - # self._sandbox_pass = sandbox_info.get('pass') - # - # # Get or start the sandbox - # self._sandbox = await get_or_start_sandbox(self._sandbox_id) - # - # # # Log URLs if not already printed - # # if not SandboxToolsBase._urls_printed: - # # vnc_link = self._sandbox.get_preview_link(6080) - # # website_link = self._sandbox.get_preview_link(8080) - # - # # vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link) - # # website_url = website_link.url if hasattr(website_link, 'url') else str(website_link) - # - # # print("\033[95m***") - # # print(f"VNC URL: {vnc_url}") - # # print(f"Website URL: {website_url}") - # # print("***\033[0m") - # # SandboxToolsBase._urls_printed = True - # - # except Exception as e: - logger.error(f"Error retrieving sandbox for project {self.project_id}: {str(e)}", exc_info=True) - raise Exception + # Get or start the sandbox + try: + self._sandbox = create_sandbox(password=config.daytona.VNC_password) + # Log URLs if not already printed + if not SandboxToolsBase._urls_printed: + vnc_link = self._sandbox.get_preview_link(6080) + website_link = self._sandbox.get_preview_link(8080) + + vnc_url = ( + vnc_link.url if hasattr(vnc_link, "url") else str(vnc_link) + ) + website_url = ( + website_link.url + if hasattr(website_link, "url") + else str(website_link) + ) + + print("\033[95m***") + print(f"VNC URL: {vnc_url}") + print(f"Website URL: {website_url}") + print("***\033[0m") + SandboxToolsBase._urls_printed = True + except Exception as e: + logger.error(f"Error retrieving or starting sandbox: {str(e)}") + raise e + else: + if ( + self._sandbox.state == SandboxState.ARCHIVED + or self._sandbox.state == SandboxState.STOPPED + ): + logger.info(f"Sandbox is in {self._sandbox.state} state. Starting...") + try: + daytona.start(self._sandbox) + # Wait a moment for the sandbox to initialize + # sleep(5) + # Refresh sandbox state after starting + # Start supervisord in a session when restarting + start_supervisord_session(self._sandbox) + except Exception as e: + logger.error(f"Error starting sandbox: {e}") + raise e return self._sandbox @property @@ -108,7 +132,9 @@ def sandbox(self) -> Sandbox: def sandbox_id(self) -> str: """Get the sandbox ID, ensuring it exists.""" if self._sandbox_id is None: - raise RuntimeError("Sandbox ID not initialized. Call _ensure_sandbox() first.") + raise RuntimeError( + "Sandbox ID not initialized. Call _ensure_sandbox() first." + ) return self._sandbox_id def clean_path(self, path: str) -> str: diff --git a/app/tool/sb_browser_tool.py b/app/tool/sb_browser_tool.py index bbb22907e..01cffa308 100644 --- a/app/tool/sb_browser_tool.py +++ b/app/tool/sb_browser_tool.py @@ -1,18 +1,20 @@ -import traceback -import json import base64 import io -from PIL import Image - +import json +import traceback from typing import Optional # Add this import for Optional + +from PIL import Image from pydantic import Field -from app.daytona.tool_base import Sandbox, ThreadMessage # Ensure Sandbox is imported correctly + +from app.daytona.tool_base import ( # Ensure Sandbox is imported correctly + Sandbox, + SandboxToolsBase, + ThreadMessage, +) from app.tool.base import ToolResult -from app.daytona.tool_base import SandboxToolsBase from app.utils.logger import logger - - # Context = TypeVar("Context") _BROWSER_DESCRIPTION = """\ A sandbox-based browser automation tool that allows interaction with web pages through various actions. @@ -55,7 +57,7 @@ class SandboxBrowserTool(SandboxToolsBase): "get_dropdown_options", "select_dropdown_option", "click_coordinates", - "drag_drop" + "drag_drop", ], "description": "The browser action to perform", }, @@ -102,7 +104,7 @@ class SandboxBrowserTool(SandboxToolsBase): "element_target": { "type": "string", "description": "Target element for drag and drop", - } + }, }, "required": ["action"], "dependencies": { @@ -124,13 +126,17 @@ class SandboxBrowserTool(SandboxToolsBase): } browser_message: Optional[ThreadMessage] = Field(default=None, exclude=True) - def __init__(self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data): + def __init__( + self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data + ): """Initialize with optional sandbox and thread_id.""" super().__init__(**data) if sandbox is not None: self._sandbox = sandbox # Directly set the base class private attribute - def _validate_base64_image(self, base64_string: str, max_size_mb: int = 10) -> tuple[bool, str]: + def _validate_base64_image( + self, base64_string: str, max_size_mb: int = 10 + ) -> tuple[bool, str]: """ Validate base64 image data. Args: @@ -142,13 +148,14 @@ def _validate_base64_image(self, base64_string: str, max_size_mb: int = 10) -> t try: if not base64_string or len(base64_string) < 10: return False, "Base64 string is empty or too short" - if base64_string.startswith('data:'): + if base64_string.startswith("data:"): try: - base64_string = base64_string.split(',', 1)[1] + base64_string = base64_string.split(",", 1)[1] except (IndexError, ValueError): return False, "Invalid data URL format" import re - if not re.match(r'^[A-Za-z0-9+/]*={0,2}$', base64_string): + + if not re.match(r"^[A-Za-z0-9+/]*={0,2}$", base64_string): return False, "Invalid base64 characters detected" if len(base64_string) % 4 != 0: return False, "Invalid base64 string length" @@ -163,7 +170,7 @@ def _validate_base64_image(self, base64_string: str, max_size_mb: int = 10) -> t image_stream = io.BytesIO(image_data) with Image.open(image_stream) as img: img.verify() - supported_formats = {'JPEG', 'PNG', 'GIF', 'BMP', 'WEBP', 'TIFF'} + supported_formats = {"JPEG", "PNG", "GIF", "BMP", "WEBP", "TIFF"} if img.format not in supported_formats: return False, f"Unsupported image format: {img.format}" image_stream.seek(0) @@ -171,7 +178,10 @@ def _validate_base64_image(self, base64_string: str, max_size_mb: int = 10) -> t width, height = img_check.size max_dimension = 8192 if width > max_dimension or height > max_dimension: - return False, f"Image dimensions exceed limit ({max_dimension}x{max_dimension})" + return ( + False, + f"Image dimensions exceed limit ({max_dimension}x{max_dimension})", + ) if width < 1 or height < 1: return False, f"Invalid image dimensions: {width}x{height}" except Exception as e: @@ -180,16 +190,24 @@ def _validate_base64_image(self, base64_string: str, max_size_mb: int = 10) -> t except Exception as e: logger.error(f"Unexpected error during base64 image validation: {e}") return False, f"Validation error: {str(e)}" - async def _execute_browser_action(self, endpoint: str, params: dict = None, method: str = "POST") -> ToolResult: + + async def _execute_browser_action( + self, endpoint: str, params: dict = None, method: str = "POST" + ) -> ToolResult: """Execute a browser automation action through the sandbox API.""" try: + await self._ensure_sandbox() url = f"http://localhost:8003/api/automation/{endpoint}" if method == "GET" and params: query_params = "&".join([f"{k}={v}" for k, v in params.items()]) url = f"{url}?{query_params}" - curl_cmd = f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'" + curl_cmd = ( + f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'" + ) else: - curl_cmd = f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'" + curl_cmd = ( + f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'" + ) if params: json_data = json.dumps(params) curl_cmd += f" -d '{json_data}'" @@ -202,9 +220,13 @@ async def _execute_browser_action(self, endpoint: str, params: dict = None, meth result.setdefault("role", "assistant") if "screenshot_base64" in result: screenshot_data = result["screenshot_base64"] - is_valid, validation_message = self._validate_base64_image(screenshot_data) + is_valid, validation_message = self._validate_base64_image( + screenshot_data + ) if not is_valid: - logger.warning(f"Screenshot validation failed: {validation_message}") + logger.warning( + f"Screenshot validation failed: {validation_message}" + ) result["image_validation_error"] = validation_message del result["screenshot_base64"] @@ -215,33 +237,43 @@ async def _execute_browser_action(self, endpoint: str, params: dict = None, meth # is_llm_message=False # ) message = ThreadMessage( - type="browser_state", - content=result, - is_llm_message=False + type="browser_state", content=result, is_llm_message=False ) self.browser_message = message success_response = { "success": result.get("success", False), - "message": result.get("message", "Browser action completed") + "message": result.get("message", "Browser action completed"), } - # if added_message and 'message_id' in added_message: - # success_response['message_id'] = added_message['message_id'] - for field in ["url", "title", "element_count", "pixels_below", "ocr_text", "image_url"]: + # if added_message and 'message_id' in added_message: + # success_response['message_id'] = added_message['message_id'] + for field in [ + "url", + "title", + "element_count", + "pixels_below", + "ocr_text", + "image_url", + ]: if field in result: success_response[field] = result[field] - return (result,self.success_response(success_response)) if success_response["success"] else self.fail_response(success_response) + return ( + (result, self.success_response(success_response)) + if success_response["success"] + else self.fail_response(success_response) + ) except json.JSONDecodeError as e: logger.error(f"Failed to parse response JSON: {e}") return self.fail_response(f"Failed to parse response JSON: {e}") else: logger.error(f"Browser automation request failed: {response}") - return self.fail_response(f"Browser automation request failed: {response}") + return self.fail_response( + f"Browser automation request failed: {response}" + ) except Exception as e: logger.error(f"Error executing browser action: {e}") logger.debug(traceback.format_exc()) return self.fail_response(f"Error executing browser action: {e}") - async def execute( self, action: str, @@ -256,7 +288,7 @@ async def execute( y: Optional[int] = None, element_source: Optional[str] = None, element_target: Optional[str] = None, - **kwargs + **kwargs, ) -> ToolResult: """ Execute a browser action in the sandbox environment. @@ -278,7 +310,7 @@ async def execute( """ # async with self.lock: try: - # Navigation actions + # Navigation actions if action == "navigate_to": if not url: return self.fail_response("URL is required for navigation") @@ -289,11 +321,17 @@ async def execute( elif action == "click_element": if index is None: return self.fail_response("Index is required for click_element") - return await self._execute_browser_action("click_element", {"index": index}) + return await self._execute_browser_action( + "click_element", {"index": index} + ) elif action == "input_text": if index is None or not text: - return self.fail_response("Index and text are required for input_text") - return await self._execute_browser_action("input_text", {"index": index, "text": text}) + return self.fail_response( + "Index and text are required for input_text" + ) + return await self._execute_browser_action( + "input_text", {"index": index, "text": text} + ) elif action == "send_keys": if not keys: return self.fail_response("Keys are required for send_keys") @@ -302,11 +340,15 @@ async def execute( elif action == "switch_tab": if page_id is None: return self.fail_response("Page ID is required for switch_tab") - return await self._execute_browser_action("switch_tab", {"page_id": page_id}) + return await self._execute_browser_action( + "switch_tab", {"page_id": page_id} + ) elif action == "close_tab": if page_id is None: return self.fail_response("Page ID is required for close_tab") - return await self._execute_browser_action("close_tab", {"page_id": page_id}) + return await self._execute_browser_action( + "close_tab", {"page_id": page_id} + ) # Scrolling actions elif action == "scroll_down": params = {"amount": amount} if amount is not None else {} @@ -317,32 +359,53 @@ async def execute( elif action == "scroll_to_text": if not text: return self.fail_response("Text is required for scroll_to_text") - return await self._execute_browser_action("scroll_to_text", {"text": text}) + return await self._execute_browser_action( + "scroll_to_text", {"text": text} + ) # Dropdown actions elif action == "get_dropdown_options": if index is None: - return self.fail_response("Index is required for get_dropdown_options") - return await self._execute_browser_action("get_dropdown_options", {"index": index}) + return self.fail_response( + "Index is required for get_dropdown_options" + ) + return await self._execute_browser_action( + "get_dropdown_options", {"index": index} + ) elif action == "select_dropdown_option": if index is None or not text: - return self.fail_response("Index and text are required for select_dropdown_option") - return await self._execute_browser_action("select_dropdown_option", {"index": index, "text": text}) + return self.fail_response( + "Index and text are required for select_dropdown_option" + ) + return await self._execute_browser_action( + "select_dropdown_option", {"index": index, "text": text} + ) # Coordinate-based actions elif action == "click_coordinates": if x is None or y is None: - return self.fail_response("X and Y coordinates are required for click_coordinates") - return await self._execute_browser_action("click_coordinates", {"x": x, "y": y}) + return self.fail_response( + "X and Y coordinates are required for click_coordinates" + ) + return await self._execute_browser_action( + "click_coordinates", {"x": x, "y": y} + ) elif action == "drag_drop": if not element_source or not element_target: - return self.fail_response("Source and target elements are required for drag_drop") - return await self._execute_browser_action("drag_drop", { - "element_source": element_source, - "element_target": element_target - }) + return self.fail_response( + "Source and target elements are required for drag_drop" + ) + return await self._execute_browser_action( + "drag_drop", + { + "element_source": element_source, + "element_target": element_target, + }, + ) # Utility actions elif action == "wait": seconds_to_wait = seconds if seconds is not None else 3 - return await self._execute_browser_action("wait", {"seconds": seconds_to_wait}) + return await self._execute_browser_action( + "wait", {"seconds": seconds_to_wait} + ) else: return self.fail_response(f"Unknown action: {action}") except Exception as e: @@ -365,7 +428,7 @@ async def get_current_state( screenshot = state.get("screenshot_base64") # Build the state info with all required fields state_info = { - "url": state.get("url",""), + "url": state.get("url", ""), "title": state.get("title", ""), "tabs": [tab.model_dump() for tab in state.get("tabs", [])], "pixels_above": getattr(state, "pixels_above", 0), diff --git a/config/config.example-daytona.toml b/config/config.example-daytona.toml index de065eb48..10975df2e 100644 --- a/config/config.example-daytona.toml +++ b/config/config.example-daytona.toml @@ -96,14 +96,13 @@ temperature = 0.0 # Controls randomness for vision mode #network_enabled = true # Daytona configuration -#[daytona] -#daytona_api_key = "" +[daytona] +daytona_api_key = "" #daytona_server_url = "https://app.daytona.io/api" -#daytona_target = "us" -#sandbox_image_name = "kortix/suna:0.1.3" -#sandbox_entrypoint = -#sandbox_id = -#VNC_password = +#daytona_target = "us" #Daytona is currently available in the following regions:United States (us)、Europe (eu) +#sandbox_image_name = "whitezxj/sandbox:0.1.0" #If you don't use this default image,sandbox tools may be useless +#sandbox_entrypoint = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf" #If you change this entrypoint,server in sandbox may be useless +#VNC_password = #The password you set to log in sandbox by VNC,it will be 123456 if you don't set # MCP (Model Context Protocol) configuration [mcp] diff --git a/tests/daytona/test_daytona.py b/tests/daytona/test_daytona.py index 654d67c3b..ddacad45b 100644 --- a/tests/daytona/test_daytona.py +++ b/tests/daytona/test_daytona.py @@ -1,40 +1,43 @@ -from daytona import Daytona, DaytonaConfig,CreateSandboxFromImageParams,Resources, Image +from daytona import ( + CreateSandboxFromImageParams, + Daytona, + DaytonaConfig, + Image, + Resources, +) + # Using environment variables (DAYTONA_API_KEY, DAYTONA_API_URL, DAYTONA_TARGET) # daytona = Daytona() # Using explicit configuration -config = DaytonaConfig( - api_key="dtn_bedf8ed9953f0b5c410c042090e1002a56ba8129b573c92f5607aef04b08c82a", - api_url="https://app.daytona.io/api", - target="us" -) +config = DaytonaConfig(api_key="", api_url="https://app.daytona.io/api", target="us") daytona = Daytona(config) params = CreateSandboxFromImageParams( - image="kortix/suna:0.1.3", + image="", # image=Image.debian_slim("3.12"), public=True, labels=None, env_vars={ - "CHROME_PERSISTENT_SESSION": "true", - "RESOLUTION": "1024x768x24", - "RESOLUTION_WIDTH": "1024", - "RESOLUTION_HEIGHT": "768", - "VNC_PASSWORD": "123456", - "ANONYMIZED_TELEMETRY": "false", - "CHROME_PATH": "", - "CHROME_USER_DATA": "", - "CHROME_DEBUGGING_PORT": "9222", - "CHROME_DEBUGGING_HOST": "localhost", - "CHROME_CDP": "" - }, + "CHROME_PERSISTENT_SESSION": "true", + "RESOLUTION": "1024x768x24", + "RESOLUTION_WIDTH": "1024", + "RESOLUTION_HEIGHT": "768", + "VNC_PASSWORD": "123456", + "ANONYMIZED_TELEMETRY": "false", + "CHROME_PATH": "", + "CHROME_USER_DATA": "", + "CHROME_DEBUGGING_PORT": "9222", + "CHROME_DEBUGGING_HOST": "localhost", + "CHROME_CDP": "", + }, resources=Resources( - cpu=1, - memory=1, - disk=1, - ), + cpu=1, + memory=1, + disk=1, + ), auto_stop_interval=15, auto_archive_interval=24 * 60, - ) - # Create the sandbox +) +# Create the sandbox sandbox = daytona.create(params) print(f"Sandbox created with ID: {sandbox.id}") @@ -58,7 +61,7 @@ # Define the configuration -# config = DaytonaConfig(api_key="dtn_bedf8ed9953f0b5c410c042090e1002a56ba8129b573c92f5607aef04b08c82a") +# config = DaytonaConfig(api_key="") # # Initialize the Daytona client diff --git a/tests/daytona/test_sb_browser_use.py b/tests/daytona/test_sb_browser_use.py index 0969e3769..3b210f71d 100644 --- a/tests/daytona/test_sb_browser_use.py +++ b/tests/daytona/test_sb_browser_use.py @@ -1,14 +1,18 @@ -from app.tool.sb_browser_tool import SandboxBrowserTool -from app.daytona.sandbox import create_sandbox,start_supervisord_session import asyncio -from app.utils.logger import logger -from daytona import DaytonaConfig, Daytona import json + +from daytona import Daytona, DaytonaConfig + +from app.daytona.sandbox import create_sandbox, start_supervisord_session +from app.tool.sb_browser_tool import SandboxBrowserTool +from app.utils.logger import logger + + async def main(): # 创建沙箱和工具 sandbox = create_sandbox(password="123456") # config = DaytonaConfig( - # api_key="dtn_bedf8ed9953f0b5c410c042090e1002a56ba8129b573c92f5607aef04b08c82a", + # api_key="", # api_url="https://app.daytona.io/api", # target="us" # ) @@ -42,7 +46,9 @@ async def main(): tool = SandboxBrowserTool(sandbox) # # 执行截图操作 - result,tooresult = await tool.execute(action="navigate_to",url="https://www.github.com") + result, tooresult = await tool.execute( + action="navigate_to", url="https://www.github.com" + ) print(result) # endpoint = "navigate_to" @@ -58,11 +64,23 @@ async def main(): # response = sandbox.process.exec(curl_cmd, timeout=30) # print(f"Response: {response}") - # response = sandbox.process.exec("ls -la") # print(response.result) # 清理资源(可选) # await computer_tool.cleanup() + +if __name__ == "__main__": + asyncio.run(main()) # 运行异步主函数 + # await computer_tool.cleanup() + +if __name__ == "__main__": + asyncio.run(main()) # 运行异步主函数 + # await computer_tool.cleanup() + +if __name__ == "__main__": + asyncio.run(main()) # 运行异步主函数 + # await computer_tool.cleanup() + if __name__ == "__main__": asyncio.run(main()) # 运行异步主函数 From 66f4ae046e6652d1a0a72231d7289898a8f45264 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Sun, 27 Jul 2025 21:59:18 +0800 Subject: [PATCH 22/33] feat:support 4 sandbox tools by using daytona --- app/agent/sandbox_agent.py | 6 - app/agentpress/__init__.py | 1 - app/agentpress/response_processor.py | 1890 --------------- app/agentpress/tool.py | 240 -- app/agentpress/tool_registry.py | 152 -- app/agentpress/utils/__init__.py | 1 - app/agentpress/utils/json_helpers.py | 174 -- app/agentpress/xml_tool_parser.py | 300 --- app/daytona/api.py | 309 ++- app/daytona/docker/Dockerfile | 133 -- app/daytona/docker/browser_api.py | 2110 ----------------- app/daytona/docker/docker-compose.yml | 45 - app/daytona/docker/entrypoint.sh | 4 - app/daytona/docker/requirements.txt | 6 - app/daytona/docker/server.py | 29 - app/daytona/docker/supervisord.conf | 94 - app/daytona/tool_base.py | 2 - app/services/billing.py | 969 -------- app/services/docker/redis.conf | 1 - app/services/email.py | 192 -- app/services/email_api.py | 70 - app/services/langfuse.py | 12 - app/services/llm.py | 411 ---- app/services/mcp_custom.py | 129 - app/services/mcp_temp.py | 299 --- app/services/redis.py | 153 -- app/services/supabase.py | 113 - app/services/transcription.py | 76 - app/tool/sb_files_tool.py | 105 +- app/tool/sb_shell_tool.py | 233 +- app/tool/sb_vision_tool.py | 103 +- app/utils/auth_utils.py | 231 -- app/utils/config.py | 253 -- app/utils/constants.py | 145 -- app/utils/retry.py | 58 - app/utils/s3_upload_utils.py | 51 - .../scripts/archive_inactive_sandboxes.py | 350 --- app/utils/scripts/archive_old_sandboxes.py | 344 --- app/utils/scripts/copy_project.py | 388 --- app/utils/scripts/delete_user_sandboxes.py | 138 -- app/utils/scripts/export_import_project.py | 400 ---- app/utils/scripts/generate_share_links.py | 166 -- app/utils/scripts/get_monthly_usage.py | 248 -- app/utils/scripts/set_all_customers_active.py | 142 -- .../scripts/update_customer_active_status.py | 326 --- 45 files changed, 474 insertions(+), 11128 deletions(-) delete mode 100644 app/agentpress/__init__.py delete mode 100644 app/agentpress/response_processor.py delete mode 100644 app/agentpress/tool.py delete mode 100644 app/agentpress/tool_registry.py delete mode 100644 app/agentpress/utils/__init__.py delete mode 100644 app/agentpress/utils/json_helpers.py delete mode 100644 app/agentpress/xml_tool_parser.py delete mode 100644 app/daytona/docker/Dockerfile delete mode 100644 app/daytona/docker/browser_api.py delete mode 100644 app/daytona/docker/docker-compose.yml delete mode 100644 app/daytona/docker/entrypoint.sh delete mode 100644 app/daytona/docker/requirements.txt delete mode 100644 app/daytona/docker/server.py delete mode 100644 app/daytona/docker/supervisord.conf delete mode 100644 app/services/billing.py delete mode 100644 app/services/docker/redis.conf delete mode 100644 app/services/email.py delete mode 100644 app/services/email_api.py delete mode 100644 app/services/langfuse.py delete mode 100644 app/services/llm.py delete mode 100644 app/services/mcp_custom.py delete mode 100644 app/services/mcp_temp.py delete mode 100644 app/services/redis.py delete mode 100644 app/services/supabase.py delete mode 100644 app/services/transcription.py delete mode 100644 app/utils/auth_utils.py delete mode 100644 app/utils/config.py delete mode 100644 app/utils/constants.py delete mode 100644 app/utils/retry.py delete mode 100644 app/utils/s3_upload_utils.py delete mode 100644 app/utils/scripts/archive_inactive_sandboxes.py delete mode 100644 app/utils/scripts/archive_old_sandboxes.py delete mode 100644 app/utils/scripts/copy_project.py delete mode 100644 app/utils/scripts/delete_user_sandboxes.py delete mode 100644 app/utils/scripts/export_import_project.py delete mode 100644 app/utils/scripts/generate_share_links.py delete mode 100644 app/utils/scripts/get_monthly_usage.py delete mode 100644 app/utils/scripts/set_all_customers_active.py delete mode 100644 app/utils/scripts/update_customer_active_status.py diff --git a/app/agent/sandbox_agent.py b/app/agent/sandbox_agent.py index 7480f8797..e1716b2cd 100644 --- a/app/agent/sandbox_agent.py +++ b/app/agent/sandbox_agent.py @@ -229,9 +229,3 @@ async def think(self) -> bool: self.next_step_prompt = original_prompt return result - return result - return result - return result - return result - return result - return result diff --git a/app/agentpress/__init__.py b/app/agentpress/__init__.py deleted file mode 100644 index 8ab9008ad..000000000 --- a/app/agentpress/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Utility functions and constants for agent tools \ No newline at end of file diff --git a/app/agentpress/response_processor.py b/app/agentpress/response_processor.py deleted file mode 100644 index e54f97331..000000000 --- a/app/agentpress/response_processor.py +++ /dev/null @@ -1,1890 +0,0 @@ -""" -Response processing module for AgentPress. - -This module handles the processing of LLM responses, including: -- Streaming and non-streaming response handling -- XML and native tool call detection and parsing -- Tool execution orchestration -- Message formatting and persistence -""" - -import json -import re -import uuid -import asyncio -from datetime import datetime, timezone -from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple, Union, Callable, Literal -from dataclasses import dataclass -from app.utils.logger import logger -from app.agentpress.tool import ToolResult -from app.agentpress.tool_registry import ToolRegistry -from app.agentpress.xml_tool_parser import XMLToolParser -from langfuse.client import StatefulTraceClient -from app.services.langfuse import langfuse -from app.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"] - -# Type alias for tool execution strategy -ToolExecutionStrategy = Literal["sequential", "parallel"] - -@dataclass -class ToolExecutionContext: - """Context for a tool execution including call details, result, and display info.""" - tool_call: Dict[str, Any] - tool_index: int - result: Optional[ToolResult] = None - function_name: Optional[str] = None - xml_tag_name: Optional[str] = None - error: Optional[Exception] = None - assistant_message_id: Optional[str] = None - parsing_details: Optional[Dict[str, Any]] = None - -@dataclass -class ProcessorConfig: - """ - Configuration for response processing and tool execution. - - This class controls how the LLM's responses are processed, including how tool calls - are detected, executed, and their results handled. - - Attributes: - xml_tool_calling: Enable XML-based tool call detection (...) - native_tool_calling: Enable OpenAI-style function calling format - execute_tools: Whether to automatically execute detected tool calls - execute_on_stream: For streaming, execute tools as they appear vs. at the end - tool_execution_strategy: How to execute multiple tools ("sequential" or "parallel") - xml_adding_strategy: How to add XML tool results to the conversation - max_xml_tool_calls: Maximum number of XML tool calls to process (0 = no limit) - """ - - xml_tool_calling: bool = True - native_tool_calling: bool = False - - execute_tools: bool = True - execute_on_stream: bool = False - tool_execution_strategy: ToolExecutionStrategy = "sequential" - xml_adding_strategy: XmlAddingStrategy = "assistant_message" - max_xml_tool_calls: int = 0 # 0 means no limit - - def __post_init__(self): - """Validate configuration after initialization.""" - if self.xml_tool_calling is False and self.native_tool_calling is False and self.execute_tools: - raise ValueError("At least one tool calling format (XML or native) must be enabled if execute_tools is True") - - if self.xml_adding_strategy not in ["user_message", "assistant_message", "inline_edit"]: - raise ValueError("xml_adding_strategy must be 'user_message', 'assistant_message', or 'inline_edit'") - - if self.max_xml_tool_calls < 0: - raise ValueError("max_xml_tool_calls must be a non-negative integer (0 = no limit)") - -class ResponseProcessor: - """Processes LLM responses, extracting and executing tool calls.""" - - def __init__(self, tool_registry: ToolRegistry, add_message_callback: Callable, trace: Optional[StatefulTraceClient] = None, is_agent_builder: bool = False, target_agent_id: Optional[str] = None, agent_config: Optional[dict] = None): - """Initialize the ResponseProcessor. - - Args: - tool_registry: Registry of available tools - add_message_callback: Callback function to add messages to the thread. - MUST return the full saved message object (dict) or None. - agent_config: Optional agent configuration with version information - """ - self.tool_registry = tool_registry - self.add_message = add_message_callback - self.trace = trace - if not self.trace: - self.trace = langfuse.trace(name="anonymous:response_processor") - # Initialize the XML parser with backwards compatibility - self.xml_parser = XMLToolParser(strict_mode=False) - self.is_agent_builder = is_agent_builder - self.target_agent_id = target_agent_id - self.agent_config = agent_config - - async def _yield_message(self, message_obj: Optional[Dict[str, Any]]) -> Dict[str, Any]: - """Helper to yield a message with proper formatting. - - Ensures that content and metadata are JSON strings for client compatibility. - """ - if message_obj: - return format_for_yield(message_obj) - - async def _add_message_with_agent_info( - self, - thread_id: str, - type: str, - content: Union[Dict[str, Any], List[Any], str], - is_llm_message: bool = False, - metadata: Optional[Dict[str, Any]] = None - ): - """Helper to add a message with agent version information if available.""" - agent_id = None - agent_version_id = None - - if self.agent_config: - agent_id = self.agent_config.get('agent_id') - agent_version_id = self.agent_config.get('current_version_id') - - return await self.add_message( - thread_id=thread_id, - type=type, - content=content, - is_llm_message=is_llm_message, - metadata=metadata, - agent_id=agent_id, - agent_version_id=agent_version_id - ) - - async def process_streaming_response( - self, - llm_response: AsyncGenerator, - thread_id: str, - prompt_messages: List[Dict[str, Any]], - llm_model: str, - config: ProcessorConfig = ProcessorConfig(), - ) -> AsyncGenerator[Dict[str, Any], None]: - """Process a streaming LLM response, handling tool calls and execution. - - Args: - llm_response: Streaming response from the LLM - thread_id: ID of the conversation thread - prompt_messages: List of messages sent to the LLM (the prompt) - llm_model: The name of the LLM model used - config: Configuration for parsing and execution - - Yields: - Complete message objects matching the DB schema, except for content chunks. - """ - accumulated_content = "" - tool_calls_buffer = {} - current_xml_content = "" - xml_chunks_buffer = [] - pending_tool_executions = [] - yielded_tool_indices = set() # Stores indices of tools whose *status* has been yielded - tool_index = 0 - xml_tool_call_count = 0 - finish_reason = None - last_assistant_message_object = None # Store the final saved assistant message object - 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}") - - thread_run_id = str(uuid.uuid4()) - - try: - # --- Save and Yield Start Events --- - start_content = {"status_type": "thread_run_start", "thread_run_id": thread_run_id} - start_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=start_content, - is_llm_message=False, metadata={"thread_run_id": thread_run_id} - ) - if start_msg_obj: yield format_for_yield(start_msg_obj) - - assist_start_content = {"status_type": "assistant_response_start"} - assist_start_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=assist_start_content, - is_llm_message=False, metadata={"thread_run_id": thread_run_id} - ) - if assist_start_msg_obj: yield format_for_yield(assist_start_msg_obj) - # --- End Start Events --- - - __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}") - - if hasattr(chunk, 'choices') and chunk.choices: - delta = chunk.choices[0].delta if hasattr(chunk.choices[0], 'delta') else None - - # Check for and log Anthropic thinking content - if delta and hasattr(delta, 'reasoning_content') and delta.reasoning_content: - if not has_printed_thinking_prefix: - # print("[THINKING]: ", end='', flush=True) - has_printed_thinking_prefix = True - # print(delta.reasoning_content, end='', flush=True) - # Append reasoning to main content to be saved in the final message - accumulated_content += delta.reasoning_content - - # Process content chunk - if delta and hasattr(delta, 'content') and delta.content: - chunk_content = delta.content - # print(chunk_content, end='', flush=True) - accumulated_content += chunk_content - current_xml_content += chunk_content - - if not (config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls): - # Yield ONLY content chunk (don't save) - now_chunk = datetime.now(timezone.utc).isoformat() - yield { - "sequence": __sequence, - "message_id": None, "thread_id": thread_id, "type": "assistant", - "is_llm_message": True, - "content": to_json_string({"role": "assistant", "content": chunk_content}), - "metadata": to_json_string({"stream_status": "chunk", "thread_run_id": thread_run_id}), - "created_at": now_chunk, "updated_at": now_chunk - } - __sequence += 1 - else: - logger.info("XML tool call limit reached - not yielding more content chunks") - self.trace.event(name="xml_tool_call_limit_reached", level="DEFAULT", status_message=(f"XML tool call limit reached - not yielding more content chunks")) - - # --- Process XML Tool Calls (if enabled and limit not reached) --- - if config.xml_tool_calling and not (config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls): - xml_chunks = self._extract_xml_chunks(current_xml_content) - for xml_chunk in xml_chunks: - current_xml_content = current_xml_content.replace(xml_chunk, "", 1) - xml_chunks_buffer.append(xml_chunk) - result = self._parse_xml_tool_call(xml_chunk) - if result: - tool_call, parsing_details = result - xml_tool_call_count += 1 - current_assistant_id = last_assistant_message_object['message_id'] if last_assistant_message_object else None - context = self._create_tool_context( - tool_call, tool_index, current_assistant_id, parsing_details - ) - - if config.execute_tools and config.execute_on_stream: - # Save and Yield tool_started status - started_msg_obj = await self._yield_and_save_tool_started(context, thread_id, thread_run_id) - if started_msg_obj: yield format_for_yield(started_msg_obj) - yielded_tool_indices.add(tool_index) # Mark status as yielded - - execution_task = asyncio.create_task(self._execute_tool(tool_call)) - pending_tool_executions.append({ - "task": execution_task, "tool_call": tool_call, - "tool_index": tool_index, "context": context - }) - tool_index += 1 - - if config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls: - logger.debug(f"Reached XML tool call limit ({config.max_xml_tool_calls})") - finish_reason = "xml_tool_limit_reached" - break # Stop processing more XML chunks in this delta - - # --- Process Native Tool Call Chunks --- - if config.native_tool_calling and delta and hasattr(delta, 'tool_calls') and delta.tool_calls: - for tool_call_chunk in delta.tool_calls: - # Yield Native Tool Call Chunk (transient status, not saved) - # ... (safe extraction logic for tool_call_data_chunk) ... - tool_call_data_chunk = {} # Placeholder for extracted data - if hasattr(tool_call_chunk, 'model_dump'): tool_call_data_chunk = tool_call_chunk.model_dump() - else: # Manual extraction... - if hasattr(tool_call_chunk, 'id'): tool_call_data_chunk['id'] = tool_call_chunk.id - if hasattr(tool_call_chunk, 'index'): tool_call_data_chunk['index'] = tool_call_chunk.index - if hasattr(tool_call_chunk, 'type'): tool_call_data_chunk['type'] = tool_call_chunk.type - if hasattr(tool_call_chunk, 'function'): - tool_call_data_chunk['function'] = {} - if hasattr(tool_call_chunk.function, 'name'): tool_call_data_chunk['function']['name'] = tool_call_chunk.function.name - if hasattr(tool_call_chunk.function, 'arguments'): tool_call_data_chunk['function']['arguments'] = tool_call_chunk.function.arguments if isinstance(tool_call_chunk.function.arguments, str) else to_json_string(tool_call_chunk.function.arguments) - - - now_tool_chunk = datetime.now(timezone.utc).isoformat() - yield { - "message_id": None, "thread_id": thread_id, "type": "status", "is_llm_message": True, - "content": to_json_string({"role": "assistant", "status_type": "tool_call_chunk", "tool_call_chunk": tool_call_data_chunk}), - "metadata": to_json_string({"thread_run_id": thread_run_id}), - "created_at": now_tool_chunk, "updated_at": now_tool_chunk - } - - # --- Buffer and Execute Complete Native Tool Calls --- - if not hasattr(tool_call_chunk, 'function'): continue - idx = tool_call_chunk.index if hasattr(tool_call_chunk, 'index') else 0 - # ... (buffer update logic remains same) ... - # ... (check complete logic remains same) ... - has_complete_tool_call = False # Placeholder - if (tool_calls_buffer.get(idx) and - tool_calls_buffer[idx]['id'] and - tool_calls_buffer[idx]['function']['name'] and - tool_calls_buffer[idx]['function']['arguments']): - try: - safe_json_parse(tool_calls_buffer[idx]['function']['arguments']) - has_complete_tool_call = True - except json.JSONDecodeError: pass - - - if has_complete_tool_call and config.execute_tools and config.execute_on_stream: - current_tool = tool_calls_buffer[idx] - tool_call_data = { - "function_name": current_tool['function']['name'], - "arguments": safe_json_parse(current_tool['function']['arguments']), - "id": current_tool['id'] - } - current_assistant_id = last_assistant_message_object['message_id'] if last_assistant_message_object else None - context = self._create_tool_context( - tool_call_data, tool_index, current_assistant_id - ) - - # Save and Yield tool_started status - started_msg_obj = await self._yield_and_save_tool_started(context, thread_id, thread_run_id) - if started_msg_obj: yield format_for_yield(started_msg_obj) - yielded_tool_indices.add(tool_index) # Mark status as yielded - - execution_task = asyncio.create_task(self._execute_tool(tool_call_data)) - pending_tool_executions.append({ - "task": execution_task, "tool_call": tool_call_data, - "tool_index": tool_index, "context": context - }) - tool_index += 1 - - if finish_reason == "xml_tool_limit_reached": - logger.info("Stopping stream processing after loop due to XML tool call limit") - self.trace.event(name="stopping_stream_processing_after_loop_due_to_xml_tool_call_limit", level="DEFAULT", status_message=(f"Stopping stream processing after loop due to XML tool call limit")) - break - - # 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) - if pending_tool_executions: - logger.info(f"Waiting for {len(pending_tool_executions)} pending streamed tool executions") - self.trace.event(name="waiting_for_pending_streamed_tool_executions", level="DEFAULT", status_message=(f"Waiting for {len(pending_tool_executions)} pending streamed tool executions")) - # ... (asyncio.wait logic) ... - pending_tasks = [execution["task"] for execution in pending_tool_executions] - done, _ = await asyncio.wait(pending_tasks) - - for execution in pending_tool_executions: - tool_idx = execution.get("tool_index", -1) - context = execution["context"] - tool_name = context.function_name - - # Check if status was already yielded during stream run - if tool_idx in yielded_tool_indices: - logger.debug(f"Status for tool index {tool_idx} already yielded.") - # Still need to process the result for the buffer - try: - if execution["task"].done(): - result = execution["task"].result() - context.result = result - tool_results_buffer.append((execution["tool_call"], result, tool_idx, context)) - - if tool_name in ['ask', 'complete']: - logger.info(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.") - self.trace.event(name="terminating_tool_completed_during_streaming", level="DEFAULT", status_message=(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.")) - agent_should_terminate = True - - else: # Should not happen with asyncio.wait - logger.warning(f"Task for tool index {tool_idx} not done after wait.") - self.trace.event(name="task_for_tool_index_not_done_after_wait", level="WARNING", status_message=(f"Task for tool index {tool_idx} not done after wait.")) - except Exception as e: - logger.error(f"Error getting result for pending tool execution {tool_idx}: {str(e)}") - self.trace.event(name="error_getting_result_for_pending_tool_execution", level="ERROR", status_message=(f"Error getting result for pending tool execution {tool_idx}: {str(e)}")) - context.error = e - # Save and Yield tool error status message (even if started was yielded) - error_msg_obj = await self._yield_and_save_tool_error(context, thread_id, thread_run_id) - if error_msg_obj: yield format_for_yield(error_msg_obj) - continue # Skip further status yielding for this tool index - - # If status wasn't yielded before (shouldn't happen with current logic), yield it now - try: - if execution["task"].done(): - result = execution["task"].result() - context.result = result - tool_results_buffer.append((execution["tool_call"], result, tool_idx, context)) - - # Check if this is a terminating tool - if tool_name in ['ask', 'complete']: - logger.info(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.") - self.trace.event(name="terminating_tool_completed_during_streaming", level="DEFAULT", status_message=(f"Terminating tool '{tool_name}' completed during streaming. Setting termination flag.")) - agent_should_terminate = True - - # Save and Yield tool completed/failed status - completed_msg_obj = await self._yield_and_save_tool_completed( - context, None, thread_id, thread_run_id - ) - if completed_msg_obj: yield format_for_yield(completed_msg_obj) - yielded_tool_indices.add(tool_idx) - except Exception as e: - logger.error(f"Error getting result/yielding status for pending tool execution {tool_idx}: {str(e)}") - self.trace.event(name="error_getting_result_yielding_status_for_pending_tool_execution", level="ERROR", status_message=(f"Error getting result/yielding status for pending tool execution {tool_idx}: {str(e)}")) - context.error = e - # Save and Yield tool error status - error_msg_obj = await self._yield_and_save_tool_error(context, thread_id, thread_run_id) - if error_msg_obj: yield format_for_yield(error_msg_obj) - yielded_tool_indices.add(tool_idx) - - - # Save and yield finish status if limit was reached - if finish_reason == "xml_tool_limit_reached": - finish_content = {"status_type": "finish", "finish_reason": "xml_tool_limit_reached"} - finish_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=finish_content, - is_llm_message=False, metadata={"thread_run_id": thread_run_id} - ) - if finish_msg_obj: yield format_for_yield(finish_msg_obj) - logger.info(f"Stream finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls") - self.trace.event(name="stream_finished_with_reason_xml_tool_limit_reached_after_xml_tool_calls", level="DEFAULT", status_message=(f"Stream finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls")) - - # --- SAVE and YIELD Final Assistant Message --- - if accumulated_content: - # ... (Truncate accumulated_content logic) ... - if config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls and xml_chunks_buffer: - last_xml_chunk = xml_chunks_buffer[-1] - last_chunk_end_pos = accumulated_content.find(last_xml_chunk) + len(last_xml_chunk) - if last_chunk_end_pos > 0: - accumulated_content = accumulated_content[:last_chunk_end_pos] - - # ... (Extract complete_native_tool_calls logic) ... - # 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']: - try: - args = safe_json_parse(tc_buf['function']['arguments']) - complete_native_tool_calls.append({ - "id": tc_buf['id'], "type": "function", - "function": {"name": tc_buf['function']['name'],"arguments": args} - }) - except json.JSONDecodeError: continue - - message_data = { # Dict to be saved in 'content' - "role": "assistant", "content": accumulated_content, - "tool_calls": complete_native_tool_calls or None - } - - last_assistant_message_object = await self._add_message_with_agent_info( - thread_id=thread_id, type="assistant", content=message_data, - is_llm_message=True, metadata={"thread_run_id": thread_run_id} - ) - - if last_assistant_message_object: - # Yield the complete saved object, adding stream_status metadata just for yield - yield_metadata = ensure_dict(last_assistant_message_object.get('metadata'), {}) - yield_metadata['stream_status'] = 'complete' - # Format the message for yielding - yield_message = last_assistant_message_object.copy() - yield_message['metadata'] = yield_metadata - yield format_for_yield(yield_message) - else: - logger.error(f"Failed to save final assistant message for thread {thread_id}") - self.trace.event(name="failed_to_save_final_assistant_message_for_thread", level="ERROR", status_message=(f"Failed to save final assistant message for thread {thread_id}")) - # Save and yield an error status - err_content = {"role": "system", "status_type": "error", "message": "Failed to save final assistant message"} - err_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=err_content, - is_llm_message=False, metadata={"thread_run_id": thread_run_id} - ) - if err_msg_obj: yield format_for_yield(err_msg_obj) - - # --- Process All Tool Results Now --- - if config.execute_tools: - final_tool_calls_to_process = [] - # ... (Gather final_tool_calls_to_process from native and XML buffers) ... - # Gather native tool calls from buffer - if config.native_tool_calling and complete_native_tool_calls: - for tc in complete_native_tool_calls: - final_tool_calls_to_process.append({ - "function_name": tc["function"]["name"], - "arguments": tc["function"]["arguments"], # Already parsed object - "id": tc["id"] - }) - # Gather XML tool calls from buffer (up to limit) - parsed_xml_data = [] - if config.xml_tool_calling: - # Reparse remaining content just in case (should be empty if processed correctly) - xml_chunks = self._extract_xml_chunks(current_xml_content) - xml_chunks_buffer.extend(xml_chunks) - # Process only chunks not already handled in the stream loop - remaining_limit = config.max_xml_tool_calls - xml_tool_call_count if config.max_xml_tool_calls > 0 else len(xml_chunks_buffer) - xml_chunks_to_process = xml_chunks_buffer[:remaining_limit] # Ensure limit is respected - - for chunk in xml_chunks_to_process: - parsed_result = self._parse_xml_tool_call(chunk) - if parsed_result: - tool_call, parsing_details = parsed_result - # Avoid adding if already processed during streaming - if not any(exec['tool_call'] == tool_call for exec in pending_tool_executions): - final_tool_calls_to_process.append(tool_call) - parsed_xml_data.append({'tool_call': tool_call, 'parsing_details': parsing_details}) - - - all_tool_data_map = {} # tool_index -> {'tool_call': ..., 'parsing_details': ...} - # Add native tool data - native_tool_index = 0 - if config.native_tool_calling and complete_native_tool_calls: - for tc in complete_native_tool_calls: - # Find the corresponding entry in final_tool_calls_to_process if needed - # For now, assume order matches if only native used - exec_tool_call = { - "function_name": tc["function"]["name"], - "arguments": tc["function"]["arguments"], - "id": tc["id"] - } - all_tool_data_map[native_tool_index] = {"tool_call": exec_tool_call, "parsing_details": None} - native_tool_index += 1 - - # Add XML tool data - xml_tool_index_start = native_tool_index - for idx, item in enumerate(parsed_xml_data): - all_tool_data_map[xml_tool_index_start + idx] = item - - - tool_results_map = {} # tool_index -> (tool_call, result, context) - - # Populate from buffer if executed on stream - if config.execute_on_stream and tool_results_buffer: - logger.info(f"Processing {len(tool_results_buffer)} buffered tool results") - self.trace.event(name="processing_buffered_tool_results", level="DEFAULT", status_message=(f"Processing {len(tool_results_buffer)} buffered tool results")) - for tool_call, result, tool_idx, context in tool_results_buffer: - if last_assistant_message_object: context.assistant_message_id = last_assistant_message_object['message_id'] - tool_results_map[tool_idx] = (tool_call, result, context) - - # Or execute now if not streamed - elif final_tool_calls_to_process and not config.execute_on_stream: - logger.info(f"Executing {len(final_tool_calls_to_process)} tools ({config.tool_execution_strategy}) after stream") - self.trace.event(name="executing_tools_after_stream", level="DEFAULT", status_message=(f"Executing {len(final_tool_calls_to_process)} tools ({config.tool_execution_strategy}) after stream")) - results_list = await self._execute_tools(final_tool_calls_to_process, config.tool_execution_strategy) - current_tool_idx = 0 - for tc, res in results_list: - # Map back using all_tool_data_map which has correct indices - if current_tool_idx in all_tool_data_map: - tool_data = all_tool_data_map[current_tool_idx] - context = self._create_tool_context( - tc, current_tool_idx, - last_assistant_message_object['message_id'] if last_assistant_message_object else None, - tool_data.get('parsing_details') - ) - context.result = res - tool_results_map[current_tool_idx] = (tc, res, context) - else: - logger.warning(f"Could not map result for tool index {current_tool_idx}") - self.trace.event(name="could_not_map_result_for_tool_index", level="WARNING", status_message=(f"Could not map result for tool index {current_tool_idx}")) - current_tool_idx += 1 - - # Save and Yield each result message - if tool_results_map: - logger.info(f"Saving and yielding {len(tool_results_map)} final tool result messages") - self.trace.event(name="saving_and_yielding_final_tool_result_messages", level="DEFAULT", status_message=(f"Saving and yielding {len(tool_results_map)} final tool result messages")) - for tool_idx in sorted(tool_results_map.keys()): - tool_call, result, context = tool_results_map[tool_idx] - context.result = result - if not context.assistant_message_id and last_assistant_message_object: - context.assistant_message_id = last_assistant_message_object['message_id'] - - # Yield start status ONLY IF executing non-streamed (already yielded if streamed) - if not config.execute_on_stream and tool_idx not in yielded_tool_indices: - started_msg_obj = await self._yield_and_save_tool_started(context, thread_id, thread_run_id) - if started_msg_obj: yield format_for_yield(started_msg_obj) - yielded_tool_indices.add(tool_idx) # Mark status yielded - - # Save the tool result message to DB - saved_tool_result_object = await self._add_tool_result( # Returns full object or None - thread_id, tool_call, result, config.xml_adding_strategy, - context.assistant_message_id, context.parsing_details - ) - - # Yield completed/failed status (linked to saved result ID if available) - completed_msg_obj = await self._yield_and_save_tool_completed( - context, - saved_tool_result_object['message_id'] if saved_tool_result_object else None, - thread_id, thread_run_id - ) - if completed_msg_obj: yield format_for_yield(completed_msg_obj) - # Don't add to yielded_tool_indices here, completion status is separate yield - - # Yield the saved tool result object - if saved_tool_result_object: - tool_result_message_objects[tool_idx] = saved_tool_result_object - yield format_for_yield(saved_tool_result_object) - else: - logger.error(f"Failed to save tool result for index {tool_idx}, not yielding result message.") - 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? - - # --- Final Finish Status --- - if finish_reason and finish_reason != "xml_tool_limit_reached": - finish_content = {"status_type": "finish", "finish_reason": finish_reason} - finish_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=finish_content, - is_llm_message=False, metadata={"thread_run_id": thread_run_id} - ) - if finish_msg_obj: yield format_for_yield(finish_msg_obj) - - # Check if agent should terminate after processing pending tools - if agent_should_terminate: - logger.info("Agent termination requested after executing ask/complete tool. Stopping further processing.") - self.trace.event(name="agent_termination_requested", level="DEFAULT", status_message="Agent termination requested after executing ask/complete tool. Stopping further processing.") - - # Set finish reason to indicate termination - finish_reason = "agent_terminated" - - # Save and yield termination status - finish_content = {"status_type": "finish", "finish_reason": "agent_terminated"} - finish_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=finish_content, - is_llm_message=False, metadata={"thread_run_id": thread_run_id} - ) - 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)}")) - # Save and yield error status message - err_content = {"role": "system", "status_type": "error", "message": str(e)} - err_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=err_content, - is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} - ) - if err_msg_obj: yield format_for_yield(err_msg_obj) # Yield the saved error message - - # Re-raise the same exception (not a new one) to ensure proper error propagation - logger.critical(f"Re-raising error to stop further processing: {str(e)}") - self.trace.event(name="re_raising_error_to_stop_further_processing", level="ERROR", status_message=(f"Re-raising error to stop further processing: {str(e)}")) - raise # Use bare 'raise' to preserve the original exception with its traceback - - finally: - # Save and Yield the final thread_run_end status - try: - end_content = {"status_type": "thread_run_end"} - end_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=end_content, - is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} - ) - if end_msg_obj: yield format_for_yield(end_msg_obj) - except Exception as final_e: - logger.error(f"Error in finally block: {str(final_e)}", exc_info=True) - self.trace.event(name="error_in_finally_block", level="ERROR", status_message=(f"Error in finally block: {str(final_e)}")) - - async def process_non_streaming_response( - self, - llm_response: Any, - thread_id: str, - prompt_messages: List[Dict[str, Any]], - llm_model: str, - config: ProcessorConfig = ProcessorConfig(), - ) -> AsyncGenerator[Dict[str, Any], None]: - """Process a non-streaming LLM response, handling tool calls and execution. - - Args: - llm_response: Response from the LLM - thread_id: ID of the conversation thread - prompt_messages: List of messages sent to the LLM (the prompt) - llm_model: The name of the LLM model used - config: Configuration for parsing and execution - - Yields: - Complete message objects matching the DB schema. - """ - content = "" - thread_run_id = str(uuid.uuid4()) - all_tool_data = [] # Stores {'tool_call': ..., 'parsing_details': ...} - tool_index = 0 - assistant_message_object = None - tool_result_message_objects = {} - finish_reason = None - native_tool_calls_for_message = [] - - try: - # Save and Yield thread_run_start status message - start_content = {"status_type": "thread_run_start", "thread_run_id": thread_run_id} - start_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=start_content, - is_llm_message=False, metadata={"thread_run_id": thread_run_id} - ) - if start_msg_obj: yield format_for_yield(start_msg_obj) - - # Extract finish_reason, content, tool calls - if hasattr(llm_response, 'choices') and llm_response.choices: - if hasattr(llm_response.choices[0], 'finish_reason'): - finish_reason = llm_response.choices[0].finish_reason - logger.info(f"Non-streaming finish_reason: {finish_reason}") - self.trace.event(name="non_streaming_finish_reason", level="DEFAULT", status_message=(f"Non-streaming finish_reason: {finish_reason}")) - response_message = llm_response.choices[0].message if hasattr(llm_response.choices[0], 'message') else None - if response_message: - if hasattr(response_message, 'content') and response_message.content: - content = response_message.content - if config.xml_tool_calling: - parsed_xml_data = self._parse_xml_tool_calls(content) - if config.max_xml_tool_calls > 0 and len(parsed_xml_data) > config.max_xml_tool_calls: - # Truncate content and tool data if limit exceeded - # ... (Truncation logic similar to streaming) ... - if parsed_xml_data: - xml_chunks = self._extract_xml_chunks(content)[:config.max_xml_tool_calls] - if xml_chunks: - last_chunk = xml_chunks[-1] - last_chunk_pos = content.find(last_chunk) - if last_chunk_pos >= 0: content = content[:last_chunk_pos + len(last_chunk)] - parsed_xml_data = parsed_xml_data[:config.max_xml_tool_calls] - finish_reason = "xml_tool_limit_reached" - all_tool_data.extend(parsed_xml_data) - - if config.native_tool_calling and hasattr(response_message, 'tool_calls') and response_message.tool_calls: - for tool_call in response_message.tool_calls: - if hasattr(tool_call, 'function'): - exec_tool_call = { - "function_name": tool_call.function.name, - "arguments": safe_json_parse(tool_call.function.arguments) if isinstance(tool_call.function.arguments, str) else tool_call.function.arguments, - "id": tool_call.id if hasattr(tool_call, 'id') else str(uuid.uuid4()) - } - all_tool_data.append({"tool_call": exec_tool_call, "parsing_details": None}) - native_tool_calls_for_message.append({ - "id": exec_tool_call["id"], "type": "function", - "function": { - "name": tool_call.function.name, - "arguments": tool_call.function.arguments if isinstance(tool_call.function.arguments, str) else to_json_string(tool_call.function.arguments) - } - }) - - - # --- SAVE and YIELD Final Assistant Message --- - message_data = {"role": "assistant", "content": content, "tool_calls": native_tool_calls_for_message or None} - assistant_message_object = await self._add_message_with_agent_info( - thread_id=thread_id, type="assistant", content=message_data, - is_llm_message=True, metadata={"thread_run_id": thread_run_id} - ) - if assistant_message_object: - yield assistant_message_object - else: - logger.error(f"Failed to save non-streaming assistant message for thread {thread_id}") - self.trace.event(name="failed_to_save_non_streaming_assistant_message_for_thread", level="ERROR", status_message=(f"Failed to save non-streaming assistant message for thread {thread_id}")) - err_content = {"role": "system", "status_type": "error", "message": "Failed to save assistant message"} - err_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=err_content, - is_llm_message=False, metadata={"thread_run_id": thread_run_id} - ) - if err_msg_obj: yield format_for_yield(err_msg_obj) - - # --- 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}") - self.trace.event(name="executing_tools_with_strategy", level="DEFAULT", status_message=(f"Executing {len(tool_calls_to_execute)} tools with strategy: {config.tool_execution_strategy}")) - tool_results = await self._execute_tools(tool_calls_to_execute, config.tool_execution_strategy) - - for i, (returned_tool_call, result) in enumerate(tool_results): - original_data = all_tool_data[i] - tool_call_from_data = original_data['tool_call'] - parsing_details = original_data['parsing_details'] - current_assistant_id = assistant_message_object['message_id'] if assistant_message_object else None - - context = self._create_tool_context( - tool_call_from_data, tool_index, current_assistant_id, parsing_details - ) - context.result = result - - # Save and Yield start status - started_msg_obj = await self._yield_and_save_tool_started(context, thread_id, thread_run_id) - if started_msg_obj: yield format_for_yield(started_msg_obj) - - # Save tool result - saved_tool_result_object = await self._add_tool_result( - thread_id, tool_call_from_data, result, config.xml_adding_strategy, - current_assistant_id, parsing_details - ) - - # Save and Yield completed/failed status - completed_msg_obj = await self._yield_and_save_tool_completed( - context, - saved_tool_result_object['message_id'] if saved_tool_result_object else None, - thread_id, thread_run_id - ) - if completed_msg_obj: yield format_for_yield(completed_msg_obj) - - # Yield the saved tool result object - if saved_tool_result_object: - tool_result_message_objects[tool_index] = saved_tool_result_object - yield format_for_yield(saved_tool_result_object) - else: - logger.error(f"Failed to save tool result for index {tool_index}") - self.trace.event(name="failed_to_save_tool_result_for_index", level="ERROR", status_message=(f"Failed to save tool result for index {tool_index}")) - - tool_index += 1 - - # --- Save and Yield Final Status --- - if finish_reason: - finish_content = {"status_type": "finish", "finish_reason": finish_reason} - finish_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=finish_content, - is_llm_message=False, metadata={"thread_run_id": thread_run_id} - ) - 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)}")) - # Save and yield error status - err_content = {"role": "system", "status_type": "error", "message": str(e)} - err_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=err_content, - is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} - ) - if err_msg_obj: yield format_for_yield(err_msg_obj) - - # Re-raise the same exception (not a new one) to ensure proper error propagation - logger.critical(f"Re-raising error to stop further processing: {str(e)}") - self.trace.event(name="re_raising_error_to_stop_further_processing", level="CRITICAL", status_message=(f"Re-raising error to stop further processing: {str(e)}")) - raise # Use bare 'raise' to preserve the original exception with its traceback - - finally: - # Save and Yield the final thread_run_end status - end_content = {"status_type": "thread_run_end"} - end_msg_obj = await self.add_message( - thread_id=thread_id, type="status", content=end_content, - is_llm_message=False, metadata={"thread_run_id": thread_run_id if 'thread_run_id' in locals() else None} - ) - if end_msg_obj: yield format_for_yield(end_msg_obj) - - # XML parsing methods - def _extract_tag_content(self, xml_chunk: str, tag_name: str) -> Tuple[Optional[str], Optional[str]]: - """Extract content between opening and closing tags, handling nested tags.""" - start_tag = f'<{tag_name}' - end_tag = f'' - - try: - # Find start tag position - start_pos = xml_chunk.find(start_tag) - if start_pos == -1: - return None, xml_chunk - - # Find end of opening tag - tag_end = xml_chunk.find('>', start_pos) - if tag_end == -1: - return None, xml_chunk - - # Find matching closing tag - content_start = tag_end + 1 - nesting_level = 1 - pos = content_start - - while nesting_level > 0 and pos < len(xml_chunk): - next_start = xml_chunk.find(start_tag, pos) - next_end = xml_chunk.find(end_tag, pos) - - if next_end == -1: - return None, xml_chunk - - if next_start != -1 and next_start < next_end: - nesting_level += 1 - pos = next_start + len(start_tag) - else: - nesting_level -= 1 - if nesting_level == 0: - content = xml_chunk[content_start:next_end] - remaining = xml_chunk[next_end + len(end_tag):] - return content, remaining - else: - pos = next_end + len(end_tag) - - return None, xml_chunk - - except Exception as e: - logger.error(f"Error extracting tag content: {e}") - self.trace.event(name="error_extracting_tag_content", level="ERROR", status_message=(f"Error extracting tag content: {e}")) - return None, xml_chunk - - def _extract_attribute(self, opening_tag: str, attr_name: str) -> Optional[str]: - """Extract attribute value from opening tag.""" - try: - # Handle both single and double quotes with raw strings - patterns = [ - fr'{attr_name}="([^"]*)"', # Double quotes - fr"{attr_name}='([^']*)'", # Single quotes - fr'{attr_name}=([^\s/>;]+)' # No quotes - fixed escape sequence - ] - - for pattern in patterns: - match = re.search(pattern, opening_tag) - if match: - value = match.group(1) - # Unescape common XML entities - value = value.replace('"', '"').replace(''', "'") - value = value.replace('<', '<').replace('>', '>') - value = value.replace('&', '&') - return value - - return None - - except Exception as e: - logger.error(f"Error extracting attribute: {e}") - self.trace.event(name="error_extracting_attribute", level="ERROR", status_message=(f"Error extracting attribute: {e}")) - return None - - def _extract_xml_chunks(self, content: str) -> List[str]: - """Extract complete XML chunks using start and end pattern matching.""" - chunks = [] - pos = 0 - - try: - # First, look for new format blocks - start_pattern = '' - end_pattern = '' - - while pos < len(content): - # Find the next function_calls block - start_pos = content.find(start_pattern, pos) - if start_pos == -1: - break - - # Find the matching end tag - end_pos = content.find(end_pattern, start_pos) - if end_pos == -1: - break - - # Extract the complete block including tags - chunk_end = end_pos + len(end_pattern) - chunk = content[start_pos:chunk_end] - chunks.append(chunk) - - # Move position past this chunk - pos = chunk_end - - # If no new format found, fall back to old format for backwards compatibility - if not chunks: - pos = 0 - while pos < len(content): - # Find the next tool tag - next_tag_start = -1 - current_tag = None - - # Find the earliest occurrence of any registered tag - for tag_name in self.tool_registry.xml_tools.keys(): - start_pattern = f'<{tag_name}' - tag_pos = content.find(start_pattern, pos) - - if tag_pos != -1 and (next_tag_start == -1 or tag_pos < next_tag_start): - next_tag_start = tag_pos - current_tag = tag_name - - if next_tag_start == -1 or not current_tag: - break - - # Find the matching end tag - end_pattern = f'' - tag_stack = [] - chunk_start = next_tag_start - current_pos = next_tag_start - - while current_pos < len(content): - # Look for next start or end tag of the same type - next_start = content.find(f'<{current_tag}', current_pos + 1) - next_end = content.find(end_pattern, current_pos) - - if next_end == -1: # No closing tag found - break - - if next_start != -1 and next_start < next_end: - # Found nested start tag - tag_stack.append(next_start) - current_pos = next_start + 1 - else: - # Found end tag - if not tag_stack: # This is our matching end tag - chunk_end = next_end + len(end_pattern) - chunk = content[chunk_start:chunk_end] - chunks.append(chunk) - pos = chunk_end - break - else: - # Pop nested tag - tag_stack.pop() - current_pos = next_end + 1 - - if current_pos >= len(content): # Reached end without finding closing tag - break - - pos = max(pos + 1, current_pos) - - except Exception as e: - logger.error(f"Error extracting XML chunks: {e}") - logger.error(f"Content was: {content}") - self.trace.event(name="error_extracting_xml_chunks", level="ERROR", status_message=(f"Error extracting XML chunks: {e}"), metadata={"content": content}) - - return chunks - - def _parse_xml_tool_call(self, xml_chunk: str) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]: - """Parse XML chunk into tool call format and return parsing details. - - Returns: - Tuple of (tool_call, parsing_details) or None if parsing fails. - - tool_call: Dict with 'function_name', 'xml_tag_name', 'arguments' - - parsing_details: Dict with 'attributes', 'elements', 'text_content', 'root_content' - """ - try: - # Check if this is the new format (contains ) - if '' in xml_chunk and ']+)', xml_chunk) - if not tag_match: - logger.error(f"No tag found in XML chunk: {xml_chunk}") - self.trace.event(name="no_tag_found_in_xml_chunk", level="ERROR", status_message=(f"No tag found in XML chunk: {xml_chunk}")) - return None - - # This is the XML tag as it appears in the text (e.g., "create-file") - xml_tag_name = tag_match.group(1) - logger.info(f"Found XML tag: {xml_tag_name}") - self.trace.event(name="found_xml_tag", level="DEFAULT", status_message=(f"Found XML tag: {xml_tag_name}")) - - # Get tool info and schema from registry - tool_info = self.tool_registry.get_xml_tool(xml_tag_name) - if not tool_info or not tool_info['schema'].xml_schema: - logger.error(f"No tool or schema found for tag: {xml_tag_name}") - self.trace.event(name="no_tool_or_schema_found_for_tag", level="ERROR", status_message=(f"No tool or schema found for tag: {xml_tag_name}")) - return None - - # This is the actual function name to call (e.g., "create_file") - function_name = tool_info['method'] - - schema = tool_info['schema'].xml_schema - params = {} - remaining_chunk = xml_chunk - - # --- Store detailed parsing info --- - parsing_details = { - "attributes": {}, - "elements": {}, - "text_content": None, - "root_content": None, - "raw_chunk": xml_chunk # Store the original chunk for reference - } - # --- - - # Process each mapping - for mapping in schema.mappings: - try: - if mapping.node_type == "attribute": - # Extract attribute from opening tag - opening_tag = remaining_chunk.split('>', 1)[0] - value = self._extract_attribute(opening_tag, mapping.param_name) - if value is not None: - params[mapping.param_name] = value - parsing_details["attributes"][mapping.param_name] = value # Store raw attribute - # logger.info(f"Found attribute {mapping.param_name}: {value}") - - elif mapping.node_type == "element": - # Extract element content - content, remaining_chunk = self._extract_tag_content(remaining_chunk, mapping.path) - if content is not None: - params[mapping.param_name] = content.strip() - parsing_details["elements"][mapping.param_name] = content.strip() # Store raw element content - # logger.info(f"Found element {mapping.param_name}: {content.strip()}") - - elif mapping.node_type == "text": - # Extract text content - content, _ = self._extract_tag_content(remaining_chunk, xml_tag_name) - if content is not None: - params[mapping.param_name] = content.strip() - parsing_details["text_content"] = content.strip() # Store raw text content - # logger.info(f"Found text content for {mapping.param_name}: {content.strip()}") - - elif mapping.node_type == "content": - # Extract root content - content, _ = self._extract_tag_content(remaining_chunk, xml_tag_name) - if content is not None: - params[mapping.param_name] = content.strip() - parsing_details["root_content"] = content.strip() # Store raw root content - # logger.info(f"Found root content for {mapping.param_name}") - - except Exception as e: - logger.error(f"Error processing mapping {mapping}: {e}") - self.trace.event(name="error_processing_mapping", level="ERROR", status_message=(f"Error processing mapping {mapping}: {e}")) - continue - - # Create tool call with clear separation between function_name and xml_tag_name - tool_call = { - "function_name": function_name, # The actual method to call (e.g., create_file) - "xml_tag_name": xml_tag_name, # The original XML tag (e.g., create-file) - "arguments": params # The extracted parameters - } - - # logger.debug(f"Parsed old format tool call: {tool_call["function_name"]}") - return tool_call, parsing_details # Return both dicts - - except Exception as e: - logger.error(f"Error parsing XML chunk: {e}") - logger.error(f"XML chunk was: {xml_chunk}") - self.trace.event(name="error_parsing_xml_chunk", level="ERROR", status_message=(f"Error parsing XML chunk: {e}"), metadata={"xml_chunk": xml_chunk}) - return None - - def _parse_xml_tool_calls(self, content: str) -> List[Dict[str, Any]]: - """Parse XML tool calls from content string. - - Returns: - List of dictionaries, each containing {'tool_call': ..., 'parsing_details': ...} - """ - parsed_data = [] - - try: - xml_chunks = self._extract_xml_chunks(content) - - for xml_chunk in xml_chunks: - result = self._parse_xml_tool_call(xml_chunk) - if result: - tool_call, parsing_details = result - parsed_data.append({ - "tool_call": tool_call, - "parsing_details": parsing_details - }) - - except Exception as e: - logger.error(f"Error parsing XML tool calls: {e}", exc_info=True) - self.trace.event(name="error_parsing_xml_tool_calls", level="ERROR", status_message=(f"Error parsing XML tool calls: {e}"), metadata={"content": content}) - - return parsed_data - - # Tool execution methods - async def _execute_tool(self, tool_call: Dict[str, Any]) -> ToolResult: - """Execute a single tool call and return the result.""" - span = self.trace.span(name=f"execute_tool.{tool_call['function_name']}", input=tool_call["arguments"]) - try: - function_name = tool_call["function_name"] - arguments = tool_call["arguments"] - - logger.info(f"Executing tool: {function_name} with arguments: {arguments}") - self.trace.event(name="executing_tool", level="DEFAULT", status_message=(f"Executing tool: {function_name} with arguments: {arguments}")) - - if isinstance(arguments, str): - try: - arguments = safe_json_parse(arguments) - except json.JSONDecodeError: - arguments = {"text": arguments} - - # Get available functions from tool registry - available_functions = self.tool_registry.get_available_functions() - - # Look up the function by name - tool_fn = available_functions.get(function_name) - if not tool_fn: - logger.error(f"Tool function '{function_name}' not found in registry") - span.end(status_message="tool_not_found", level="ERROR") - return ToolResult(success=False, output=f"Tool function '{function_name}' not found") - - logger.debug(f"Found tool function for '{function_name}', executing...") - result = await tool_fn(**arguments) - logger.info(f"Tool execution complete: {function_name} -> {result}") - span.end(status_message="tool_executed", output=result) - return result - except Exception as e: - logger.error(f"Error executing tool {tool_call['function_name']}: {str(e)}", exc_info=True) - span.end(status_message="tool_execution_error", output=f"Error executing tool: {str(e)}", level="ERROR") - return ToolResult(success=False, output=f"Error executing tool: {str(e)}") - - async def _execute_tools( - self, - tool_calls: List[Dict[str, Any]], - execution_strategy: ToolExecutionStrategy = "sequential" - ) -> List[Tuple[Dict[str, Any], ToolResult]]: - """Execute tool calls with the specified strategy. - - This is the main entry point for tool execution. It dispatches to the appropriate - execution method based on the provided strategy. - - Args: - tool_calls: List of tool calls to execute - execution_strategy: Strategy for executing tools: - - "sequential": Execute tools one after another, waiting for each to complete - - "parallel": Execute all tools simultaneously for better performance - - Returns: - List of tuples containing the original tool call and its result - """ - logger.info(f"Executing {len(tool_calls)} tools with strategy: {execution_strategy}") - self.trace.event(name="executing_tools_with_strategy", level="DEFAULT", status_message=(f"Executing {len(tool_calls)} tools with strategy: {execution_strategy}")) - - if execution_strategy == "sequential": - return await self._execute_tools_sequentially(tool_calls) - elif execution_strategy == "parallel": - return await self._execute_tools_in_parallel(tool_calls) - else: - logger.warning(f"Unknown execution strategy: {execution_strategy}, falling back to sequential") - return await self._execute_tools_sequentially(tool_calls) - - async def _execute_tools_sequentially(self, tool_calls: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], ToolResult]]: - """Execute tool calls sequentially and return results. - - This method executes tool calls one after another, waiting for each tool to complete - before starting the next one. This is useful when tools have dependencies on each other. - - Args: - tool_calls: List of tool calls to execute - - Returns: - List of tuples containing the original tool call and its result - """ - if not tool_calls: - return [] - - try: - tool_names = [t.get('function_name', 'unknown') for t in tool_calls] - logger.info(f"Executing {len(tool_calls)} tools sequentially: {tool_names}") - self.trace.event(name="executing_tools_sequentially", level="DEFAULT", status_message=(f"Executing {len(tool_calls)} tools sequentially: {tool_names}")) - - results = [] - for index, tool_call in enumerate(tool_calls): - tool_name = tool_call.get('function_name', 'unknown') - logger.debug(f"Executing tool {index+1}/{len(tool_calls)}: {tool_name}") - - try: - result = await self._execute_tool(tool_call) - results.append((tool_call, result)) - logger.debug(f"Completed tool {tool_name} with success={result.success}") - - # Check if this is a terminating tool (ask or complete) - if tool_name in ['ask', 'complete']: - logger.info(f"Terminating tool '{tool_name}' executed. Stopping further tool execution.") - self.trace.event(name="terminating_tool_executed", level="DEFAULT", status_message=(f"Terminating tool '{tool_name}' executed. Stopping further tool execution.")) - break # Stop executing remaining tools - - except Exception as e: - logger.error(f"Error executing tool {tool_name}: {str(e)}") - self.trace.event(name="error_executing_tool", level="ERROR", status_message=(f"Error executing tool {tool_name}: {str(e)}")) - error_result = ToolResult(success=False, output=f"Error executing tool: {str(e)}") - results.append((tool_call, error_result)) - - logger.info(f"Sequential execution completed for {len(results)} tools (out of {len(tool_calls)} total)") - self.trace.event(name="sequential_execution_completed", level="DEFAULT", status_message=(f"Sequential execution completed for {len(results)} tools (out of {len(tool_calls)} total)")) - return results - - except Exception as e: - logger.error(f"Error in sequential tool execution: {str(e)}", exc_info=True) - # Return partial results plus error results for remaining tools - completed_tool_names = [r[0].get('function_name', 'unknown') for r in results] if 'results' in locals() else [] - remaining_tools = [t for t in tool_calls if t.get('function_name', 'unknown') not in completed_tool_names] - - # Add error results for remaining tools - error_results = [(tool, ToolResult(success=False, output=f"Execution error: {str(e)}")) - for tool in remaining_tools] - - return (results if 'results' in locals() else []) + error_results - - async def _execute_tools_in_parallel(self, tool_calls: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], ToolResult]]: - """Execute tool calls in parallel and return results. - - This method executes all tool calls simultaneously using asyncio.gather, which - can significantly improve performance when executing multiple independent tools. - - Args: - tool_calls: List of tool calls to execute - - Returns: - List of tuples containing the original tool call and its result - """ - if not tool_calls: - return [] - - try: - tool_names = [t.get('function_name', 'unknown') for t in tool_calls] - logger.info(f"Executing {len(tool_calls)} tools in parallel: {tool_names}") - self.trace.event(name="executing_tools_in_parallel", level="DEFAULT", status_message=(f"Executing {len(tool_calls)} tools in parallel: {tool_names}")) - - # Create tasks for all tool calls - tasks = [self._execute_tool(tool_call) for tool_call in tool_calls] - - # Execute all tasks concurrently with error handling - results = await asyncio.gather(*tasks, return_exceptions=True) - - # Process results and handle any exceptions - processed_results = [] - for i, (tool_call, result) in enumerate(zip(tool_calls, results)): - if isinstance(result, Exception): - logger.error(f"Error executing tool {tool_call.get('function_name', 'unknown')}: {str(result)}") - self.trace.event(name="error_executing_tool", level="ERROR", status_message=(f"Error executing tool {tool_call.get('function_name', 'unknown')}: {str(result)}")) - # Create error result - error_result = ToolResult(success=False, output=f"Error executing tool: {str(result)}") - processed_results.append((tool_call, error_result)) - else: - processed_results.append((tool_call, result)) - - logger.info(f"Parallel execution completed for {len(tool_calls)} tools") - self.trace.event(name="parallel_execution_completed", level="DEFAULT", status_message=(f"Parallel execution completed for {len(tool_calls)} tools")) - return processed_results - - except Exception as e: - logger.error(f"Error in parallel tool execution: {str(e)}", exc_info=True) - self.trace.event(name="error_in_parallel_tool_execution", level="ERROR", status_message=(f"Error in parallel tool execution: {str(e)}")) - # Return error results for all tools if the gather itself fails - return [(tool_call, ToolResult(success=False, output=f"Execution error: {str(e)}")) - for tool_call in tool_calls] - - async def _add_tool_result( - self, - thread_id: str, - tool_call: Dict[str, Any], - result: ToolResult, - strategy: Union[XmlAddingStrategy, str] = "assistant_message", - assistant_message_id: Optional[str] = None, - parsing_details: Optional[Dict[str, Any]] = None - ) -> Optional[Dict[str, Any]]: # Return the full message object - """Add a tool result to the conversation thread based on the specified format. - - This method formats tool results and adds them to the conversation history, - making them visible to the LLM in subsequent interactions. Results can be - added either as native tool messages (OpenAI format) or as XML-wrapped content - with a specified role (user or assistant). - - Args: - thread_id: ID of the conversation thread - tool_call: The original tool call that produced this result - result: The result from the tool execution - strategy: How to add XML tool results to the conversation - ("user_message", "assistant_message", or "inline_edit") - assistant_message_id: ID of the assistant message that generated this tool call - parsing_details: Detailed parsing info for XML calls (attributes, elements, etc.) - """ - try: - message_obj = None # Initialize message_obj - - # Create metadata with assistant_message_id if provided - metadata = {} - if assistant_message_id: - metadata["assistant_message_id"] = assistant_message_id - logger.info(f"Linking tool result to assistant message: {assistant_message_id}") - self.trace.event(name="linking_tool_result_to_assistant_message", level="DEFAULT", status_message=(f"Linking tool result to assistant message: {assistant_message_id}")) - - # --- Add parsing details to metadata if available --- - if parsing_details: - metadata["parsing_details"] = parsing_details - logger.info("Adding parsing_details to tool result metadata") - self.trace.event(name="adding_parsing_details_to_tool_result_metadata", level="DEFAULT", status_message=(f"Adding parsing_details to tool result metadata"), metadata={"parsing_details": parsing_details}) - # --- - - # Check if this is a native function call (has id field) - if "id" in tool_call: - # Format as a proper tool message according to OpenAI spec - function_name = tool_call.get("function_name", "") - - # Format the tool result content - tool role needs string content - if isinstance(result, str): - content = result - elif hasattr(result, 'output'): - # If it's a ToolResult object - if isinstance(result.output, dict) or isinstance(result.output, list): - # If output is already a dict or list, convert to JSON string - content = json.dumps(result.output) - else: - # Otherwise just use the string representation - content = str(result.output) - else: - # Fallback to string representation of the whole result - content = str(result) - - logger.info(f"Formatted tool result content: {content[:100]}...") - self.trace.event(name="formatted_tool_result_content", level="DEFAULT", status_message=(f"Formatted tool result content: {content[:100]}...")) - - # Create the tool response message with proper format - tool_message = { - "role": "tool", - "tool_call_id": tool_call["id"], - "name": function_name, - "content": content - } - - logger.info(f"Adding native tool result for tool_call_id={tool_call['id']} with role=tool") - self.trace.event(name="adding_native_tool_result_for_tool_call_id", level="DEFAULT", status_message=(f"Adding native tool result for tool_call_id={tool_call['id']} with role=tool")) - - # Add as a tool message to the conversation history - # This makes the result visible to the LLM in the next turn - message_obj = await self.add_message( - thread_id=thread_id, - type="tool", # Special type for tool responses - content=tool_message, - is_llm_message=True, - metadata=metadata - ) - return message_obj # Return the full message object - - # Check if this is an MCP tool (function_name starts with "call_mcp_tool") - function_name = tool_call.get("function_name", "") - - # Check if this is an MCP tool - either the old call_mcp_tool or a dynamically registered MCP tool - is_mcp_tool = False - if function_name == "call_mcp_tool": - is_mcp_tool = True - else: - # Check if the result indicates it's an MCP tool by looking for MCP metadata - if hasattr(result, 'output') and isinstance(result.output, str): - # Check for MCP metadata pattern in the output - if "MCP Tool Result from" in result.output and "Tool Metadata:" in result.output: - is_mcp_tool = True - # Also check for MCP metadata in JSON format - elif "mcp_metadata" in result.output: - is_mcp_tool = True - - if is_mcp_tool: - # Special handling for MCP tools - make content prominent and LLM-friendly - result_role = "user" if strategy == "user_message" else "assistant" - - # Extract the actual content from the ToolResult - if hasattr(result, 'output'): - mcp_content = str(result.output) - else: - mcp_content = str(result) - - # Create a simple, LLM-friendly message format that puts content first - simple_message = { - "role": result_role, - "content": mcp_content # Direct content, no complex nesting - } - - logger.info(f"Adding MCP tool result with simplified format for LLM visibility") - self.trace.event(name="adding_mcp_tool_result_simplified", level="DEFAULT", status_message="Adding MCP tool result with simplified format for LLM visibility") - - message_obj = await self.add_message( - thread_id=thread_id, - type="tool", - content=simple_message, - is_llm_message=True, - metadata=metadata - ) - return message_obj - - # For XML and other non-native tools, use the new structured format - # Determine message role based on strategy - result_role = "user" if strategy == "user_message" else "assistant" - - # Create the new structured tool result format - structured_result = self._create_structured_tool_result(tool_call, result, parsing_details) - - # Add the message with the appropriate role to the conversation history - # This allows the LLM to see the tool result in subsequent interactions - result_message = { - "role": result_role, - "content": json.dumps(structured_result) - } - message_obj = await self.add_message( - thread_id=thread_id, - type="tool", - content=result_message, - is_llm_message=True, - metadata=metadata - ) - return message_obj # Return the full message object - except Exception as e: - logger.error(f"Error adding tool result: {str(e)}", exc_info=True) - self.trace.event(name="error_adding_tool_result", level="ERROR", status_message=(f"Error adding tool result: {str(e)}"), metadata={"tool_call": tool_call, "result": result, "strategy": strategy, "assistant_message_id": assistant_message_id, "parsing_details": parsing_details}) - # Fallback to a simple message - try: - fallback_message = { - "role": "user", - "content": str(result) - } - message_obj = await self.add_message( - thread_id=thread_id, - type="tool", - content=fallback_message, - is_llm_message=True, - metadata={"assistant_message_id": assistant_message_id} if assistant_message_id else {} - ) - return message_obj # Return the full message object - except Exception as e2: - logger.error(f"Failed even with fallback message: {str(e2)}", exc_info=True) - self.trace.event(name="failed_even_with_fallback_message", level="ERROR", status_message=(f"Failed even with fallback message: {str(e2)}"), metadata={"tool_call": tool_call, "result": result, "strategy": strategy, "assistant_message_id": assistant_message_id, "parsing_details": parsing_details}) - return None # Return None on error - - def _create_structured_tool_result(self, tool_call: Dict[str, Any], result: ToolResult, parsing_details: Optional[Dict[str, Any]] = None): - """Create a structured tool result format that's tool-agnostic and provides rich information. - - Args: - tool_call: The original tool call that was executed - result: The result from the tool execution - parsing_details: Optional parsing details for XML calls - - Returns: - Structured dictionary containing tool execution information - """ - # Extract tool information - function_name = tool_call.get("function_name", "unknown") - xml_tag_name = tool_call.get("xml_tag_name") - arguments = tool_call.get("arguments", {}) - tool_call_id = tool_call.get("id") - logger.info(f"Creating structured tool result for tool_call: {tool_call}") - - # Process the output - if it's a JSON string, parse it back to an object - output = result.output if hasattr(result, 'output') else str(result) - if isinstance(output, str): - try: - # Try to parse as JSON to provide structured data to frontend - parsed_output = safe_json_parse(output) - # If parsing succeeded and we got a dict/list, use the parsed version - if isinstance(parsed_output, (dict, list)): - output = parsed_output - # Otherwise keep the original string - except Exception: - # If parsing fails, keep the original string - pass - - # Create the structured result - structured_result_v1 = { - "tool_execution": { - "function_name": function_name, - "xml_tag_name": xml_tag_name, - "tool_call_id": tool_call_id, - "arguments": arguments, - "result": { - "success": result.success if hasattr(result, 'success') else True, - "output": output, # Now properly structured for frontend - "error": getattr(result, 'error', None) if hasattr(result, 'error') else None - }, - # "execution_details": { - # "timestamp": datetime.now(timezone.utc).isoformat(), - # "parsing_details": parsing_details - # } - } - } - - # STRUCTURED_OUTPUT_TOOLS = { - # "str_replace", - # "get_data_provider_endpoints", - # } - - # summary_output = result.output if hasattr(result, 'output') else str(result) - - # if xml_tag_name: - # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" - # summary = f"Tool '{xml_tag_name}' {status}. Output: {summary_output}" - # else: - # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" - # summary = f"Function '{function_name}' {status}. Output: {summary_output}" - - # if self.is_agent_builder: - # return summary - # if function_name in STRUCTURED_OUTPUT_TOOLS: - # return structured_result_v1 - # else: - # return summary - - summary_output = result.output if hasattr(result, 'output') else str(result) - success_status = structured_result_v1["tool_execution"]["result"]["success"] - - # # Create a more comprehensive summary for the LLM - # if xml_tag_name: - # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" - # summary = f"Tool '{xml_tag_name}' {status}. Output: {summary_output}" - # else: - # status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed" - # summary = f"Function '{function_name}' {status}. Output: {summary_output}" - - # if self.is_agent_builder: - # return summary - # elif function_name == "get_data_provider_endpoints": - # logger.info(f"Returning sumnary for data provider call: {summary}") - # return summary - - return structured_result_v1 - - def _format_xml_tool_result(self, tool_call: Dict[str, Any], result: ToolResult) -> str: - """Format a tool result wrapped in a tag. - - DEPRECATED: This method is kept for backwards compatibility. - New implementations should use _create_structured_tool_result instead. - - Args: - tool_call: The tool call that was executed - result: The result of the tool execution - - Returns: - String containing the formatted result wrapped in tag - """ - # Always use xml_tag_name if it exists - if "xml_tag_name" in tool_call: - xml_tag_name = tool_call["xml_tag_name"] - return f" <{xml_tag_name}> {str(result)} " - - # Non-XML tool, just return the function result - function_name = tool_call["function_name"] - return f"Result for {function_name}: {str(result)}" - - def _create_tool_context(self, tool_call: Dict[str, Any], tool_index: int, assistant_message_id: Optional[str] = None, parsing_details: Optional[Dict[str, Any]] = None) -> ToolExecutionContext: - """Create a tool execution context with display name and parsing details populated.""" - context = ToolExecutionContext( - tool_call=tool_call, - tool_index=tool_index, - assistant_message_id=assistant_message_id, - parsing_details=parsing_details - ) - - # Set function_name and xml_tag_name fields - if "xml_tag_name" in tool_call: - context.xml_tag_name = tool_call["xml_tag_name"] - context.function_name = tool_call.get("function_name", tool_call["xml_tag_name"]) - else: - # For non-XML tools, use function name directly - context.function_name = tool_call.get("function_name", "unknown") - context.xml_tag_name = None - - return context - - async def _yield_and_save_tool_started(self, context: ToolExecutionContext, thread_id: str, thread_run_id: str) -> Optional[Dict[str, Any]]: - """Formats, saves, and returns a tool started status message.""" - tool_name = context.xml_tag_name or context.function_name - content = { - "role": "assistant", "status_type": "tool_started", - "function_name": context.function_name, "xml_tag_name": context.xml_tag_name, - "message": f"Starting execution of {tool_name}", "tool_index": context.tool_index, - "tool_call_id": context.tool_call.get("id") # Include tool_call ID if native - } - metadata = {"thread_run_id": thread_run_id} - 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 the full object (or None if saving failed) - - async def _yield_and_save_tool_completed(self, context: ToolExecutionContext, tool_message_id: Optional[str], thread_id: str, thread_run_id: str) -> Optional[Dict[str, Any]]: - """Formats, saves, and returns a tool completed/failed status message.""" - if not context.result: - # Delegate to error saving if result is missing (e.g., execution failed) - return await self._yield_and_save_tool_error(context, thread_id, thread_run_id) - - tool_name = context.xml_tag_name or context.function_name - status_type = "tool_completed" if context.result.success else "tool_failed" - message_text = f"Tool {tool_name} {'completed successfully' if context.result.success else 'failed'}" - - content = { - "role": "assistant", "status_type": status_type, - "function_name": context.function_name, "xml_tag_name": context.xml_tag_name, - "message": message_text, "tool_index": context.tool_index, - "tool_call_id": context.tool_call.get("id") - } - metadata = {"thread_run_id": thread_run_id} - # Add the *actual* tool result message ID to the metadata if available and successful - if context.result.success and tool_message_id: - metadata["linked_tool_result_message_id"] = tool_message_id - - # <<< ADDED: Signal if this is a terminating tool >>> - if context.function_name in ['ask', 'complete']: - metadata["agent_should_terminate"] = True - logger.info(f"Marking tool status for '{context.function_name}' with termination signal.") - self.trace.event(name="marking_tool_status_for_termination", level="DEFAULT", status_message=(f"Marking tool status for '{context.function_name}' with termination signal.")) - # <<< END ADDED >>> - - 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 - - async def _yield_and_save_tool_error(self, context: ToolExecutionContext, thread_id: str, thread_run_id: str) -> Optional[Dict[str, Any]]: - """Formats, saves, and returns a tool error status message.""" - error_msg = str(context.error) if context.error else "Unknown error during tool execution" - tool_name = context.xml_tag_name or context.function_name - content = { - "role": "assistant", "status_type": "tool_error", - "function_name": context.function_name, "xml_tag_name": context.xml_tag_name, - "message": f"Error executing tool {tool_name}: {error_msg}", - "tool_index": context.tool_index, - "tool_call_id": context.tool_call.get("id") - } - metadata = {"thread_run_id": thread_run_id} - # Save the status message with is_llm_message=False - 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 diff --git a/app/agentpress/tool.py b/app/agentpress/tool.py deleted file mode 100644 index 8bcb90247..000000000 --- a/app/agentpress/tool.py +++ /dev/null @@ -1,240 +0,0 @@ -""" -Core tool system providing the foundation for creating and managing tools. - -This module defines the base classes and decorators for creating tools in AgentPress: -- Tool base class for implementing tool functionality -- Schema decorators for OpenAPI and XML tool definitions -- Result containers for standardized tool outputs -""" - -from typing import Dict, Any, Union, Optional, List -from dataclasses import dataclass, field -from abc import ABC -import json -import inspect -from enum import Enum -from app.utils.logger import logger - -class SchemaType(Enum): - """Enumeration of supported schema types for tool definitions.""" - OPENAPI = "openapi" - XML = "xml" - CUSTOM = "custom" - -@dataclass -class XMLNodeMapping: - """Maps an XML node to a function parameter. - - Attributes: - param_name (str): Name of the function parameter - node_type (str): Type of node ("element", "attribute", or "content") - path (str): XPath-like path to the node ("." means root element) - required (bool): Whether the parameter is required (defaults to True) - """ - param_name: str - node_type: str = "element" - path: str = "." - required: bool = True - -@dataclass -class XMLTagSchema: - """Schema definition for XML tool tags. - - Attributes: - tag_name (str): Root tag name for the tool - mappings (List[XMLNodeMapping]): Parameter mappings for the tag - example (str, optional): Example showing tag usage - - Methods: - add_mapping: Add a new parameter mapping to the schema - """ - tag_name: str - mappings: List[XMLNodeMapping] = field(default_factory=list) - example: Optional[str] = None - - def add_mapping(self, param_name: str, node_type: str = "element", path: str = ".", required: bool = True) -> None: - """Add a new node mapping to the schema. - - Args: - param_name: Name of the function parameter - node_type: Type of node ("element", "attribute", or "content") - path: XPath-like path to the node - required: Whether the parameter is required - """ - self.mappings.append(XMLNodeMapping( - param_name=param_name, - node_type=node_type, - path=path, - required=required - )) - logger.debug(f"Added XML mapping for parameter '{param_name}' with type '{node_type}' at path '{path}', required={required}") - -@dataclass -class ToolSchema: - """Container for tool schemas with type information. - - Attributes: - schema_type (SchemaType): Type of schema (OpenAPI, XML, or Custom) - schema (Dict[str, Any]): The actual schema definition - xml_schema (XMLTagSchema, optional): XML-specific schema if applicable - """ - schema_type: SchemaType - schema: Dict[str, Any] - xml_schema: Optional[XMLTagSchema] = None - -@dataclass -class ToolResult: - """Container for tool execution results. - - Attributes: - success (bool): Whether the tool execution succeeded - output (str): Output message or error description - """ - success: bool - output: str - -class Tool(ABC): - """Abstract base class for all tools. - - Provides the foundation for implementing tools with schema registration - and result handling capabilities. - - Attributes: - _schemas (Dict[str, List[ToolSchema]]): Registered schemas for tool methods - - Methods: - get_schemas: Get all registered tool schemas - success_response: Create a successful result - fail_response: Create a failed result - """ - - def __init__(self): - """Initialize tool with empty schema registry.""" - self._schemas: Dict[str, List[ToolSchema]] = {} - logger.debug(f"Initializing tool class: {self.__class__.__name__}") - self._register_schemas() - - def _register_schemas(self): - """Register schemas from all decorated methods.""" - for name, method in inspect.getmembers(self, predicate=inspect.ismethod): - if hasattr(method, 'tool_schemas'): - self._schemas[name] = method.tool_schemas - logger.debug(f"Registered schemas for method '{name}' in {self.__class__.__name__}") - - def get_schemas(self) -> Dict[str, List[ToolSchema]]: - """Get all registered tool schemas. - - Returns: - Dict mapping method names to their schema definitions - """ - return self._schemas - - def success_response(self, data: Union[Dict[str, Any], str]) -> ToolResult: - """Create a successful tool result. - - Args: - data: Result data (dictionary or string) - - Returns: - ToolResult with success=True and formatted output - """ - if isinstance(data, str): - text = data - else: - text = json.dumps(data, indent=2) - logger.debug(f"Created success response for {self.__class__.__name__}") - return ToolResult(success=True, output=text) - - def fail_response(self, msg: str) -> ToolResult: - """Create a failed tool result. - - Args: - msg: Error message describing the failure - - Returns: - ToolResult with success=False and error message - """ - logger.debug(f"Tool {self.__class__.__name__} returned failed result: {msg}") - return ToolResult(success=False, output=msg) - -def _add_schema(func, schema: ToolSchema): - """Helper to add schema to a function.""" - if not hasattr(func, 'tool_schemas'): - func.tool_schemas = [] - func.tool_schemas.append(schema) - logger.debug(f"Added {schema.schema_type.value} schema to function {func.__name__}") - return func - -def openapi_schema(schema: Dict[str, Any]): - """Decorator for OpenAPI schema tools.""" - def decorator(func): - logger.debug(f"Applying OpenAPI schema to function {func.__name__}") - return _add_schema(func, ToolSchema( - schema_type=SchemaType.OPENAPI, - schema=schema - )) - return decorator - -def xml_schema( - tag_name: str, - mappings: List[Dict[str, Any]] = None, - example: str = None -): - """ - Decorator for XML schema tools with improved node mapping. - - Args: - tag_name: Name of the root XML tag - mappings: List of mapping definitions, each containing: - - param_name: Name of the function parameter - - node_type: "element", "attribute", or "content" - - path: Path to the node (default "." for root) - - required: Whether the parameter is required (default True) - example: Optional example showing how to use the XML tag - - Example: - @xml_schema( - tag_name="str-replace", - mappings=[ - {"param_name": "file_path", "node_type": "attribute", "path": "."}, - {"param_name": "old_str", "node_type": "element", "path": "old_str"}, - {"param_name": "new_str", "node_type": "element", "path": "new_str"} - ], - example=''' - - text to replace - replacement text - - ''' - ) - """ - def decorator(func): - logger.debug(f"Applying XML schema with tag '{tag_name}' to function {func.__name__}") - xml_schema = XMLTagSchema(tag_name=tag_name, example=example) - - # Add mappings - if mappings: - for mapping in mappings: - xml_schema.add_mapping( - param_name=mapping["param_name"], - node_type=mapping.get("node_type", "element"), - path=mapping.get("path", "."), - required=mapping.get("required", True) - ) - - return _add_schema(func, ToolSchema( - schema_type=SchemaType.XML, - schema={}, # OpenAPI schema could be added here if needed - xml_schema=xml_schema - )) - return decorator - -def custom_schema(schema: Dict[str, Any]): - """Decorator for custom schema tools.""" - def decorator(func): - logger.debug(f"Applying custom schema to function {func.__name__}") - return _add_schema(func, ToolSchema( - schema_type=SchemaType.CUSTOM, - schema=schema - )) - return decorator diff --git a/app/agentpress/tool_registry.py b/app/agentpress/tool_registry.py deleted file mode 100644 index 481410b6e..000000000 --- a/app/agentpress/tool_registry.py +++ /dev/null @@ -1,152 +0,0 @@ -from typing import Dict, Type, Any, List, Optional, Callable -from app.agentpress.tool import Tool, SchemaType -from app.utils.logger import logger - - -class ToolRegistry: - """Registry for managing and accessing tools. - - Maintains a collection of tool instances and their schemas, allowing for - selective registration of tool functions and easy access to tool capabilities. - - Attributes: - tools (Dict[str, Dict[str, Any]]): OpenAPI-style tools and schemas - xml_tools (Dict[str, Dict[str, Any]]): XML-style tools and schemas - - Methods: - register_tool: Register a tool with optional function filtering - get_tool: Get a specific tool by name - get_xml_tool: Get a tool by XML tag name - get_openapi_schemas: Get OpenAPI schemas for function calling - get_xml_examples: Get examples of XML tool usage - """ - - def __init__(self): - """Initialize a new ToolRegistry instance.""" - self.tools = {} - self.xml_tools = {} - logger.debug("Initialized new ToolRegistry instance") - - def register_tool(self, tool_class: Type[Tool], function_names: Optional[List[str]] = None, **kwargs): - """Register a tool with optional function filtering. - - Args: - tool_class: The tool class to register - function_names: Optional list of specific functions to register - **kwargs: Additional arguments passed to tool initialization - - Notes: - - If function_names is None, all functions are registered - - Handles both OpenAPI and XML schema registration - """ - logger.debug(f"Registering tool class: {tool_class.__name__}") - tool_instance = tool_class(**kwargs) - schemas = tool_instance.get_schemas() - - logger.debug(f"Available schemas for {tool_class.__name__}: {list(schemas.keys())}") - - registered_openapi = 0 - registered_xml = 0 - - for func_name, schema_list in schemas.items(): - if function_names is None or func_name in function_names: - for schema in schema_list: - if schema.schema_type == SchemaType.OPENAPI: - self.tools[func_name] = { - "instance": tool_instance, - "schema": schema - } - registered_openapi += 1 - logger.debug(f"Registered OpenAPI function {func_name} from {tool_class.__name__}") - - if schema.schema_type == SchemaType.XML and schema.xml_schema: - self.xml_tools[schema.xml_schema.tag_name] = { - "instance": tool_instance, - "method": func_name, - "schema": schema - } - registered_xml += 1 - logger.debug(f"Registered XML tag {schema.xml_schema.tag_name} -> {func_name} from {tool_class.__name__}") - - logger.debug(f"Tool registration complete for {tool_class.__name__}: {registered_openapi} OpenAPI functions, {registered_xml} XML tags") - - def get_available_functions(self) -> Dict[str, Callable]: - """Get all available tool functions. - - Returns: - Dict mapping function names to their implementations - """ - available_functions = {} - - # Get OpenAPI tool functions - for tool_name, tool_info in self.tools.items(): - tool_instance = tool_info['instance'] - function_name = tool_name - function = getattr(tool_instance, function_name) - available_functions[function_name] = function - - # Get XML tool functions - for tag_name, tool_info in self.xml_tools.items(): - tool_instance = tool_info['instance'] - method_name = tool_info['method'] - function = getattr(tool_instance, method_name) - available_functions[method_name] = function - - logger.debug(f"Retrieved {len(available_functions)} available functions") - return available_functions - - def get_tool(self, tool_name: str) -> Dict[str, Any]: - """Get a specific tool by name. - - Args: - tool_name: Name of the tool function - - Returns: - Dict containing tool instance and schema, or empty dict if not found - """ - tool = self.tools.get(tool_name, {}) - if not tool: - logger.warning(f"Tool not found: {tool_name}") - return tool - - def get_xml_tool(self, tag_name: str) -> Dict[str, Any]: - """Get tool info by XML tag name. - - Args: - tag_name: XML tag name for the tool - - Returns: - Dict containing tool instance, method name, and schema - """ - tool = self.xml_tools.get(tag_name, {}) - if not tool: - logger.warning(f"XML tool not found for tag: {tag_name}") - return tool - - def get_openapi_schemas(self) -> List[Dict[str, Any]]: - """Get OpenAPI schemas for function calling. - - Returns: - List of OpenAPI-compatible schema definitions - """ - schemas = [ - tool_info['schema'].schema - for tool_info in self.tools.values() - if tool_info['schema'].schema_type == SchemaType.OPENAPI - ] - logger.debug(f"Retrieved {len(schemas)} OpenAPI schemas") - return schemas - - def get_xml_examples(self) -> Dict[str, str]: - """Get all XML tag examples. - - Returns: - Dict mapping tag names to their example usage - """ - examples = {} - for tool_info in self.xml_tools.values(): - schema = tool_info['schema'] - if schema.xml_schema and schema.xml_schema.example: - examples[schema.xml_schema.tag_name] = schema.xml_schema.example - logger.debug(f"Retrieved {len(examples)} XML examples") - return examples diff --git a/app/agentpress/utils/__init__.py b/app/agentpress/utils/__init__.py deleted file mode 100644 index e0dababfd..000000000 --- a/app/agentpress/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Utils module for AgentPress \ No newline at end of file diff --git a/app/agentpress/utils/json_helpers.py b/app/agentpress/utils/json_helpers.py deleted file mode 100644 index 3bda9092a..000000000 --- a/app/agentpress/utils/json_helpers.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -JSON helper utilities for handling both legacy (string) and new (dict/list) formats. - -These utilities help with the transition from storing JSON as strings to storing -them as proper JSONB objects in the database. -""" - -import json -from typing import Any, Union, Dict, List - - -def ensure_dict(value: Union[str, Dict[str, Any], None], default: Dict[str, Any] = None) -> Dict[str, Any]: - """ - Ensure a value is a dictionary. - - Handles: - - None -> returns default or {} - - Dict -> returns as-is - - JSON string -> parses and returns dict - - Other -> returns default or {} - - Args: - value: The value to ensure is a dict - default: Default value if conversion fails - - Returns: - A dictionary - """ - if default is None: - default = {} - - if value is None: - return default - - if isinstance(value, dict): - return value - - if isinstance(value, str): - try: - parsed = json.loads(value) - if isinstance(parsed, dict): - return parsed - return default - except (json.JSONDecodeError, TypeError): - return default - - return default - - -def ensure_list(value: Union[str, List[Any], None], default: List[Any] = None) -> List[Any]: - """ - Ensure a value is a list. - - Handles: - - None -> returns default or [] - - List -> returns as-is - - JSON string -> parses and returns list - - Other -> returns default or [] - - Args: - value: The value to ensure is a list - default: Default value if conversion fails - - Returns: - A list - """ - if default is None: - default = [] - - if value is None: - return default - - if isinstance(value, list): - return value - - if isinstance(value, str): - try: - parsed = json.loads(value) - if isinstance(parsed, list): - return parsed - return default - except (json.JSONDecodeError, TypeError): - return default - - return default - - -def safe_json_parse(value: Union[str, Dict, List, Any], default: Any = None) -> Any: - """ - Safely parse a value that might be JSON string or already parsed. - - This handles the transition period where some data might be stored as - JSON strings (old format) and some as proper objects (new format). - - Args: - value: The value to parse - default: Default value if parsing fails - - Returns: - Parsed value or default - """ - if value is None: - return default - - # If it's already a dict or list, return as-is - if isinstance(value, (dict, list)): - return value - - # If it's a string, try to parse it - if isinstance(value, str): - try: - return json.loads(value) - except (json.JSONDecodeError, TypeError): - # If it's not valid JSON, return the string itself - return value - - # For any other type, return as-is - return value - - -def to_json_string(value: Any) -> str: - """ - Convert a value to a JSON string if needed. - - This is used for backwards compatibility when yielding data that - expects JSON strings. - - Args: - value: The value to convert - - Returns: - JSON string representation - """ - if isinstance(value, str): - # If it's already a string, check if it's valid JSON - try: - json.loads(value) - return value # It's already a JSON string - except (json.JSONDecodeError, TypeError): - # It's a plain string, encode it as JSON - return json.dumps(value) - - # For all other types, convert to JSON - return json.dumps(value) - - -def format_for_yield(message_object: Dict[str, Any]) -> Dict[str, Any]: - """ - Format a message object for yielding, ensuring content and metadata are JSON strings. - - This maintains backward compatibility with clients expecting JSON strings - while the database now stores proper objects. - - Args: - message_object: The message object from the database - - Returns: - Message object with content and metadata as JSON strings - """ - if not message_object: - return message_object - - # Create a copy to avoid modifying the original - formatted = message_object.copy() - - # Ensure content is a JSON string - if 'content' in formatted and not isinstance(formatted['content'], str): - formatted['content'] = json.dumps(formatted['content']) - - # Ensure metadata is a JSON string - if 'metadata' in formatted and not isinstance(formatted['metadata'], str): - formatted['metadata'] = json.dumps(formatted['metadata']) - - return formatted \ No newline at end of file diff --git a/app/agentpress/xml_tool_parser.py b/app/agentpress/xml_tool_parser.py deleted file mode 100644 index e882a66f0..000000000 --- a/app/agentpress/xml_tool_parser.py +++ /dev/null @@ -1,300 +0,0 @@ -""" -XML Tool Call Parser Module - -This module provides a reliable XML tool call parsing system that supports -the Cursor-style format with structured function_calls blocks. -""" - -import re -import xml.etree.ElementTree as ET -from typing import List, Dict, Any, Optional, Tuple -from dataclasses import dataclass -import json -import logging - -logger = logging.getLogger(__name__) - - -@dataclass -class XMLToolCall: - """Represents a parsed XML tool call.""" - function_name: str - parameters: Dict[str, Any] - raw_xml: str - parsing_details: Dict[str, Any] - - -class XMLToolParser: - """ - Parser for XML tool calls using the Cursor-style format: - - - - param_value - ... - - - """ - - # Regex patterns for extracting XML blocks - FUNCTION_CALLS_PATTERN = re.compile( - r'(.*?)', - re.DOTALL | re.IGNORECASE - ) - - INVOKE_PATTERN = re.compile( - r'(.*?)', - re.DOTALL | re.IGNORECASE - ) - - PARAMETER_PATTERN = re.compile( - r'(.*?)', - re.DOTALL | re.IGNORECASE - ) - - def __init__(self, strict_mode: bool = False): - """ - Initialize the XML tool parser. - - Args: - strict_mode: If True, only accept the exact format. If False, - also try to parse legacy formats for backwards compatibility. - """ - self.strict_mode = strict_mode - - def parse_content(self, content: str) -> List[XMLToolCall]: - """ - Parse XML tool calls from content. - - Args: - content: The text content potentially containing XML tool calls - - Returns: - List of parsed XMLToolCall objects - """ - tool_calls = [] - - # First, try to find function_calls blocks - function_calls_matches = self.FUNCTION_CALLS_PATTERN.findall(content) - - for fc_content in function_calls_matches: - # Find all invoke blocks within this function_calls block - invoke_matches = self.INVOKE_PATTERN.findall(fc_content) - - for function_name, invoke_content in invoke_matches: - try: - tool_call = self._parse_invoke_block( - function_name, - invoke_content, - fc_content - ) - if tool_call: - tool_calls.append(tool_call) - except Exception as e: - logger.error(f"Error parsing invoke block for {function_name}: {e}") - - # If not in strict mode and no tool calls found, try legacy format - if not self.strict_mode and not tool_calls: - tool_calls.extend(self._parse_legacy_format(content)) - - return tool_calls - - def _parse_invoke_block( - self, - function_name: str, - invoke_content: str, - full_block: str - ) -> Optional[XMLToolCall]: - """Parse a single invoke block into an XMLToolCall.""" - parameters = {} - parsing_details = { - "format": "v2", - "function_name": function_name, - "raw_parameters": {} - } - - # Extract all parameters - param_matches = self.PARAMETER_PATTERN.findall(invoke_content) - - for param_name, param_value in param_matches: - # Clean up the parameter value - param_value = param_value.strip() - - # Try to parse as JSON if it looks like JSON - parsed_value = self._parse_parameter_value(param_value) - - parameters[param_name] = parsed_value - parsing_details["raw_parameters"][param_name] = param_value - - # Extract the raw XML for this specific invoke - invoke_pattern = re.compile( - rf'.*?', - re.DOTALL | re.IGNORECASE - ) - raw_xml_match = invoke_pattern.search(full_block) - raw_xml = raw_xml_match.group(0) if raw_xml_match else f"..." - - return XMLToolCall( - function_name=function_name, - parameters=parameters, - raw_xml=raw_xml, - parsing_details=parsing_details - ) - - def _parse_parameter_value(self, value: str) -> Any: - """ - Parse a parameter value, attempting to convert to appropriate type. - - Args: - value: The string value to parse - - Returns: - Parsed value (could be dict, list, bool, int, float, or str) - """ - value = value.strip() - - # Try to parse as JSON first - if value.startswith(('{', '[')): - try: - return json.loads(value) - except json.JSONDecodeError: - pass - - # Try to parse as boolean - if value.lower() in ('true', 'false'): - return value.lower() == 'true' - - # Try to parse as number - try: - if '.' in value: - return float(value) - else: - return int(value) - except ValueError: - pass - - # Return as string - return value - - def _parse_legacy_format(self, content: str) -> List[XMLToolCall]: - """ - Parse legacy XML tool formats for backwards compatibility. - This handles formats like ... or - ... - """ - tool_calls = [] - - # Pattern for finding XML-like tags - tag_pattern = re.compile(r'<([a-zA-Z][\w\-]*)((?:\s+[\w\-]+=["\'][^"\']*["\'])*)\s*>(.*?)', re.DOTALL) - - for match in tag_pattern.finditer(content): - tag_name = match.group(1) - attributes_str = match.group(2) - inner_content = match.group(3) - - # Skip our own format tags - if tag_name in ('function_calls', 'invoke', 'parameter'): - continue - - parameters = {} - parsing_details = { - "format": "legacy", - "tag_name": tag_name, - "attributes": {}, - "inner_content": inner_content.strip() - } - - # Parse attributes - if attributes_str: - attr_pattern = re.compile(r'([\w\-]+)=["\']([^"\']*)["\']') - for attr_match in attr_pattern.finditer(attributes_str): - attr_name = attr_match.group(1) - attr_value = attr_match.group(2) - parameters[attr_name] = self._parse_parameter_value(attr_value) - parsing_details["attributes"][attr_name] = attr_value - - # If there's inner content and no attributes, use it as a 'content' parameter - if inner_content.strip() and not parameters: - parameters['content'] = inner_content.strip() - - # Convert tag name to function name (e.g., create-file -> create_file) - function_name = tag_name.replace('-', '_') - - tool_calls.append(XMLToolCall( - function_name=function_name, - parameters=parameters, - raw_xml=match.group(0), - parsing_details=parsing_details - )) - - return tool_calls - - def format_tool_call(self, function_name: str, parameters: Dict[str, Any]) -> str: - """ - Format a tool call in the Cursor-style XML format. - - Args: - function_name: Name of the function to call - parameters: Dictionary of parameters - - Returns: - Formatted XML string - """ - lines = ['', ''.format(function_name)] - - for param_name, param_value in parameters.items(): - # Convert value to string representation - if isinstance(param_value, (dict, list)): - value_str = json.dumps(param_value) - elif isinstance(param_value, bool): - value_str = str(param_value).lower() - else: - value_str = str(param_value) - - lines.append('{}'.format( - param_name, value_str - )) - - lines.extend(['', '']) - return '\n'.join(lines) - - def validate_tool_call(self, tool_call: XMLToolCall, expected_params: Optional[Dict[str, type]] = None) -> Tuple[bool, Optional[str]]: - """ - Validate a tool call against expected parameters. - - Args: - tool_call: The XMLToolCall to validate - expected_params: Optional dict of parameter names to expected types - - Returns: - Tuple of (is_valid, error_message) - """ - if not tool_call.function_name: - return False, "Function name is required" - - if expected_params: - for param_name, expected_type in expected_params.items(): - if param_name not in tool_call.parameters: - return False, f"Missing required parameter: {param_name}" - - param_value = tool_call.parameters[param_name] - if not isinstance(param_value, expected_type): - return False, f"Parameter {param_name} should be of type {expected_type.__name__}" - - return True, None - - -# Convenience function for quick parsing -def parse_xml_tool_calls(content: str, strict_mode: bool = False) -> List[XMLToolCall]: - """ - Parse XML tool calls from content. - - Args: - content: The text content potentially containing XML tool calls - strict_mode: If True, only accept the Cursor-style format - - Returns: - List of parsed XMLToolCall objects - """ - parser = XMLToolParser(strict_mode=strict_mode) - return parser.parse_content(content) \ No newline at end of file diff --git a/app/daytona/api.py b/app/daytona/api.py index ccc592677..8f90bb53d 100644 --- a/app/daytona/api.py +++ b/app/daytona/api.py @@ -2,7 +2,16 @@ import urllib.parse from typing import Optional -from fastapi import FastAPI, UploadFile, File, HTTPException, APIRouter, Form, Depends, Request +from fastapi import ( + FastAPI, + UploadFile, + File, + HTTPException, + APIRouter, + Form, + Depends, + Request, +) from fastapi.responses import Response from pydantic import BaseModel @@ -15,14 +24,17 @@ router = APIRouter(tags=["sandbox"]) db = None + def initialize(_db: DBConnection): """Initialize the sandbox API with resources from the main API.""" global db db = _db logger.info("Initialized sandbox API with database connection") + class FileInfo(BaseModel): """Model for file information""" + name: str path: str is_dir: bool @@ -30,243 +42,285 @@ class FileInfo(BaseModel): mod_time: str permissions: Optional[str] = None + def normalize_path(path: str) -> str: """ Normalize a path to ensure proper UTF-8 encoding and handling. - + Args: path: The file path, potentially containing URL-encoded characters - + Returns: Normalized path with proper UTF-8 encoding """ try: # First, ensure the path is properly URL-decoded decoded_path = urllib.parse.unquote(path) - + # Handle Unicode escape sequences like \u0308 try: # Replace Python-style Unicode escapes (\u0308) with actual characters # This handles cases where the Unicode escape sequence is part of the URL import re - unicode_pattern = re.compile(r'\\u([0-9a-fA-F]{4})') - + + unicode_pattern = re.compile(r"\\u([0-9a-fA-F]{4})") + def replace_unicode(match): hex_val = match.group(1) return chr(int(hex_val, 16)) - + decoded_path = unicode_pattern.sub(replace_unicode, decoded_path) except Exception as unicode_err: - logger.warning(f"Error processing Unicode escapes in path '{path}': {str(unicode_err)}") - + logger.warning( + f"Error processing Unicode escapes in path '{path}': {str(unicode_err)}" + ) + logger.debug(f"Normalized path from '{path}' to '{decoded_path}'") return decoded_path except Exception as e: logger.error(f"Error normalizing path '{path}': {str(e)}") return path # Return original path if decoding fails + async def verify_sandbox_access(client, sandbox_id: str, user_id: Optional[str] = None): """ Verify that a user has access to a specific sandbox based on account membership. - + Args: client: The Supabase client sandbox_id: The sandbox ID to check access for user_id: The user ID to check permissions for. Can be None for public resource access. - + Returns: dict: Project data containing sandbox information - + Raises: HTTPException: If the user doesn't have access to the sandbox or sandbox doesn't exist """ # Find the project that owns this sandbox - project_result = await client.table('projects').select('*').filter('sandbox->>id', 'eq', sandbox_id).execute() - + project_result = ( + await client.table("projects") + .select("*") + .filter("sandbox->>id", "eq", sandbox_id) + .execute() + ) + if not project_result.data or len(project_result.data) == 0: raise HTTPException(status_code=404, detail="Sandbox not found") - + project_data = project_result.data[0] - if project_data.get('is_public'): + if project_data.get("is_public"): return project_data - + # For private projects, we must have a user_id if not user_id: - raise HTTPException(status_code=401, detail="Authentication required for this resource") - - account_id = project_data.get('account_id') - + raise HTTPException( + status_code=401, detail="Authentication required for this resource" + ) + + account_id = project_data.get("account_id") + # Verify account membership if account_id: - account_user_result = await client.schema('basejump').from_('account_user').select('account_role').eq('user_id', user_id).eq('account_id', account_id).execute() + account_user_result = ( + await client.schema("basejump") + .from_("account_user") + .select("account_role") + .eq("user_id", user_id) + .eq("account_id", account_id) + .execute() + ) if account_user_result.data and len(account_user_result.data) > 0: return project_data - + raise HTTPException(status_code=403, detail="Not authorized to access this sandbox") + async def get_sandbox_by_id_safely(client, sandbox_id: str): """ Safely retrieve a sandbox object by its ID, using the project that owns it. - + Args: client: The Supabase client sandbox_id: The sandbox ID to retrieve - + Returns: Sandbox: The sandbox object - + Raises: HTTPException: If the sandbox doesn't exist or can't be retrieved """ # Find the project that owns this sandbox - project_result = await client.table('projects').select('project_id').filter('sandbox->>id', 'eq', sandbox_id).execute() - + project_result = ( + await client.table("projects") + .select("project_id") + .filter("sandbox->>id", "eq", sandbox_id) + .execute() + ) + if not project_result.data or len(project_result.data) == 0: logger.error(f"No project found for sandbox ID: {sandbox_id}") - raise HTTPException(status_code=404, detail="Sandbox not found - no project owns this sandbox ID") - + raise HTTPException( + status_code=404, + detail="Sandbox not found - no project owns this sandbox ID", + ) + # project_id = project_result.data[0]['project_id'] # logger.debug(f"Found project {project_id} for sandbox {sandbox_id}") - + try: # Get the sandbox sandbox = await get_or_start_sandbox(sandbox_id) # Extract just the sandbox object from the tuple (sandbox, sandbox_id, sandbox_pass) # sandbox = sandbox_tuple[0] - + return sandbox except Exception as e: logger.error(f"Error retrieving sandbox {sandbox_id}: {str(e)}") - raise HTTPException(status_code=500, detail=f"Failed to retrieve sandbox: {str(e)}") + raise HTTPException( + status_code=500, detail=f"Failed to retrieve sandbox: {str(e)}" + ) + @router.post("/sandboxes/{sandbox_id}/files") async def create_file( - sandbox_id: str, + sandbox_id: str, path: str = Form(...), file: UploadFile = File(...), request: Request = None, - user_id: Optional[str] = Depends(get_optional_user_id) + user_id: Optional[str] = Depends(get_optional_user_id), ): """Create a file in the sandbox using direct file upload""" # Normalize the path to handle UTF-8 encoding correctly path = normalize_path(path) - - logger.info(f"Received file upload request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}") + + logger.info( + f"Received file upload request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}" + ) client = await db.client - + # Verify the user has access to this sandbox await verify_sandbox_access(client, sandbox_id, user_id) - + try: # Get sandbox using the safer method sandbox = await get_sandbox_by_id_safely(client, sandbox_id) - + # Read file content directly from the uploaded file content = await file.read() - + # Create file using raw binary content sandbox.fs.upload_file(content, path) logger.info(f"File created at {path} in sandbox {sandbox_id}") - + return {"status": "success", "created": True, "path": path} except Exception as e: logger.error(f"Error creating file in sandbox {sandbox_id}: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) + @router.get("/sandboxes/{sandbox_id}/files") async def list_files( - sandbox_id: str, + sandbox_id: str, path: str, request: Request = None, - user_id: Optional[str] = Depends(get_optional_user_id) + user_id: Optional[str] = Depends(get_optional_user_id), ): """List files and directories at the specified path""" # Normalize the path to handle UTF-8 encoding correctly path = normalize_path(path) - - logger.info(f"Received list files request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}") + + logger.info( + f"Received list files request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}" + ) client = await db.client - + # Verify the user has access to this sandbox await verify_sandbox_access(client, sandbox_id, user_id) - + try: # Get sandbox using the safer method sandbox = await get_sandbox_by_id_safely(client, sandbox_id) - + # List files files = sandbox.fs.list_files(path) result = [] - + for file in files: # Convert file information to our model # Ensure forward slashes are used for paths, regardless of OS - full_path = f"{path.rstrip('/')}/{file.name}" if path != '/' else f"/{file.name}" + full_path = ( + f"{path.rstrip('/')}/{file.name}" if path != "/" else f"/{file.name}" + ) file_info = FileInfo( name=file.name, - path=full_path, # Use the constructed path + path=full_path, # Use the constructed path is_dir=file.is_dir, size=file.size, mod_time=str(file.mod_time), - permissions=getattr(file, 'permissions', None) + permissions=getattr(file, "permissions", None), ) result.append(file_info) - + logger.info(f"Successfully listed {len(result)} files in sandbox {sandbox_id}") return {"files": [file.dict() for file in result]} except Exception as e: logger.error(f"Error listing files in sandbox {sandbox_id}: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) + @router.get("/sandboxes/{sandbox_id}/files/content") async def read_file( - sandbox_id: str, + sandbox_id: str, path: str, request: Request = None, - user_id: Optional[str] = Depends(get_optional_user_id) + user_id: Optional[str] = Depends(get_optional_user_id), ): """Read a file from the sandbox""" # Normalize the path to handle UTF-8 encoding correctly original_path = path path = normalize_path(path) - - logger.info(f"Received file read request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}") + + logger.info( + f"Received file read request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}" + ) if original_path != path: logger.info(f"Normalized path from '{original_path}' to '{path}'") - + client = await db.client - + # Verify the user has access to this sandbox await verify_sandbox_access(client, sandbox_id, user_id) - + try: # Get sandbox using the safer method sandbox = await get_sandbox_by_id_safely(client, sandbox_id) - + # Read file directly - don't check existence first with a separate call try: content = sandbox.fs.download_file(path) except Exception as download_err: - logger.error(f"Error downloading file {path} from sandbox {sandbox_id}: {str(download_err)}") + logger.error( + f"Error downloading file {path} from sandbox {sandbox_id}: {str(download_err)}" + ) raise HTTPException( - status_code=404, - detail=f"Failed to download file: {str(download_err)}" + status_code=404, detail=f"Failed to download file: {str(download_err)}" ) - + # Return a Response object with the content directly filename = os.path.basename(path) logger.info(f"Successfully read file {filename} from sandbox {sandbox_id}") - + # Ensure proper encoding by explicitly using UTF-8 for the filename in Content-Disposition header # This applies RFC 5987 encoding for the filename to support non-ASCII characters - encoded_filename = filename.encode('utf-8').decode('latin-1') + encoded_filename = filename.encode("utf-8").decode("latin-1") content_disposition = f"attachment; filename*=UTF-8''{encoded_filename}" - + return Response( content=content, media_type="application/octet-stream", - headers={"Content-Disposition": content_disposition} + headers={"Content-Disposition": content_disposition}, ) except HTTPException: # Re-raise HTTP exceptions without wrapping @@ -275,116 +329,149 @@ async def read_file( logger.error(f"Error reading file in sandbox {sandbox_id}: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) + @router.delete("/sandboxes/{sandbox_id}/files") async def delete_file( - sandbox_id: str, + sandbox_id: str, path: str, request: Request = None, - user_id: Optional[str] = Depends(get_optional_user_id) + user_id: Optional[str] = Depends(get_optional_user_id), ): """Delete a file from the sandbox""" # Normalize the path to handle UTF-8 encoding correctly path = normalize_path(path) - - logger.info(f"Received file delete request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}") + + logger.info( + f"Received file delete request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}" + ) client = await db.client - + # Verify the user has access to this sandbox await verify_sandbox_access(client, sandbox_id, user_id) - + try: # Get sandbox using the safer method sandbox = await get_sandbox_by_id_safely(client, sandbox_id) - + # Delete file sandbox.fs.delete_file(path) logger.info(f"File deleted at {path} in sandbox {sandbox_id}") - + return {"status": "success", "deleted": True, "path": path} except Exception as e: logger.error(f"Error deleting file in sandbox {sandbox_id}: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) + @router.delete("/sandboxes/{sandbox_id}") async def delete_sandbox_route( sandbox_id: str, request: Request = None, - user_id: Optional[str] = Depends(get_optional_user_id) + user_id: Optional[str] = Depends(get_optional_user_id), ): """Delete an entire sandbox""" - logger.info(f"Received sandbox delete request for sandbox {sandbox_id}, user_id: {user_id}") + logger.info( + f"Received sandbox delete request for sandbox {sandbox_id}, user_id: {user_id}" + ) client = await db.client - + # Verify the user has access to this sandbox await verify_sandbox_access(client, sandbox_id, user_id) - + try: # Delete the sandbox using the sandbox module function await delete_sandbox(sandbox_id) - + return {"status": "success", "deleted": True, "sandbox_id": sandbox_id} except Exception as e: logger.error(f"Error deleting sandbox {sandbox_id}: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) + # Should happen on server-side fully @router.post("/project/{project_id}/sandbox/ensure-active") async def ensure_project_sandbox_active( project_id: str, request: Request = None, - user_id: Optional[str] = Depends(get_optional_user_id) + user_id: Optional[str] = Depends(get_optional_user_id), ): """ Ensure that a project's sandbox is active and running. Checks the sandbox status and starts it if it's not running. """ - logger.info(f"Received ensure sandbox active request for project {project_id}, user_id: {user_id}") + logger.info( + f"Received ensure sandbox active request for project {project_id}, user_id: {user_id}" + ) client = await db.client - + # Find the project and sandbox information - project_result = await client.table('projects').select('*').eq('project_id', project_id).execute() - + project_result = ( + await client.table("projects") + .select("*") + .eq("project_id", project_id) + .execute() + ) + if not project_result.data or len(project_result.data) == 0: logger.error(f"Project not found: {project_id}") raise HTTPException(status_code=404, detail="Project not found") - + project_data = project_result.data[0] - + # For public projects, no authentication is needed - if not project_data.get('is_public'): + if not project_data.get("is_public"): # For private projects, we must have a user_id if not user_id: logger.error(f"Authentication required for private project {project_id}") - raise HTTPException(status_code=401, detail="Authentication required for this resource") - - account_id = project_data.get('account_id') - + raise HTTPException( + status_code=401, detail="Authentication required for this resource" + ) + + account_id = project_data.get("account_id") + # Verify account membership if account_id: - account_user_result = await client.schema('basejump').from_('account_user').select('account_role').eq('user_id', user_id).eq('account_id', account_id).execute() + account_user_result = ( + await client.schema("basejump") + .from_("account_user") + .select("account_role") + .eq("user_id", user_id) + .eq("account_id", account_id) + .execute() + ) if not (account_user_result.data and len(account_user_result.data) > 0): - logger.error(f"User {user_id} not authorized to access project {project_id}") - raise HTTPException(status_code=403, detail="Not authorized to access this project") - + logger.error( + f"User {user_id} not authorized to access project {project_id}" + ) + raise HTTPException( + status_code=403, detail="Not authorized to access this project" + ) + try: # Get sandbox ID from project data - sandbox_info = project_data.get('sandbox', {}) - if not sandbox_info.get('id'): - raise HTTPException(status_code=404, detail="No sandbox found for this project") - - sandbox_id = sandbox_info['id'] - + sandbox_info = project_data.get("sandbox", {}) + if not sandbox_info.get("id"): + raise HTTPException( + status_code=404, detail="No sandbox found for this project" + ) + + sandbox_id = sandbox_info["id"] + # Get or start the sandbox logger.info(f"Ensuring sandbox is active for project {project_id}") sandbox = await get_or_start_sandbox(sandbox_id) - - logger.info(f"Successfully ensured sandbox {sandbox_id} is active for project {project_id}") - + + logger.info( + f"Successfully ensured sandbox {sandbox_id} is active for project {project_id}" + ) + return { - "status": "success", + "status": "success", "sandbox_id": sandbox_id, - "message": "Sandbox is active" + "message": "Sandbox is active", } except Exception as e: - logger.error(f"Error ensuring sandbox is active for project {project_id}: {str(e)}") + logger.error( + f"Error ensuring sandbox is active for project {project_id}: {str(e)}" + ) raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/daytona/docker/Dockerfile b/app/daytona/docker/Dockerfile deleted file mode 100644 index d2f12ff16..000000000 --- a/app/daytona/docker/Dockerfile +++ /dev/null @@ -1,133 +0,0 @@ -FROM python:3.11-slim - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - wget \ - netcat-traditional \ - gnupg \ - curl \ - unzip \ - zip \ - xvfb \ - libgconf-2-4 \ - libxss1 \ - libnss3 \ - libnspr4 \ - libasound2 \ - libatk1.0-0 \ - libatk-bridge2.0-0 \ - libcups2 \ - libdbus-1-3 \ - libdrm2 \ - libgbm1 \ - libgtk-3-0 \ - libxcomposite1 \ - libxdamage1 \ - libxfixes3 \ - libxrandr2 \ - xdg-utils \ - fonts-liberation \ - dbus \ - xauth \ - xvfb \ - x11vnc \ - tigervnc-tools \ - supervisor \ - net-tools \ - procps \ - git \ - python3-numpy \ - fontconfig \ - fonts-dejavu \ - fonts-dejavu-core \ - fonts-dejavu-extra \ - tmux \ - # PDF Processing Tools - poppler-utils \ - wkhtmltopdf \ - # Document Processing Tools - antiword \ - unrtf \ - catdoc \ - # Text Processing Tools - grep \ - gawk \ - sed \ - # File Analysis Tools - file \ - # Data Processing Tools - jq \ - csvkit \ - xmlstarlet \ - # Additional Utilities - less \ - vim \ - tree \ - rsync \ - lsof \ - iputils-ping \ - dnsutils \ - sudo \ - # OCR Tools - tesseract-ocr \ - tesseract-ocr-eng \ - && rm -rf /var/lib/apt/lists/* - -# Install Node.js and npm -RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ - && apt-get install -y nodejs \ - && npm install -g npm@latest - -# Install Cloudflare Wrangler CLI globally -RUN npm install -g wrangler - -# Install noVNC -RUN git clone https://github.com/novnc/noVNC.git /opt/novnc \ - && git clone https://github.com/novnc/websockify /opt/novnc/utils/websockify \ - && ln -s /opt/novnc/vnc.html /opt/novnc/index.html - -# Set platform for ARM64 compatibility -ARG TARGETPLATFORM=linux/amd64 - -# Set up working directory -WORKDIR /app - -# Copy requirements and install Python dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Install Playwright and browsers with system dependencies -ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright -# Install Playwright package first -RUN pip install playwright -# Then install dependencies and browsers -RUN playwright install-deps -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 -ENV ANONYMIZED_TELEMETRY=false -ENV DISPLAY=:99 -ENV RESOLUTION=1024x768x24 -ENV VNC_PASSWORD=vncpassword -ENV CHROME_PERSISTENT_SESSION=true -ENV RESOLUTION_WIDTH=1024 -ENV RESOLUTION_HEIGHT=768 -# Add Chrome flags to prevent multiple tabs/windows -ENV CHROME_FLAGS="--single-process --no-first-run --no-default-browser-check --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-component-extensions-with-background-pages --disable-dev-shm-usage --disable-extensions --disable-features=TranslateUI --disable-ipc-flooding-protection --disable-renderer-backgrounding --enable-features=NetworkServiceInProcess2 --force-color-profile=srgb --metrics-recording-only --mute-audio --no-sandbox --disable-gpu" - -# Set up supervisor configuration -RUN mkdir -p /var/log/supervisor -COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf - -EXPOSE 7788 6080 5901 8000 8080 - -CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] \ No newline at end of file diff --git a/app/daytona/docker/browser_api.py b/app/daytona/docker/browser_api.py deleted file mode 100644 index 7456b8483..000000000 --- a/app/daytona/docker/browser_api.py +++ /dev/null @@ -1,2110 +0,0 @@ -from fastapi import FastAPI, APIRouter, HTTPException, Body -from playwright.async_api import async_playwright, Browser, BrowserContext, Page -from pydantic import BaseModel -from typing import Optional, List, Dict, Any -import asyncio -import json -import logging -import base64 -from dataclasses import dataclass, field -from datetime import datetime -import os -import random -from functools import cached_property -import traceback -import pytesseract -from PIL import Image -import io - -####################################################### -# Action model definitions -####################################################### - -class Position(BaseModel): - x: int - y: int - -class ClickElementAction(BaseModel): - index: int - -class ClickCoordinatesAction(BaseModel): - x: int - y: int - -class GoToUrlAction(BaseModel): - url: str - -class InputTextAction(BaseModel): - index: int - text: str - -class ScrollAction(BaseModel): - amount: Optional[int] = None - -class SendKeysAction(BaseModel): - keys: str - -class SearchGoogleAction(BaseModel): - query: str - -class SwitchTabAction(BaseModel): - page_id: int - -class OpenTabAction(BaseModel): - url: str - -class CloseTabAction(BaseModel): - page_id: int - -class NoParamsAction(BaseModel): - pass - -class DragDropAction(BaseModel): - element_source: Optional[str] = None - element_target: Optional[str] = None - element_source_offset: Optional[Position] = None - element_target_offset: Optional[Position] = None - coord_source_x: Optional[int] = None - coord_source_y: Optional[int] = None - coord_target_x: Optional[int] = None - coord_target_y: Optional[int] = None - steps: Optional[int] = 10 - delay_ms: Optional[int] = 5 - -class DoneAction(BaseModel): - success: bool = True - text: str = "" - -####################################################### -# DOM Structure Models -####################################################### - -@dataclass -class CoordinateSet: - x: int = 0 - y: int = 0 - width: int = 0 - height: int = 0 - -@dataclass -class ViewportInfo: - width: int = 0 - height: int = 0 - scroll_x: int = 0 - scroll_y: int = 0 - -@dataclass -class HashedDomElement: - tag_name: str - attributes: Dict[str, str] - is_visible: bool - page_coordinates: Optional[CoordinateSet] = None - -@dataclass -class DOMBaseNode: - is_visible: bool - parent: Optional['DOMElementNode'] = None - -@dataclass -class DOMTextNode(DOMBaseNode): - text: str = field(default="") - type: str = 'TEXT_NODE' - - def has_parent_with_highlight_index(self) -> bool: - current = self.parent - while current is not None: - if current.highlight_index is not None: - return True - current = current.parent - return False - -@dataclass -class DOMElementNode(DOMBaseNode): - tag_name: str = field(default="") - xpath: str = field(default="") - attributes: Dict[str, str] = field(default_factory=dict) - children: List['DOMBaseNode'] = field(default_factory=list) - - is_interactive: bool = False - is_top_element: bool = False - is_in_viewport: bool = False - shadow_root: bool = False - highlight_index: Optional[int] = None - viewport_coordinates: Optional[CoordinateSet] = None - page_coordinates: Optional[CoordinateSet] = None - viewport_info: Optional[ViewportInfo] = None - - def __repr__(self) -> str: - tag_str = f'<{self.tag_name}' - for key, value in self.attributes.items(): - tag_str += f' {key}="{value}"' - tag_str += '>' - - extras = [] - if self.is_interactive: - extras.append('interactive') - if self.is_top_element: - extras.append('top') - if self.highlight_index is not None: - extras.append(f'highlight:{self.highlight_index}') - - if extras: - tag_str += f' [{", ".join(extras)}]' - - return tag_str - - @cached_property - def hash(self) -> HashedDomElement: - return HashedDomElement( - tag_name=self.tag_name, - attributes=self.attributes, - is_visible=self.is_visible, - page_coordinates=self.page_coordinates - ) - - def get_all_text_till_next_clickable_element(self, max_depth: int = -1) -> str: - text_parts = [] - - def collect_text(node: DOMBaseNode, current_depth: int) -> None: - if max_depth != -1 and current_depth > max_depth: - return - - if isinstance(node, DOMElementNode) and node != self and node.highlight_index is not None: - return - - if isinstance(node, DOMTextNode): - text_parts.append(node.text) - elif isinstance(node, DOMElementNode): - for child in node.children: - collect_text(child, current_depth + 1) - - collect_text(self, 0) - return '\n'.join(text_parts).strip() - - def clickable_elements_to_string(self, include_attributes: list[str] | None = None) -> str: - """Convert the processed DOM content to HTML.""" - formatted_text = [] - - def process_node(node: DOMBaseNode, depth: int) -> None: - if isinstance(node, DOMElementNode): - # Add element with highlight_index - if node.highlight_index is not None: - attributes_str = '' - text = node.get_all_text_till_next_clickable_element() - - # Process attributes for display - display_attributes = [] - if include_attributes: - for key, value in node.attributes.items(): - if key in include_attributes and value and value != node.tag_name: - if text and value in text: - continue # Skip if attribute value is already in the text - display_attributes.append(str(value)) - - attributes_str = ';'.join(display_attributes) - - # Build the element string - line = f'[{node.highlight_index}]<{node.tag_name}' - - # Add important attributes for identification - for attr_name in ['id', 'href', 'name', 'value', 'type']: - if attr_name in node.attributes and node.attributes[attr_name]: - line += f' {attr_name}="{node.attributes[attr_name]}"' - - # Add the text content if available - if text: - line += f'> {text}' - elif attributes_str: - line += f'> {attributes_str}' - else: - # If no text and no attributes, use the tag name - line += f'> {node.tag_name.upper()}' - - line += ' ' - formatted_text.append(line) - - # Process children regardless - for child in node.children: - process_node(child, depth + 1) - - elif isinstance(node, DOMTextNode): - # Add text only if it doesn't have a highlighted parent - if not node.has_parent_with_highlight_index() and node.is_visible: - if node.text and node.text.strip(): - formatted_text.append(node.text) - - process_node(self, 0) - result = '\n'.join(formatted_text) - return result if result.strip() else "No interactive elements found" - -@dataclass -class DOMState: - element_tree: DOMElementNode - selector_map: Dict[int, DOMElementNode] - url: str = "" - title: str = "" - pixels_above: int = 0 - pixels_below: int = 0 - -####################################################### -# Browser Action Result Model -####################################################### - -class BrowserActionResult(BaseModel): - success: bool = True - message: str = "" - error: str = "" - - # Extended state information - url: Optional[str] = None - title: Optional[str] = None - elements: Optional[str] = None # Formatted string of clickable elements - screenshot_base64: Optional[str] = None - pixels_above: int = 0 - pixels_below: int = 0 - content: Optional[str] = None - ocr_text: Optional[str] = None # Added field for OCR text - - # Additional metadata - element_count: int = 0 # Number of interactive elements found - interactive_elements: Optional[List[Dict[str, Any]]] = None # Simplified list of interactive elements - viewport_width: Optional[int] = None - viewport_height: Optional[int] = None - - class Config: - arbitrary_types_allowed = True - -####################################################### -# Browser Automation Implementation -####################################################### - -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") - self.include_attributes = ["id", "href", "src", "alt", "aria-label", "placeholder", "name", "role", "title", "value"] - self.screenshot_dir = os.path.join(os.getcwd(), "screenshots") - os.makedirs(self.screenshot_dir, exist_ok=True) - - # Register routes - self.router.on_startup.append(self.startup) - self.router.on_shutdown.append(self.shutdown) - - # Basic navigation - self.router.post("/automation/navigate_to")(self.navigate_to) - self.router.post("/automation/search_google")(self.search_google) - self.router.post("/automation/go_back")(self.go_back) - self.router.post("/automation/wait")(self.wait) - - # Element interaction - self.router.post("/automation/click_element")(self.click_element) - self.router.post("/automation/click_coordinates")(self.click_coordinates) - self.router.post("/automation/input_text")(self.input_text) - self.router.post("/automation/send_keys")(self.send_keys) - - # Tab management - self.router.post("/automation/switch_tab")(self.switch_tab) - self.router.post("/automation/open_tab")(self.open_tab) - self.router.post("/automation/close_tab")(self.close_tab) - - # Content actions - self.router.post("/automation/extract_content")(self.extract_content) - self.router.post("/automation/save_pdf")(self.save_pdf) - - # Scroll actions - self.router.post("/automation/scroll_down")(self.scroll_down) - self.router.post("/automation/scroll_up")(self.scroll_up) - self.router.post("/automation/scroll_to_text")(self.scroll_to_text) - - # Dropdown actions - self.router.post("/automation/get_dropdown_options")(self.get_dropdown_options) - self.router.post("/automation/select_dropdown_option")(self.select_dropdown_option) - - # Drag and drop - self.router.post("/automation/drag_drop")(self.drag_drop) - - async def startup(self): - """Initialize the browser instance on startup""" - try: - print("Starting browser initialization...") - playwright = await async_playwright().start() - print("Playwright started, launching browser...") - - # Use non-headless mode for testing with slower timeouts - launch_options = { - "headless": False, - "timeout": 60000 - } - - 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}") - # Try with minimal options - 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: - await self.get_current_page() - print("Found existing page, using it") - 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_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: - print(f"Browser startup error: {str(e)}") - traceback.print_exc() - raise RuntimeError(f"Browser initialization failed: {str(e)}") - - 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""" - if not self.pages: - raise HTTPException(status_code=500, detail="No browser pages available") - return self.pages[self.current_page_index] - - async def get_selector_map(self) -> Dict[int, DOMElementNode]: - """Get a map of selectable elements on the page""" - page = await self.get_current_page() - - # Create a selector map for interactive elements - selector_map = {} - - try: - # More comprehensive JavaScript to find interactive elements - elements_js = """ - (() => { - // Helper function to get all attributes as an object - function getAttributes(el) { - const attributes = {}; - for (const attr of el.attributes) { - attributes[attr.name] = attr.value; - } - return attributes; - } - - // Find all potentially interactive elements - const interactiveElements = Array.from(document.querySelectorAll( - 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [tabindex]:not([tabindex="-1"])' - )); - - // Filter for visible elements - const visibleElements = interactiveElements.filter(el => { - const style = window.getComputedStyle(el); - const rect = el.getBoundingClientRect(); - return style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - rect.width > 0 && - rect.height > 0; - }); - - // Map to our expected structure - return visibleElements.map((el, index) => { - const rect = el.getBoundingClientRect(); - const isInViewport = rect.top >= 0 && - rect.left >= 0 && - rect.bottom <= window.innerHeight && - rect.right <= window.innerWidth; - - return { - index: index + 1, - tagName: el.tagName.toLowerCase(), - text: el.innerText || el.value || '', - attributes: getAttributes(el), - isVisible: true, - isInteractive: true, - pageCoordinates: { - x: rect.left + window.scrollX, - y: rect.top + window.scrollY, - width: rect.width, - height: rect.height - }, - viewportCoordinates: { - x: rect.left, - y: rect.top, - width: rect.width, - height: rect.height - }, - isInViewport: isInViewport - }; - }); - })(); - """ - - elements = await page.evaluate(elements_js) - print(f"Found {len(elements)} interactive elements in selector map") - - # Create a root element for the tree - root = DOMElementNode( - is_visible=True, - tag_name="body", - is_interactive=False, - is_top_element=True - ) - - # Create element nodes for each element - for idx, el in enumerate(elements): - # Create coordinate sets - page_coordinates = None - viewport_coordinates = None - - if 'pageCoordinates' in el: - coords = el['pageCoordinates'] - page_coordinates = CoordinateSet( - x=coords.get('x', 0), - y=coords.get('y', 0), - width=coords.get('width', 0), - height=coords.get('height', 0) - ) - - if 'viewportCoordinates' in el: - coords = el['viewportCoordinates'] - viewport_coordinates = CoordinateSet( - x=coords.get('x', 0), - y=coords.get('y', 0), - width=coords.get('width', 0), - height=coords.get('height', 0) - ) - - # Create the element node - element_node = DOMElementNode( - is_visible=el.get('isVisible', True), - tag_name=el.get('tagName', 'div'), - attributes=el.get('attributes', {}), - is_interactive=el.get('isInteractive', True), - is_in_viewport=el.get('isInViewport', False), - highlight_index=el.get('index', idx + 1), - page_coordinates=page_coordinates, - viewport_coordinates=viewport_coordinates - ) - - # Add a text node if there's text content - if el.get('text'): - text_node = DOMTextNode(is_visible=True, text=el.get('text', '')) - text_node.parent = element_node - element_node.children.append(text_node) - - selector_map[el.get('index', idx + 1)] = element_node - root.children.append(element_node) - element_node.parent = root - - except Exception as e: - print(f"Error getting selector map: {e}") - traceback.print_exc() - # Create a dummy element to avoid breaking tests - dummy = DOMElementNode( - is_visible=True, - tag_name="a", - attributes={'href': '#'}, - is_interactive=True, - highlight_index=1 - ) - dummy_text = DOMTextNode(is_visible=True, text="Dummy Element") - dummy_text.parent = dummy - dummy.children.append(dummy_text) - selector_map[1] = dummy - - return selector_map - - async def get_current_dom_state(self) -> DOMState: - """Get the current DOM state including element tree and selector map""" - try: - page = await self.get_current_page() - selector_map = await self.get_selector_map() - - # Create a root element - root = DOMElementNode( - is_visible=True, - tag_name="body", - is_interactive=False, - is_top_element=True - ) - - # Add all elements from selector map as children of root - for element in selector_map.values(): - if element.parent is None: - element.parent = root - root.children.append(element) - - # Get basic page info - url = page.url - try: - title = await page.title() - except: - title = "Unknown Title" - - # Get more accurate scroll information - fix JavaScript syntax - try: - scroll_info = await page.evaluate(""" - () => { - const body = document.body; - const html = document.documentElement; - const totalHeight = Math.max( - body.scrollHeight, body.offsetHeight, - html.clientHeight, html.scrollHeight, html.offsetHeight - ); - const scrollY = window.scrollY || window.pageYOffset; - const windowHeight = window.innerHeight; - - return { - pixelsAbove: scrollY, - pixelsBelow: Math.max(0, totalHeight - scrollY - windowHeight), - totalHeight: totalHeight, - viewportHeight: windowHeight - }; - } - """) - pixels_above = scroll_info.get('pixelsAbove', 0) - pixels_below = scroll_info.get('pixelsBelow', 0) - except Exception as e: - print(f"Error getting scroll info: {e}") - pixels_above = 0 - pixels_below = 0 - - return DOMState( - element_tree=root, - selector_map=selector_map, - url=url, - title=title, - pixels_above=pixels_above, - pixels_below=pixels_below - ) - except Exception as e: - print(f"Error getting DOM state: {e}") - traceback.print_exc() - # Return a minimal valid state to avoid breaking tests - dummy_root = DOMElementNode( - is_visible=True, - tag_name="body", - is_interactive=False, - is_top_element=True - ) - dummy_map = {1: dummy_root} - current_url = "unknown" - try: - if 'page' in locals(): - current_url = page.url - except: - pass - return DOMState( - element_tree=dummy_root, - selector_map=dummy_map, - url=current_url, - title="Error page", - pixels_above=0, - pixels_below=0 - ) - - async def take_screenshot(self) -> str: - """Take a screenshot and return as base64 encoded string""" - try: - page = await self.get_current_page() - - # Wait for network to be idle and DOM to be stable - try: - await page.wait_for_load_state("networkidle", timeout=60000) # Increased timeout to 60s - except Exception as e: - print(f"Warning: Network idle timeout, proceeding anyway: {e}") - - # Wait for any animations to complete - # await page.wait_for_timeout(1000) # Wait 1 second for animations - - # Take screenshot with increased timeout and better options - screenshot_bytes = await page.screenshot( - type='jpeg', - quality=60, - full_page=False, - timeout=60000, # Increased timeout to 60s - scale='device' # Use device scale factor - ) - - return base64.b64encode(screenshot_bytes).decode('utf-8') - except Exception as e: - print(f"Error taking screenshot: {e}") - traceback.print_exc() - # Return an empty string rather than failing - return "" - - async def save_screenshot_to_file(self) -> str: - """Take a screenshot and save to file, returning the path""" - try: - page = await self.get_current_page() - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - random_id = random.randint(1000, 9999) - filename = f"screenshot_{timestamp}_{random_id}.jpg" - filepath = os.path.join(self.screenshot_dir, filename) - - await page.screenshot(path=filepath, type='jpeg', quality=60, full_page=False) - return filepath - except Exception as e: - print(f"Error saving screenshot: {e}") - return "" - - async def extract_ocr_text_from_screenshot(self, screenshot_base64: str) -> str: - """Extract text from screenshot using OCR""" - if not screenshot_base64: - return "" - - try: - # Decode base64 to image - image_bytes = base64.b64decode(screenshot_base64) - image = Image.open(io.BytesIO(image_bytes)) - - # Extract text using pytesseract - ocr_text = pytesseract.image_to_string(image) - - # Clean up the text - ocr_text = ocr_text.strip() - - return ocr_text - except Exception as e: - print(f"Error performing OCR: {e}") - traceback.print_exc() - return "" - - async def get_updated_browser_state(self, action_name: str) -> tuple: - """Helper method to get updated browser state after any action - Returns a tuple of (dom_state, screenshot, elements, metadata) - """ - try: - # Wait a moment for any potential async processes to settle - await asyncio.sleep(0.5) - - # Get updated state - dom_state = await self.get_current_dom_state() - screenshot = await self.take_screenshot() - - # Format elements for output - elements = dom_state.element_tree.clickable_elements_to_string( - include_attributes=self.include_attributes - ) - - # Collect additional metadata - page = await self.get_current_page() - metadata = {} - - # Get element count - metadata['element_count'] = len(dom_state.selector_map) - - # Create simplified interactive elements list - interactive_elements = [] - for idx, element in dom_state.selector_map.items(): - element_info = { - 'index': idx, - 'tag_name': element.tag_name, - 'text': element.get_all_text_till_next_clickable_element(), - 'is_in_viewport': element.is_in_viewport - } - - # Add key attributes - for attr_name in ['id', 'href', 'src', 'alt', 'placeholder', 'name', 'role', 'title', 'type']: - if attr_name in element.attributes: - element_info[attr_name] = element.attributes[attr_name] - - interactive_elements.append(element_info) - - metadata['interactive_elements'] = interactive_elements - - # Get viewport dimensions - Fix syntax error in JavaScript - try: - viewport = await page.evaluate(""" - () => { - return { - width: window.innerWidth, - height: window.innerHeight - }; - } - """) - metadata['viewport_width'] = viewport.get('width', 0) - metadata['viewport_height'] = viewport.get('height', 0) - except Exception as e: - print(f"Error getting viewport dimensions: {e}") - metadata['viewport_width'] = 0 - metadata['viewport_height'] = 0 - - # Extract OCR text from screenshot if available - ocr_text = "" - if screenshot: - ocr_text = await self.extract_ocr_text_from_screenshot(screenshot) - metadata['ocr_text'] = ocr_text - - print(f"Got updated state after {action_name}: {len(dom_state.selector_map)} elements") - return dom_state, screenshot, elements, metadata - except Exception as e: - print(f"Error getting updated state after {action_name}: {e}") - traceback.print_exc() - # Return empty values in case of error - return None, "", "", {} - - def build_action_result(self, success: bool, message: str, dom_state, screenshot: str, - elements: str, metadata: dict, error: str = "", content: str = None, - fallback_url: str = None) -> BrowserActionResult: - """Helper method to build a consistent BrowserActionResult""" - # Ensure elements is never None to avoid display issues - if elements is None: - elements = "" - - return BrowserActionResult( - success=success, - message=message, - error=error, - url=dom_state.url if dom_state else fallback_url or "", - title=dom_state.title if dom_state else "", - elements=elements, - screenshot_base64=screenshot, - pixels_above=dom_state.pixels_above if dom_state else 0, - pixels_below=dom_state.pixels_below if dom_state else 0, - content=content, - ocr_text=metadata.get('ocr_text', ""), - element_count=metadata.get('element_count', 0), - interactive_elements=metadata.get('interactive_elements', []), - viewport_width=metadata.get('viewport_width', 0), - viewport_height=metadata.get('viewport_height', 0) - ) - - # Basic Navigation Actions - - async def navigate_to(self, action: GoToUrlAction = Body(...)): - """Navigate to a specified URL""" - try: - page = await self.get_current_page() - await page.goto(action.url, wait_until="domcontentloaded") - await page.wait_for_load_state("networkidle", timeout=10000) - - # Get updated state after action - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"navigate_to({action.url})") - - result = self.build_action_result( - True, - f"Navigated to {action.url}", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - - print(f"Navigation result: success={result.success}, url={result.url}") - return result - except Exception as e: - print(f"Navigation error: {str(e)}") - traceback.print_exc() - # Try to get some state info even after error - try: - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state("navigate_error_recovery") - return self.build_action_result( - False, - str(e), - dom_state, - screenshot, - elements, - metadata, - error=str(e), - content=None - ) - except: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - async def search_google(self, action: SearchGoogleAction = Body(...)): - """Search Google with the provided query""" - try: - page = await self.get_current_page() - search_url = f"https://www.google.com/search?q={action.query}" - await page.goto(search_url) - await page.wait_for_load_state() - - # Get updated state after action - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"search_google({action.query})") - - return self.build_action_result( - True, - f"Searched for '{action.query}' in Google", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - except Exception as e: - print(f"Search error: {str(e)}") - traceback.print_exc() - # Try to get some state info even after error - try: - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state("search_error_recovery") - return self.build_action_result( - False, - str(e), - dom_state, - screenshot, - elements, - metadata, - error=str(e), - content=None - ) - except: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - async def go_back(self, _: NoParamsAction = Body(...)): - """Navigate back in browser history""" - try: - page = await self.get_current_page() - await page.go_back() - await page.wait_for_load_state() - - # Get updated state after action - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state("go_back") - - return self.build_action_result( - True, - "Navigated back", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - except Exception as e: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - async def wait(self, seconds: int = Body(3)): - """Wait for the specified number of seconds""" - try: - await asyncio.sleep(seconds) - - # Get updated state after waiting - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"wait({seconds} seconds)") - - return self.build_action_result( - True, - f"Waited for {seconds} seconds", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - except Exception as e: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - # Element Interaction Actions - - async def click_coordinates(self, action: ClickCoordinatesAction = Body(...)): - """Click at specific x,y coordinates on the page""" - try: - page = await self.get_current_page() - - # Perform the click at the specified coordinates - await page.mouse.click(action.x, action.y) - - # 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})") - - return self.build_action_result( - True, - f"Clicked at coordinates ({action.x}, {action.y})", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - except Exception as e: - print(f"Error in click_coordinates: {e}") - traceback.print_exc() - - # 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, - str(e), - dom_state, - screenshot, - elements, - metadata, - error=str(e), - content=None - ) - except: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - async def click_element(self, action: ClickElementAction = Body(...)): - """Click on an element by index""" - try: - page = await self.get_current_page() - - # Get the current state and selector map *before* the click - initial_dom_state = await self.get_current_dom_state() - selector_map = initial_dom_state.selector_map - - if action.index not in selector_map: - # Get updated state even if element not found initially - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"click_element_error (index {action.index} not found)") - return self.build_action_result( - False, - f"Element with index {action.index} not found", - dom_state, # Use the latest state - screenshot, - elements, - metadata, - error=f"Element with index {action.index} not found" - ) - - element_to_click = selector_map[action.index] - print(f"Attempting to click element: {element_to_click}") - - # Construct a more reliable selector using JavaScript evaluation - # Find the element based on its properties captured in selector_map - js_selector_script = """ - (targetElementInfo) => { - const interactiveElements = Array.from(document.querySelectorAll( - 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [tabindex]:not([tabindex="-1"])' - )); - - const visibleElements = interactiveElements.filter(el => { - const style = window.getComputedStyle(el); - const rect = el.getBoundingClientRect(); - return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && rect.width > 0 && rect.height > 0; - }); - - if (targetElementInfo.index > 0 && targetElementInfo.index <= visibleElements.length) { - // Return the element at the specified index (1-based) - return visibleElements[targetElementInfo.index - 1]; - } - return null; // Element not found at the expected index - } - """ - - element_info = {'index': action.index} # Pass the target index to the script - - target_element_handle = await page.evaluate_handle(js_selector_script, element_info) - - click_success = False - error_message = "" - - if await target_element_handle.evaluate("node => node !== null"): - try: - # Use Playwright's recommended way: click the handle - # Add timeout and wait for element to be stable - await target_element_handle.click(timeout=5000) - click_success = True - print(f"Successfully clicked element handle for index {action.index}") - except Exception as click_error: - error_message = f"Error clicking element handle: {click_error}" - print(error_message) - # Optional: Add fallback methods here if needed - # e.g., target_element_handle.dispatch_event('click') - else: - error_message = f"Could not locate the target element handle for index {action.index} using JS script." - print(error_message) - - - # Wait for potential page changes/network activity - try: - 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) - - # Get updated state after action - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"click_element({action.index})") - - return self.build_action_result( - click_success, - f"Clicked element with index {action.index}" if click_success else f"Attempted to click element {action.index} but failed. Error: {error_message}", - dom_state, - screenshot, - elements, - metadata, - error=error_message if not click_success else "", - content=None - ) - - except Exception as e: - print(f"Error in click_element: {e}") - traceback.print_exc() - # Try to get state even after error - try: - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state("click_element_error_recovery") - return self.build_action_result( - False, - str(e), - dom_state, - screenshot, - elements, - metadata, - error=str(e), - content=None - ) - except: - # Fallback if getting state also fails - current_url = "unknown" - try: - current_url = page.url # Try to get at least the URL - except: - pass - return self.build_action_result( - False, - str(e), - None, # No DOM state available - "", # No screenshot - "", # No elements string - {}, # Empty metadata - error=str(e), - content=None, - fallback_url=current_url - ) - - async def input_text(self, action: InputTextAction = Body(...)): - """Input text into an element""" - try: - page = await self.get_current_page() - selector_map = await self.get_selector_map() - - if action.index not in selector_map: - return self.build_action_result( - False, - f"Element with index {action.index} not found", - None, - "", - "", - {}, - error=f"Element with index {action.index} not found" - ) - - # In a real implementation, we would use the selector map to get the element's - # properties and use them to find and type into the element - element = selector_map[action.index] - - # Use CSS selector or XPath to locate and type into the element - await page.wait_for_timeout(500) # Small delay before typing - - # Demo implementation - would use proper selectors in production - if element.attributes.get("id"): - await page.fill(f"#{element.attributes['id']}", action.text) - elif element.attributes.get("class"): - class_selector = f".{element.attributes['class'].replace(' ', '.')}" - await page.fill(class_selector, action.text) - else: - # 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}')") - - return self.build_action_result( - True, - f"Input '{action.text}' into element with index {action.index}", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - except Exception as e: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - async def send_keys(self, action: SendKeysAction = Body(...)): - """Send keyboard keys""" - try: - 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})") - - return self.build_action_result( - True, - f"Sent keys: {action.keys}", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - except Exception as e: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - # Tab Management Actions - - async def switch_tab(self, action: SwitchTabAction = Body(...)): - """Switch to a different tab by index""" - try: - if 0 <= action.page_id < len(self.pages): - self.current_page_index = action.page_id - page = await self.get_current_page() - await page.wait_for_load_state() - - # Get updated state after action - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"switch_tab({action.page_id})") - - return self.build_action_result( - True, - f"Switched to tab {action.page_id}", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - else: - return self.build_action_result( - False, - f"Tab {action.page_id} not found", - None, - "", - "", - {}, - error=f"Tab {action.page_id} not found" - ) - except Exception as e: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - async def open_tab(self, action: OpenTabAction = Body(...)): - """Open a new tab with the specified URL""" - try: - print(f"Attempting to open new tab with URL: {action.url}") - # Create new page in same browser instance - new_page = await self.browser_context.new_page() - print(f"New page created successfully") - - # Navigate to the URL - await new_page.goto(action.url, wait_until="domcontentloaded") - await new_page.wait_for_load_state("networkidle", timeout=10000) - print(f"Navigated to URL in new tab: {action.url}") - - # Add to page list and make it current - self.pages.append(new_page) - self.current_page_index = len(self.pages) - 1 - print(f"New tab added as index {self.current_page_index}") - - # Get updated state after action - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"open_tab({action.url})") - - return self.build_action_result( - True, - f"Opened new tab with URL: {action.url}", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - except Exception as e: - print("****"*10) - print(f"Error opening tab: {e}") - print(traceback.format_exc()) - print("****"*10) - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - async def close_tab(self, action: CloseTabAction = Body(...)): - """Close a tab by index""" - try: - if 0 <= action.page_id < len(self.pages): - page = self.pages[action.page_id] - url = page.url - await page.close() - self.pages.pop(action.page_id) - - # Adjust current index if needed - if self.current_page_index >= len(self.pages): - self.current_page_index = max(0, len(self.pages) - 1) - elif self.current_page_index >= action.page_id: - self.current_page_index = max(0, self.current_page_index - 1) - - # Get updated state after action - page = await self.get_current_page() - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"close_tab({action.page_id})") - - return self.build_action_result( - True, - f"Closed tab {action.page_id} with URL: {url}", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - else: - return self.build_action_result( - False, - f"Tab {action.page_id} not found", - None, - "", - "", - {}, - error=f"Tab {action.page_id} not found" - ) - except Exception as e: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - # Content Actions - - async def extract_content(self, goal: str = Body(...)): - """Extract content from the current page based on the provided goal""" - try: - page = await self.get_current_page() - content = await page.content() - - # In a full implementation, we would use an LLM to extract specific content - # based on the goal. For this example, we'll extract visible text. - extracted_text = await page.evaluate(""" - Array.from(document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, span, div')) - .filter(el => { - const style = window.getComputedStyle(el); - return style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - el.innerText && - el.innerText.trim().length > 0; - }) - .map(el => el.innerText.trim()) - .join('\\n\\n'); - """) - - # Get updated state - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"extract_content({goal})") - - return self.build_action_result( - True, - f"Content extracted based on goal: {goal}", - dom_state, - screenshot, - elements, - metadata, - error="", - content=extracted_text - ) - except Exception as e: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - async def save_pdf(self): - """Save the current page as a PDF""" - try: - page = await self.get_current_page() - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - random_id = random.randint(1000, 9999) - filename = f"page_{timestamp}_{random_id}.pdf" - filepath = os.path.join(self.screenshot_dir, filename) - - await page.pdf(path=filepath) - - # Get updated state - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state("save_pdf") - - return self.build_action_result( - True, - f"Saved page as PDF: {filepath}", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - except Exception as e: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - # Scroll Actions - - async def scroll_down(self, action: ScrollAction = Body(...)): - """Scroll down the page""" - try: - page = await self.get_current_page() - if action.amount is not None: - await page.evaluate(f"window.scrollBy(0, {action.amount});") - amount_str = f"{action.amount} pixels" - else: - await page.evaluate("window.scrollBy(0, window.innerHeight);") - amount_str = "one page" - - await page.wait_for_timeout(500) # Wait for scroll to complete - - # Get updated state after action - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"scroll_down({amount_str})") - - return self.build_action_result( - True, - f"Scrolled down by {amount_str}", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - except Exception as e: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - async def scroll_up(self, action: ScrollAction = Body(...)): - """Scroll up the page""" - try: - page = await self.get_current_page() - if action.amount is not None: - await page.evaluate(f"window.scrollBy(0, -{action.amount});") - amount_str = f"{action.amount} pixels" - else: - await page.evaluate("window.scrollBy(0, -window.innerHeight);") - amount_str = "one page" - - await page.wait_for_timeout(500) # Wait for scroll to complete - - # Get updated state after action - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"scroll_up({amount_str})") - - return self.build_action_result( - True, - f"Scrolled up by {amount_str}", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - except Exception as e: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - async def scroll_to_text(self, text: str = Body(...)): - """Scroll to text on the page""" - try: - page = await self.get_current_page() - locators = [ - page.get_by_text(text, exact=False), - page.locator(f"text={text}"), - page.locator(f"//*[contains(text(), '{text}')]"), - ] - - found = False - for locator in locators: - try: - if await locator.count() > 0 and await locator.first.is_visible(): - await locator.first.scroll_into_view_if_needed() - await asyncio.sleep(0.5) # Wait for scroll to complete - found = True - break - except Exception: - continue - - # Get updated state after action - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"scroll_to_text({text})") - - message = f"Scrolled to text: {text}" if found else f"Text '{text}' not found or not visible on page" - - return self.build_action_result( - found, - message, - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - except Exception as e: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - # Dropdown Actions - - async def get_dropdown_options(self, index: int = Body(...)): - """Get all options from a dropdown""" - try: - page = await self.get_current_page() - selector_map = await self.get_selector_map() - - if index not in selector_map: - return self.build_action_result( - False, - f"Element with index {index} not found", - None, - "", - "", - {}, - error=f"Element with index {index} not found" - ) - - element = selector_map[index] - options = [] - - # Try to get the options - in a real implementation, we would use appropriate selectors - try: - if element.tag_name.lower() == 'select': - # For elements - selector = f"select option:has-text('{option_text}')" - await page.select_option( - f"#{element.attributes.get('id')}" if element.attributes.get('id') else f"//select[{index}]", - label=option_text - ) - else: - # For custom dropdowns - # First click to open the dropdown - if element.attributes.get('id'): - await page.click(f"#{element.attributes.get('id')}") - else: - await page.click(f"//{element.tag_name}[{index}]") - - await page.wait_for_timeout(500) - - # Then try to click the option - await page.click(f"text={option_text}") - - await page.wait_for_timeout(500) - - # Get updated state after action - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"select_dropdown_option({index}, '{option_text}')") - - return self.build_action_result( - True, - f"Selected option '{option_text}' from dropdown with index {index}", - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - except Exception as e: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - - # Drag and Drop - - async def drag_drop(self, action: DragDropAction = Body(...)): - """Perform drag and drop operation""" - try: - page = await self.get_current_page() - - # Element-based drag and drop - if action.element_source and action.element_target: - # In a real implementation, we would get the elements and perform the drag - source_desc = action.element_source - target_desc = action.element_target - - # We would locate the elements using selectors and perform the drag - # For this example, we'll use a simplified version - await page.evaluate(""" - console.log("Simulating drag and drop between elements"); - """) - - message = f"Dragged element '{source_desc}' to '{target_desc}'" - - # Coordinate-based drag and drop - elif all(coord is not None for coord in [ - action.coord_source_x, action.coord_source_y, - action.coord_target_x, action.coord_target_y - ]): - source_x = action.coord_source_x - source_y = action.coord_source_y - target_x = action.coord_target_x - target_y = action.coord_target_y - - # Perform the drag - await page.mouse.move(source_x, source_y) - await page.mouse.down() - - steps = max(1, action.steps or 10) - delay_ms = max(0, action.delay_ms or 5) - - for i in range(1, steps + 1): - ratio = i / steps - intermediate_x = int(source_x + (target_x - source_x) * ratio) - intermediate_y = int(source_y + (target_y - source_y) * ratio) - await page.mouse.move(intermediate_x, intermediate_y) - if delay_ms > 0: - await asyncio.sleep(delay_ms / 1000) - - await page.mouse.move(target_x, target_y) - await page.mouse.up() - - message = f"Dragged from ({source_x}, {source_y}) to ({target_x}, {target_y})" - else: - return self.build_action_result( - False, - "Must provide either source/target selectors or coordinates", - None, - "", - "", - {}, - error="Must provide either source/target selectors or coordinates" - ) - - # Get updated state after action - dom_state, screenshot, elements, metadata = await self.get_updated_browser_state(f"drag_drop({action.element_source}, {action.element_target})") - - return self.build_action_result( - True, - message, - dom_state, - screenshot, - elements, - metadata, - error="", - content=None - ) - except Exception as e: - return self.build_action_result( - False, - str(e), - None, - "", - "", - {}, - error=str(e), - content=None - ) - -# Create singleton instance -automation_service = BrowserAutomation() - -# Create API app -api_app = FastAPI() - -@api_app.get("/api") -async def health_check(): - return {"status": "ok", "message": "API server is running"} - -# Include automation service router with /api prefix -api_app.include_router(automation_service.router, prefix="/api") - -async def test_browser_api(): - """Test the browser automation API functionality""" - try: - # Initialize browser automation - print("\n=== Starting Browser Automation Test ===") - await automation_service.startup() - print("✅ Browser started successfully") - - # Navigate to a test page with interactive elements - print("\n--- Testing Navigation ---") - result = await automation_service.navigate_to(GoToUrlAction(url="https://www.youtube.com")) - print(f"Navigation status: {'✅ Success' if result.success else '❌ Failed'}") - if not result.success: - print(f"Error: {result.error}") - return - - print(f"URL: {result.url}") - print(f"Title: {result.title}") - - # Check DOM state and elements - print(f"\nFound {result.element_count} interactive elements") - if result.elements and result.elements.strip(): - print("Elements:") - print(result.elements) - else: - print("No formatted elements found, but DOM was processed") - - # Display interactive elements as JSON - if result.interactive_elements and len(result.interactive_elements) > 0: - print("\nInteractive elements summary:") - for el in result.interactive_elements: - print(f" [{el['index']}] <{el['tag_name']}> {el.get('text', '')[:30]}") - - # Screenshot info - print(f"\nScreenshot captured: {'Yes' if result.screenshot_base64 else 'No'}") - print(f"Viewport size: {result.viewport_width}x{result.viewport_height}") - - # Test OCR extraction from screenshot - print("\n--- Testing OCR Text Extraction ---") - if result.ocr_text: - print("OCR text extracted from screenshot:") - print("=== OCR TEXT START ===") - print(result.ocr_text) - print("=== OCR TEXT END ===") - print(f"OCR text length: {len(result.ocr_text)} characters") - print(result.ocr_text) - else: - print("No OCR text extracted from screenshot") - - await asyncio.sleep(2) - - # Test search functionality - print("\n--- Testing Search ---") - result = await automation_service.search_google(SearchGoogleAction(query="browser automation")) - print(f"Search status: {'✅ Success' if result.success else '❌ Failed'}") - if not result.success: - print(f"Error: {result.error}") - else: - print(f"Found {result.element_count} elements after search") - print(f"Page title: {result.title}") - - # Test OCR extraction from search results - if result.ocr_text: - print("\nOCR text from search results:") - print("=== OCR TEXT START ===") - print(result.ocr_text) - print("=== OCR TEXT END ===") - else: - print("\nNo OCR text extracted from search results") - - await asyncio.sleep(2) - - # Test scrolling - print("\n--- Testing Scrolling ---") - result = await automation_service.scroll_down(ScrollAction(amount=300)) - print(f"Scroll status: {'✅ Success' if result.success else '❌ Failed'}") - if result.success: - print(f"Pixels above viewport: {result.pixels_above}") - print(f"Pixels below viewport: {result.pixels_below}") - - await asyncio.sleep(2) - - # Test clicking on an element - print("\n--- Testing Element Click ---") - if result.element_count > 0: - click_result = await automation_service.click_element(ClickElementAction(index=1)) - print(f"Click status: {'✅ Success' if click_result.success else '❌ Failed'}") - print(f"Message: {click_result.message}") - print(f"New URL after click: {click_result.url}") - else: - print("Skipping click test - no elements found") - - await asyncio.sleep(2) - - # Test clicking on coordinates - print("\n--- Testing Click Coordinates ---") - coord_click_result = await automation_service.click_coordinates(ClickCoordinatesAction(x=100, y=100)) - print(f"Coordinate click status: {'✅ Success' if coord_click_result.success else '❌ Failed'}") - print(f"Message: {coord_click_result.message}") - print(f"URL after coordinate click: {coord_click_result.url}") - - await asyncio.sleep(2) - - # Test extracting content - print("\n--- Testing Content Extraction ---") - content_result = await automation_service.extract_content("test goal") - print(f"Content extraction status: {'✅ Success' if content_result.success else '❌ Failed'}") - if content_result.content: - content_preview = content_result.content[:100] + "..." if len(content_result.content) > 100 else content_result.content - print(f"Content sample: {content_preview}") - print(f"Total content length: {len(content_result.content)} chars") - else: - print("No content was extracted") - - # Test tab management - print("\n--- Testing Tab Management ---") - tab_result = await automation_service.open_tab(OpenTabAction(url="https://www.example.org")) - print(f"New tab status: {'✅ Success' if tab_result.success else '❌ Failed'}") - if tab_result.success: - print(f"New tab title: {tab_result.title}") - print(f"Interactive elements: {tab_result.element_count}") - - print("\n✅ All tests completed successfully!") - - except Exception as e: - print(f"\n❌ Test failed: {str(e)}") - traceback.print_exc() - finally: - # Ensure browser is closed - print("\n--- Cleaning up ---") - await automation_service.shutdown() - print("Browser closed") - -async def test_browser_api_2(): - """Test the browser automation API functionality on the chess page""" - try: - # Initialize browser automation - print("\n=== Starting Browser Automation Test 2 (Chess Page) ===") - await automation_service.startup() - print("✅ Browser started successfully") - - # Navigate to the chess test page - print("\n--- Testing Navigation to Chess Page ---") - test_url = "https://dat-lequoc.github.io/chess-for-suna/chess.html" - result = await automation_service.navigate_to(GoToUrlAction(url=test_url)) - print(f"Navigation status: {'✅ Success' if result.success else '❌ Failed'}") - if not result.success: - print(f"Error: {result.error}") - return - - print(f"URL: {result.url}") - print(f"Title: {result.title}") - - # Check DOM state and elements - print(f"\nFound {result.element_count} interactive elements") - if result.elements and result.elements.strip(): - print("Elements:") - print(result.elements) - else: - print("No formatted elements found, but DOM was processed") - - # Display interactive elements as JSON - if result.interactive_elements and len(result.interactive_elements) > 0: - print("\nInteractive elements summary:") - for el in result.interactive_elements: - print(f" [{el['index']}] <{el['tag_name']}> {el.get('text', '')[:30]}") - - # Screenshot info - print(f"\nScreenshot captured: {'Yes' if result.screenshot_base64 else 'No'}") - print(f"Viewport size: {result.viewport_width}x{result.viewport_height}") - - await asyncio.sleep(2) - - # Test clicking on an element (e.g., a chess square) - print("\n--- Testing Element Click (element 5) ---") - if result.element_count > 4: # Ensure element 5 exists - click_index = 5 - click_result = await automation_service.click_element(ClickElementAction(index=click_index)) - print(f"Click status for element {click_index}: {'✅ Success' if click_result.success else '❌ Failed'}") - print(f"Message: {click_result.message}") - print(f"URL after click: {click_result.url}") - - # Retrieve and display elements again after click - print(f"\n--- Retrieving elements after clicking element {click_index} ---") - if click_result.elements and click_result.elements.strip(): - print("Updated Elements:") - print(click_result.elements) - else: - print("No formatted elements found after click.") - - if click_result.interactive_elements and len(click_result.interactive_elements) > 0: - print("\nUpdated interactive elements summary:") - for el in click_result.interactive_elements: - print(f" [{el['index']}] <{el['tag_name']}> {el.get('text', '')[:30]}") - else: - print("No interactive elements found after click.") - - # Test clicking element 1 after the first click - print("\n--- Testing Element Click (element 1 after clicking 5) ---") - if click_result.element_count > 0: # Check if there are still elements - click_index_2 = 1 - click_result_2 = await automation_service.click_element(ClickElementAction(index=click_index_2)) - print(f"Click status for element {click_index_2}: {'✅ Success' if click_result_2.success else '❌ Failed'}") - print(f"Message: {click_result_2.message}") - print(f"URL after click: {click_result_2.url}") - - # Retrieve and display elements again after the second click - print(f"\n--- Retrieving elements after clicking element {click_index_2} ---") - if click_result_2.elements and click_result_2.elements.strip(): - print("Elements after second click:") - print(click_result_2.elements) - else: - print("No formatted elements found after second click.") - - if click_result_2.interactive_elements and len(click_result_2.interactive_elements) > 0: - print("\nInteractive elements summary after second click:") - for el in click_result_2.interactive_elements: - print(f" [{el['index']}] <{el['tag_name']}> {el.get('text', '')[:30]}") - else: - print("No interactive elements found after second click.") - else: - print("Skipping second element click test - no elements found after first click.") - - else: - print("Skipping element click test - fewer than 5 elements found.") - - await asyncio.sleep(2) - - print("\n✅ Chess Page Test Completed!") - await asyncio.sleep(100) - - except Exception as e: - print(f"\n❌ Chess Page Test failed: {str(e)}") - traceback.print_exc() - finally: - # Ensure browser is closed - print("\n--- Cleaning up ---") - await automation_service.shutdown() - print("Browser closed") - -if __name__ == '__main__': - import uvicorn - import sys - - # Check command line arguments for test mode - test_mode_1 = "--test" in sys.argv - test_mode_2 = "--test2" in sys.argv - - if test_mode_1: - print("Running in test mode 1") - asyncio.run(test_browser_api()) - elif test_mode_2: - print("Running in test mode 2 (Chess Page)") - asyncio.run(test_browser_api_2()) - else: - print("Starting API server") - uvicorn.run("browser_api:api_app", host="0.0.0.0", port=8003) \ No newline at end of file diff --git a/app/daytona/docker/docker-compose.yml b/app/daytona/docker/docker-compose.yml deleted file mode 100644 index 55ae72533..000000000 --- a/app/daytona/docker/docker-compose.yml +++ /dev/null @@ -1,45 +0,0 @@ -services: - kortix-suna: - platform: linux/amd64 - build: - context: . - dockerfile: ${DOCKERFILE:-Dockerfile} - args: - TARGETPLATFORM: ${TARGETPLATFORM:-linux/amd64} - image: daytona_sanbox:1.0 - ports: - - "6080:6080" # noVNC web interface - - "5901:5901" # VNC port - - "9222:9222" # Chrome remote debugging port - - "8003:8003" # API server port - - "8080:8080" # HTTP server port - environment: - - ANONYMIZED_TELEMETRY=${ANONYMIZED_TELEMETRY:-false} - - CHROME_PATH=/usr/bin/google-chrome - - CHROME_USER_DATA=/app/data/chrome_data - - CHROME_PERSISTENT_SESSION=${CHROME_PERSISTENT_SESSION:-false} - - CHROME_CDP=${CHROME_CDP:-http://localhost:9222} - - DISPLAY=:99 - - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright - - RESOLUTION=${RESOLUTION:-1024x768x24} - - RESOLUTION_WIDTH=${RESOLUTION_WIDTH:-1024} - - RESOLUTION_HEIGHT=${RESOLUTION_HEIGHT:-768} - - VNC_PASSWORD=${VNC_PASSWORD:-vncpassword} - - CHROME_DEBUGGING_PORT=9222 - - CHROME_DEBUGGING_HOST=localhost - - CHROME_FLAGS=${CHROME_FLAGS:-"--single-process --no-first-run --no-default-browser-check --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-component-extensions-with-background-pages --disable-dev-shm-usage --disable-extensions --disable-features=TranslateUI --disable-ipc-flooding-protection --disable-renderer-backgrounding --enable-features=NetworkServiceInProcess2 --force-color-profile=srgb --metrics-recording-only --mute-audio --no-sandbox --disable-gpu"} - volumes: - - /tmp/.X11-unix:/tmp/.X11-unix - restart: unless-stopped - shm_size: '2gb' - cap_add: - - SYS_ADMIN - security_opt: - - seccomp=unconfined - tmpfs: - - /tmp - healthcheck: - test: ["CMD", "nc", "-z", "localhost", "5901"] - interval: 10s - timeout: 5s - retries: 3 diff --git a/app/daytona/docker/entrypoint.sh b/app/daytona/docker/entrypoint.sh deleted file mode 100644 index 9ab9240b3..000000000 --- a/app/daytona/docker/entrypoint.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -# Start supervisord in the foreground to properly manage child processes -exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf \ No newline at end of file diff --git a/app/daytona/docker/requirements.txt b/app/daytona/docker/requirements.txt deleted file mode 100644 index fae0febe9..000000000 --- a/app/daytona/docker/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -fastapi==0.115.12 -uvicorn==0.34.0 -pyautogui==0.9.54 -pillow==10.2.0 -pydantic==2.6.1 -pytesseract==0.3.13 \ No newline at end of file diff --git a/app/daytona/docker/server.py b/app/daytona/docker/server.py deleted file mode 100644 index defa5f0af..000000000 --- a/app/daytona/docker/server.py +++ /dev/null @@ -1,29 +0,0 @@ -from fastapi import FastAPI, Request -from fastapi.staticfiles import StaticFiles -from starlette.middleware.base import BaseHTTPMiddleware -import uvicorn -import os - -# Ensure we're serving from the /workspace directory -workspace_dir = "/workspace" - -class WorkspaceDirMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next): - # Check if workspace directory exists and recreate if deleted - if not os.path.exists(workspace_dir): - print(f"Workspace directory {workspace_dir} not found, recreating...") - os.makedirs(workspace_dir, exist_ok=True) - return await call_next(request) - -app = FastAPI() -app.add_middleware(WorkspaceDirMiddleware) - -# Initial directory creation -os.makedirs(workspace_dir, exist_ok=True) -app.mount('/', StaticFiles(directory=workspace_dir, html=True), name='site') - -# This is needed for the import string approach with uvicorn -if __name__ == '__main__': - print(f"Starting server with auto-reload, serving files from: {workspace_dir}") - # Don't use reload directly in the run call - uvicorn.run("server:app", host="0.0.0.0", port=8080, reload=True) \ No newline at end of file diff --git a/app/daytona/docker/supervisord.conf b/app/daytona/docker/supervisord.conf deleted file mode 100644 index b55ceb1e6..000000000 --- a/app/daytona/docker/supervisord.conf +++ /dev/null @@ -1,94 +0,0 @@ -[supervisord] -user=root -nodaemon=true -logfile=/dev/stdout -logfile_maxbytes=0 -loglevel=debug - -[program:xvfb] -command=Xvfb :99 -screen 0 %(ENV_RESOLUTION)s -ac +extension GLX +render -noreset -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 -priority=100 -startsecs=3 -stopsignal=TERM -stopwaitsecs=10 - -[program:vnc_setup] -command=bash -c "mkdir -p ~/.vnc && echo '%(ENV_VNC_PASSWORD)s' | vncpasswd -f > ~/.vnc/passwd && chmod 600 ~/.vnc/passwd && ls -la ~/.vnc/passwd" -autorestart=false -startsecs=0 -priority=150 -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 - -[program:x11vnc] -command=bash -c "mkdir -p /var/log && touch /var/log/x11vnc.log && chmod 666 /var/log/x11vnc.log && sleep 5 && DISPLAY=:99 x11vnc -display :99 -forever -shared -rfbauth /root/.vnc/passwd -rfbport 5901 -o /var/log/x11vnc.log" -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 -priority=200 -startretries=10 -startsecs=10 -stopsignal=TERM -stopwaitsecs=10 -depends_on=vnc_setup,xvfb - -[program:x11vnc_log] -command=bash -c "mkdir -p /var/log && touch /var/log/x11vnc.log && tail -f /var/log/x11vnc.log" -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 -priority=250 -stopsignal=TERM -stopwaitsecs=5 -depends_on=x11vnc - -[program:novnc] -command=bash -c "sleep 5 && cd /opt/novnc && ./utils/novnc_proxy --vnc localhost:5901 --listen 0.0.0.0:6080 --web /opt/novnc" -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 -priority=300 -startretries=5 -startsecs=3 -depends_on=x11vnc - -[program:http_server] -command=python /app/server.py -directory=/app -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 -priority=400 -startretries=5 -startsecs=5 -stopsignal=TERM -stopwaitsecs=10 - -[program:browser_api] -command=python /app/browser_api.py -directory=/app -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 -priority=400 -startretries=5 -startsecs=5 -stopsignal=TERM -stopwaitsecs=10 diff --git a/app/daytona/tool_base.py b/app/daytona/tool_base.py index 6a69b067c..58fa62950 100644 --- a/app/daytona/tool_base.py +++ b/app/daytona/tool_base.py @@ -12,8 +12,6 @@ get_or_start_sandbox, start_supervisord_session, ) - -# from app.agentpress.thread_manager import ThreadManager from app.tool.base import BaseTool, ToolResult from app.utils.files_utils import clean_path from app.utils.logger import logger diff --git a/app/services/billing.py b/app/services/billing.py deleted file mode 100644 index 67fa9bb40..000000000 --- a/app/services/billing.py +++ /dev/null @@ -1,969 +0,0 @@ -""" -Stripe Billing API implementation for Suna on top of Basejump. ONLY HAS SUPPOT FOR USER ACCOUNTS – no team accounts. As we are using the user_id as account_id as is the case with personal accounts. In personal accounts, the account_id equals the user_id. In team accounts, the account_id is unique. - -stripe listen --forward-to localhost:8000/api/billing/webhook -""" - -from fastapi import APIRouter, HTTPException, Depends, Request -from typing import Optional, Dict, Tuple -import stripe -from datetime import datetime, timezone -from utils.logger import logger -from utils.config import config, EnvMode -from services.supabase import DBConnection -from utils.auth_utils import get_current_user_id_from_jwt -from pydantic import BaseModel -from utils.constants import MODEL_ACCESS_TIERS, MODEL_NAME_ALIASES -import os - -# Initialize Stripe -stripe.api_key = config.STRIPE_SECRET_KEY - -# Initialize router -router = APIRouter(prefix="/billing", tags=["billing"]) - - -SUBSCRIPTION_TIERS = { - config.STRIPE_FREE_TIER_ID: {'name': 'free', 'minutes': 60}, - config.STRIPE_TIER_2_20_ID: {'name': 'tier_2_20', 'minutes': 120}, # 2 hours - config.STRIPE_TIER_6_50_ID: {'name': 'tier_6_50', 'minutes': 360}, # 6 hours - config.STRIPE_TIER_12_100_ID: {'name': 'tier_12_100', 'minutes': 720}, # 12 hours - config.STRIPE_TIER_25_200_ID: {'name': 'tier_25_200', 'minutes': 1500}, # 25 hours - config.STRIPE_TIER_50_400_ID: {'name': 'tier_50_400', 'minutes': 3000}, # 50 hours - config.STRIPE_TIER_125_800_ID: {'name': 'tier_125_800', 'minutes': 7500}, # 125 hours - config.STRIPE_TIER_200_1000_ID: {'name': 'tier_200_1000', 'minutes': 12000}, # 200 hours -} - -# Pydantic models for request/response validation -class CreateCheckoutSessionRequest(BaseModel): - price_id: str - success_url: str - cancel_url: str - tolt_referral: Optional[str] = None - -class CreatePortalSessionRequest(BaseModel): - return_url: str - -class SubscriptionStatus(BaseModel): - status: str # e.g., 'active', 'trialing', 'past_due', 'scheduled_downgrade', 'no_subscription' - plan_name: Optional[str] = None - price_id: Optional[str] = None # Added price ID - current_period_end: Optional[datetime] = None - cancel_at_period_end: bool = False - trial_end: Optional[datetime] = None - minutes_limit: Optional[int] = None - current_usage: Optional[float] = None - # Fields for scheduled changes - has_schedule: bool = False - scheduled_plan_name: Optional[str] = None - scheduled_price_id: Optional[str] = None # Added scheduled price ID - scheduled_change_date: Optional[datetime] = None - -# Helper functions -async def get_stripe_customer_id(client, user_id: str) -> Optional[str]: - """Get the Stripe customer ID for a user.""" - result = await client.schema('basejump').from_('billing_customers') \ - .select('id') \ - .eq('account_id', user_id) \ - .execute() - - if result.data and len(result.data) > 0: - return result.data[0]['id'] - return None - -async def create_stripe_customer(client, user_id: str, email: str) -> str: - """Create a new Stripe customer for a user.""" - # Create customer in Stripe - customer = stripe.Customer.create( - email=email, - metadata={"user_id": user_id} - ) - - # Store customer ID in Supabase - await client.schema('basejump').from_('billing_customers').insert({ - 'id': customer.id, - 'account_id': user_id, - 'email': email, - 'provider': 'stripe' - }).execute() - - return customer.id - -async def get_user_subscription(user_id: str) -> Optional[Dict]: - """Get the current subscription for a user from Stripe.""" - try: - # Get customer ID - db = DBConnection() - client = await db.client - customer_id = await get_stripe_customer_id(client, user_id) - - if not customer_id: - return None - - # Get all active subscriptions for the customer - subscriptions = stripe.Subscription.list( - customer=customer_id, - status='active' - ) - # print("Found subscriptions:", subscriptions) - - # Check if we have any subscriptions - if not subscriptions or not subscriptions.get('data'): - return None - - # Filter subscriptions to only include our product's subscriptions - our_subscriptions = [] - for sub in subscriptions['data']: - # Get the first subscription item - if sub.get('items') and sub['items'].get('data') and len(sub['items']['data']) > 0: - item = sub['items']['data'][0] - if item.get('price') and item['price'].get('id') in [ - config.STRIPE_FREE_TIER_ID, - config.STRIPE_TIER_2_20_ID, - config.STRIPE_TIER_6_50_ID, - config.STRIPE_TIER_12_100_ID, - config.STRIPE_TIER_25_200_ID, - config.STRIPE_TIER_50_400_ID, - config.STRIPE_TIER_125_800_ID, - config.STRIPE_TIER_200_1000_ID - ]: - our_subscriptions.append(sub) - - if not our_subscriptions: - return None - - # If there are multiple active subscriptions, we need to handle this - if len(our_subscriptions) > 1: - logger.warning(f"User {user_id} has multiple active subscriptions: {[sub['id'] for sub in our_subscriptions]}") - - # Get the most recent subscription - most_recent = max(our_subscriptions, key=lambda x: x['created']) - - # Cancel all other subscriptions - for sub in our_subscriptions: - if sub['id'] != most_recent['id']: - try: - stripe.Subscription.modify( - sub['id'], - cancel_at_period_end=True - ) - logger.info(f"Cancelled subscription {sub['id']} for user {user_id}") - except Exception as e: - logger.error(f"Error cancelling subscription {sub['id']}: {str(e)}") - - return most_recent - - return our_subscriptions[0] - - except Exception as e: - logger.error(f"Error getting subscription from Stripe: {str(e)}") - return None - -async def calculate_monthly_usage(client, user_id: str) -> float: - """Calculate total agent run minutes for the current month for a user.""" - # Get start of current month in UTC - now = datetime.now(timezone.utc) - start_of_month = datetime(now.year, now.month, 1, tzinfo=timezone.utc) - - # First get all threads for this user - threads_result = await client.table('threads') \ - .select('thread_id') \ - .eq('account_id', user_id) \ - .execute() - - if not threads_result.data: - return 0.0 - - thread_ids = [t['thread_id'] for t in threads_result.data] - - # Then get all agent runs for these threads in current month - runs_result = await client.table('agent_runs') \ - .select('started_at, completed_at') \ - .in_('thread_id', thread_ids) \ - .gte('started_at', start_of_month.isoformat()) \ - .execute() - - if not runs_result.data: - return 0.0 - - # Calculate total minutes - total_seconds = 0 - now_ts = now.timestamp() - - for run in runs_result.data: - start_time = datetime.fromisoformat(run['started_at'].replace('Z', '+00:00')).timestamp() - if run['completed_at']: - end_time = datetime.fromisoformat(run['completed_at'].replace('Z', '+00:00')).timestamp() - if start_time < end_time - 7200: - continue - else: - # if the start time is more than an hour ago, don't consider that time in total. else use the current time - if start_time < now_ts - 3600: - continue - else: - end_time = now_ts - - total_seconds += (end_time - start_time) - - return total_seconds / 60 # Convert to minutes - -async def get_allowed_models_for_user(client, user_id: str): - """ - Get the list of models allowed for a user based on their subscription tier. - - Returns: - List of model names allowed for the user's subscription tier. - """ - - subscription = await get_user_subscription(user_id) - tier_name = 'free' - - if subscription: - price_id = None - if subscription.get('items') and subscription['items'].get('data') and len(subscription['items']['data']) > 0: - price_id = subscription['items']['data'][0]['price']['id'] - else: - price_id = subscription.get('price_id', config.STRIPE_FREE_TIER_ID) - - # Get tier info for this price_id - tier_info = SUBSCRIPTION_TIERS.get(price_id) - if tier_info: - tier_name = tier_info['name'] - - # Return allowed models for this tier - return MODEL_ACCESS_TIERS.get(tier_name, MODEL_ACCESS_TIERS['free']) # Default to free tier if unknown - - -async def can_use_model(client, user_id: str, model_name: str): - if config.ENV_MODE == EnvMode.LOCAL: - logger.info("Running in local development mode - billing checks are disabled") - return True, "Local development mode - billing disabled", { - "price_id": "local_dev", - "plan_name": "Local Development", - "minutes_limit": "no limit" - } - - allowed_models = await get_allowed_models_for_user(client, user_id) - resolved_model = MODEL_NAME_ALIASES.get(model_name, model_name) - if resolved_model in allowed_models: - return True, "Model access allowed", allowed_models - - return False, f"Your current subscription plan does not include access to {model_name}. Please upgrade your subscription or choose from your available models: {', '.join(allowed_models)}", allowed_models - -async def check_billing_status(client, user_id: str) -> Tuple[bool, str, Optional[Dict]]: - """ - Check if a user can run agents based on their subscription and usage. - - Returns: - Tuple[bool, str, Optional[Dict]]: (can_run, message, subscription_info) - """ - if config.ENV_MODE == EnvMode.LOCAL: - logger.info("Running in local development mode - billing checks are disabled") - return True, "Local development mode - billing disabled", { - "price_id": "local_dev", - "plan_name": "Local Development", - "minutes_limit": "no limit" - } - - # Get current subscription - subscription = await get_user_subscription(user_id) - # print("Current subscription:", subscription) - - # If no subscription, they can use free tier - if not subscription: - subscription = { - 'price_id': config.STRIPE_FREE_TIER_ID, # Free tier - 'plan_name': 'free' - } - - # Extract price ID from subscription items - price_id = None - if subscription.get('items') and subscription['items'].get('data') and len(subscription['items']['data']) > 0: - price_id = subscription['items']['data'][0]['price']['id'] - else: - price_id = subscription.get('price_id', config.STRIPE_FREE_TIER_ID) - - # Get tier info - default to free tier if not found - tier_info = SUBSCRIPTION_TIERS.get(price_id) - if not tier_info: - logger.warning(f"Unknown subscription tier: {price_id}, defaulting to free tier") - tier_info = SUBSCRIPTION_TIERS[config.STRIPE_FREE_TIER_ID] - - # Calculate current month's usage - current_usage = await calculate_monthly_usage(client, user_id) - - # Check if within limits - if current_usage >= tier_info['minutes']: - return False, f"Monthly limit of {tier_info['minutes']} minutes reached. Please upgrade your plan or wait until next month.", subscription - - return True, "OK", subscription - -# API endpoints -@router.post("/create-checkout-session") -async def create_checkout_session( - request: CreateCheckoutSessionRequest, - current_user_id: str = Depends(get_current_user_id_from_jwt) -): - """Create a Stripe Checkout session or modify an existing subscription.""" - try: - # Get Supabase client - db = DBConnection() - client = await db.client - - # Get user email from auth.users - user_result = await client.auth.admin.get_user_by_id(current_user_id) - if not user_result: raise HTTPException(status_code=404, detail="User not found") - email = user_result.user.email - - # Get or create Stripe customer - customer_id = await get_stripe_customer_id(client, current_user_id) - if not customer_id: customer_id = await create_stripe_customer(client, current_user_id, email) - - # Get the target price and product ID - try: - price = stripe.Price.retrieve(request.price_id, expand=['product']) - product_id = price['product']['id'] - except stripe.error.InvalidRequestError: - raise HTTPException(status_code=400, detail=f"Invalid price ID: {request.price_id}") - - # Verify the price belongs to our product - if product_id != config.STRIPE_PRODUCT_ID: - raise HTTPException(status_code=400, detail="Price ID does not belong to the correct product.") - - # Check for existing subscription for our product - existing_subscription = await get_user_subscription(current_user_id) - # print("Existing subscription for product:", existing_subscription) - - if existing_subscription: - # --- Handle Subscription Change (Upgrade or Downgrade) --- - try: - subscription_id = existing_subscription['id'] - subscription_item = existing_subscription['items']['data'][0] - current_price_id = subscription_item['price']['id'] - - # Skip if already on this plan - if current_price_id == request.price_id: - return { - "subscription_id": subscription_id, - "status": "no_change", - "message": "Already subscribed to this plan.", - "details": { - "is_upgrade": None, - "effective_date": None, - "current_price": round(price['unit_amount'] / 100, 2) if price.get('unit_amount') else 0, - "new_price": round(price['unit_amount'] / 100, 2) if price.get('unit_amount') else 0, - } - } - - # Get current and new price details - current_price = stripe.Price.retrieve(current_price_id) - new_price = price # Already retrieved - is_upgrade = new_price['unit_amount'] > current_price['unit_amount'] - - if is_upgrade: - # --- Handle Upgrade --- Immediate modification - updated_subscription = stripe.Subscription.modify( - subscription_id, - items=[{ - 'id': subscription_item['id'], - 'price': request.price_id, - }], - proration_behavior='always_invoice', # Prorate and charge immediately - billing_cycle_anchor='now' # Reset billing cycle - ) - - # Update active status in database to true (customer has active subscription) - await client.schema('basejump').from_('billing_customers').update( - {'active': True} - ).eq('id', customer_id).execute() - logger.info(f"Updated customer {customer_id} active status to TRUE after subscription upgrade") - - latest_invoice = None - if updated_subscription.get('latest_invoice'): - latest_invoice = stripe.Invoice.retrieve(updated_subscription['latest_invoice']) - - return { - "subscription_id": updated_subscription['id'], - "status": "updated", - "message": "Subscription upgraded successfully", - "details": { - "is_upgrade": True, - "effective_date": "immediate", - "current_price": round(current_price['unit_amount'] / 100, 2) if current_price.get('unit_amount') else 0, - "new_price": round(new_price['unit_amount'] / 100, 2) if new_price.get('unit_amount') else 0, - "invoice": { - "id": latest_invoice['id'] if latest_invoice else None, - "status": latest_invoice['status'] if latest_invoice else None, - "amount_due": round(latest_invoice['amount_due'] / 100, 2) if latest_invoice else 0, - "amount_paid": round(latest_invoice['amount_paid'] / 100, 2) if latest_invoice else 0 - } if latest_invoice else None - } - } - else: - # --- Handle Downgrade --- Use Subscription Schedule - try: - current_period_end_ts = subscription_item['current_period_end'] - - # Retrieve the subscription again to get the schedule ID if it exists - # This ensures we have the latest state before creating/modifying schedule - sub_with_schedule = stripe.Subscription.retrieve(subscription_id) - schedule_id = sub_with_schedule.get('schedule') - - # Get the current phase configuration from the schedule or subscription - if schedule_id: - schedule = stripe.SubscriptionSchedule.retrieve(schedule_id) - # Find the current phase in the schedule - # This logic assumes simple schedules; might need refinement for complex ones - current_phase = None - for phase in reversed(schedule['phases']): - if phase['start_date'] <= datetime.now(timezone.utc).timestamp(): - current_phase = phase - break - if not current_phase: # Fallback if logic fails - current_phase = schedule['phases'][-1] - else: - # If no schedule, the current subscription state defines the current phase - current_phase = { - 'items': existing_subscription['items']['data'], # Use original items data - 'start_date': existing_subscription['current_period_start'], # Use sub start if no schedule - # Add other relevant fields if needed for create/modify - } - - # Prepare the current phase data for the update/create - # Ensure items is formatted correctly for the API - current_phase_items_for_api = [] - for item in current_phase.get('items', []): - price_data = item.get('price') - quantity = item.get('quantity') - price_id = None - - # Safely extract price ID whether it's an object or just the ID string - if isinstance(price_data, dict): - price_id = price_data.get('id') - elif isinstance(price_data, str): - price_id = price_data - - if price_id and quantity is not None: - current_phase_items_for_api.append({'price': price_id, 'quantity': quantity}) - else: - logger.warning(f"Skipping item in current phase due to missing price ID or quantity: {item}") - - if not current_phase_items_for_api: - raise ValueError("Could not determine valid items for the current phase.") - - current_phase_update_data = { - 'items': current_phase_items_for_api, - 'start_date': current_phase['start_date'], # Preserve original start date - 'end_date': current_period_end_ts, # End this phase at period end - 'proration_behavior': 'none' - # Include other necessary fields from current_phase if modifying? - # e.g., 'billing_cycle_anchor', 'collection_method'? Usually inherited. - } - - # Define the new (downgrade) phase - new_downgrade_phase_data = { - 'items': [{'price': request.price_id, 'quantity': 1}], - 'start_date': current_period_end_ts, # Start immediately after current phase ends - 'proration_behavior': 'none' - # iterations defaults to 1, meaning it runs for one billing cycle - # then schedule ends based on end_behavior - } - - # Update or Create Schedule - if schedule_id: - # Update existing schedule, replacing all future phases - # print(f"Updating existing schedule {schedule_id}") - logger.info(f"Updating existing schedule {schedule_id} for subscription {subscription_id}") - logger.debug(f"Current phase data: {current_phase_update_data}") - logger.debug(f"New phase data: {new_downgrade_phase_data}") - updated_schedule = stripe.SubscriptionSchedule.modify( - schedule_id, - phases=[current_phase_update_data, new_downgrade_phase_data], - end_behavior='release' - ) - logger.info(f"Successfully updated schedule {updated_schedule['id']}") - else: - # Create a new schedule using the defined phases - print(f"Creating new schedule for subscription {subscription_id}") - logger.info(f"Creating new schedule for subscription {subscription_id}") - # Deep debug logging - write subscription details to help diagnose issues - logger.debug(f"Subscription details: {subscription_id}, current_period_end_ts: {current_period_end_ts}") - logger.debug(f"Current price: {current_price_id}, New price: {request.price_id}") - - try: - updated_schedule = stripe.SubscriptionSchedule.create( - from_subscription=subscription_id, - phases=[ - { - 'start_date': current_phase['start_date'], - 'end_date': current_period_end_ts, - 'proration_behavior': 'none', - 'items': [ - { - 'price': current_price_id, - 'quantity': 1 - } - ] - }, - { - 'start_date': current_period_end_ts, - 'proration_behavior': 'none', - 'items': [ - { - 'price': request.price_id, - 'quantity': 1 - } - ] - } - ], - end_behavior='release' - ) - # Don't try to link the schedule - that's handled by from_subscription - logger.info(f"Created new schedule {updated_schedule['id']} from subscription {subscription_id}") - # print(f"Created new schedule {updated_schedule['id']} from subscription {subscription_id}") - - # Verify the schedule was created correctly - fetched_schedule = stripe.SubscriptionSchedule.retrieve(updated_schedule['id']) - logger.info(f"Schedule verification - Status: {fetched_schedule.get('status')}, Phase Count: {len(fetched_schedule.get('phases', []))}") - logger.debug(f"Schedule details: {fetched_schedule}") - except Exception as schedule_error: - logger.exception(f"Failed to create schedule: {str(schedule_error)}") - raise schedule_error # Re-raise to be caught by the outer try-except - - return { - "subscription_id": subscription_id, - "schedule_id": updated_schedule['id'], - "status": "scheduled", - "message": "Subscription downgrade scheduled", - "details": { - "is_upgrade": False, - "effective_date": "end_of_period", - "current_price": round(current_price['unit_amount'] / 100, 2) if current_price.get('unit_amount') else 0, - "new_price": round(new_price['unit_amount'] / 100, 2) if new_price.get('unit_amount') else 0, - "effective_at": datetime.fromtimestamp(current_period_end_ts, tz=timezone.utc).isoformat() - } - } - except Exception as e: - logger.exception(f"Error handling subscription schedule for sub {subscription_id}: {str(e)}") - raise HTTPException(status_code=500, detail=f"Error handling subscription schedule: {str(e)}") - except Exception as e: - logger.exception(f"Error updating subscription {existing_subscription.get('id') if existing_subscription else 'N/A'}: {str(e)}") - raise HTTPException(status_code=500, detail=f"Error updating subscription: {str(e)}") - else: - - session = stripe.checkout.Session.create( - customer=customer_id, - payment_method_types=['card'], - line_items=[{'price': request.price_id, 'quantity': 1}], - mode='subscription', - success_url=request.success_url, - cancel_url=request.cancel_url, - metadata={ - 'user_id': current_user_id, - 'product_id': product_id, - 'tolt_referral': request.tolt_referral - }, - allow_promotion_codes=True - ) - - # Update customer status to potentially active (will be confirmed by webhook) - # This ensures customer is marked as active once payment is completed - await client.schema('basejump').from_('billing_customers').update( - {'active': True} - ).eq('id', customer_id).execute() - logger.info(f"Updated customer {customer_id} active status to TRUE after creating checkout session") - - return {"session_id": session['id'], "url": session['url'], "status": "new"} - - except Exception as e: - logger.exception(f"Error creating checkout session: {str(e)}") - # Check if it's a Stripe error with more details - if hasattr(e, 'json_body') and e.json_body and 'error' in e.json_body: - error_detail = e.json_body['error'].get('message', str(e)) - else: - error_detail = str(e) - raise HTTPException(status_code=500, detail=f"Error creating checkout session: {error_detail}") - -@router.post("/create-portal-session") -async def create_portal_session( - request: CreatePortalSessionRequest, - current_user_id: str = Depends(get_current_user_id_from_jwt) -): - """Create a Stripe Customer Portal session for subscription management.""" - try: - # Get Supabase client - db = DBConnection() - client = await db.client - - # Get customer ID - customer_id = await get_stripe_customer_id(client, current_user_id) - if not customer_id: - raise HTTPException(status_code=404, detail="No billing customer found") - - # Ensure the portal configuration has subscription_update enabled - try: - # First, check if we have a configuration that already enables subscription update - configurations = stripe.billing_portal.Configuration.list(limit=100) - active_config = None - - # Look for a configuration with subscription_update enabled - for config in configurations.get('data', []): - features = config.get('features', {}) - subscription_update = features.get('subscription_update', {}) - if subscription_update.get('enabled', False): - active_config = config - logger.info(f"Found existing portal configuration with subscription_update enabled: {config['id']}") - break - - # If no config with subscription_update found, create one or update the active one - if not active_config: - # Find the active configuration or create a new one - if configurations.get('data', []): - default_config = configurations['data'][0] - logger.info(f"Updating default portal configuration: {default_config['id']} to enable subscription_update") - - active_config = stripe.billing_portal.Configuration.update( - default_config['id'], - features={ - 'subscription_update': { - 'enabled': True, - 'proration_behavior': 'create_prorations', - 'default_allowed_updates': ['price'] - }, - # Preserve other features that may already be enabled - 'customer_update': default_config.get('features', {}).get('customer_update', {'enabled': True, 'allowed_updates': ['email', 'address']}), - 'invoice_history': {'enabled': True}, - 'payment_method_update': {'enabled': True} - } - ) - else: - # Create a new configuration with subscription_update enabled - logger.info("Creating new portal configuration with subscription_update enabled") - active_config = stripe.billing_portal.Configuration.create( - business_profile={ - 'headline': 'Subscription Management', - 'privacy_policy_url': config.FRONTEND_URL + '/privacy', - 'terms_of_service_url': config.FRONTEND_URL + '/terms' - }, - features={ - 'subscription_update': { - 'enabled': True, - 'proration_behavior': 'create_prorations', - 'default_allowed_updates': ['price'] - }, - 'customer_update': { - 'enabled': True, - 'allowed_updates': ['email', 'address'] - }, - 'invoice_history': {'enabled': True}, - 'payment_method_update': {'enabled': True} - } - ) - - # Log the active configuration for debugging - logger.info(f"Using portal configuration: {active_config['id']} with subscription_update: {active_config.get('features', {}).get('subscription_update', {}).get('enabled', False)}") - - except Exception as config_error: - logger.warning(f"Error configuring portal: {config_error}. Continuing with default configuration.") - - # Create portal session using the proper configuration if available - portal_params = { - "customer": customer_id, - "return_url": request.return_url - } - - # Add configuration_id if we found or created one with subscription_update enabled - if active_config: - portal_params["configuration"] = active_config['id'] - - # Create the session - session = stripe.billing_portal.Session.create(**portal_params) - - return {"url": session.url} - - except Exception as e: - logger.error(f"Error creating portal session: {str(e)}") - raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/subscription") -async def get_subscription( - current_user_id: str = Depends(get_current_user_id_from_jwt) -): - """Get the current subscription status for the current user, including scheduled changes.""" - try: - # Get subscription from Stripe (this helper already handles filtering/cleanup) - subscription = await get_user_subscription(current_user_id) - # print("Subscription data for status:", subscription) - - if not subscription: - # Default to free tier status if no active subscription for our product - free_tier_id = config.STRIPE_FREE_TIER_ID - free_tier_info = SUBSCRIPTION_TIERS.get(free_tier_id) - return SubscriptionStatus( - status="no_subscription", - plan_name=free_tier_info.get('name', 'free') if free_tier_info else 'free', - price_id=free_tier_id, - minutes_limit=free_tier_info.get('minutes') if free_tier_info else 0 - ) - - # Extract current plan details - current_item = subscription['items']['data'][0] - current_price_id = current_item['price']['id'] - current_tier_info = SUBSCRIPTION_TIERS.get(current_price_id) - if not current_tier_info: - # Fallback if somehow subscribed to an unknown price within our product - logger.warning(f"User {current_user_id} subscribed to unknown price {current_price_id}. Defaulting info.") - current_tier_info = {'name': 'unknown', 'minutes': 0} - - # Calculate current usage - db = DBConnection() - client = await db.client - current_usage = await calculate_monthly_usage(client, current_user_id) - - status_response = SubscriptionStatus( - status=subscription['status'], # 'active', 'trialing', etc. - plan_name=subscription['plan'].get('nickname') or current_tier_info['name'], - price_id=current_price_id, - current_period_end=datetime.fromtimestamp(current_item['current_period_end'], tz=timezone.utc), - cancel_at_period_end=subscription['cancel_at_period_end'], - trial_end=datetime.fromtimestamp(subscription['trial_end'], tz=timezone.utc) if subscription.get('trial_end') else None, - minutes_limit=current_tier_info['minutes'], - current_usage=round(current_usage, 2), - has_schedule=False # Default - ) - - # Check for an attached schedule (indicates pending downgrade) - schedule_id = subscription.get('schedule') - if schedule_id: - try: - schedule = stripe.SubscriptionSchedule.retrieve(schedule_id) - # Find the *next* phase after the current one - next_phase = None - current_phase_end = current_item['current_period_end'] - - for phase in schedule.get('phases', []): - # Check if this phase starts exactly when the current one ends - if phase.get('start_date') == current_phase_end: - next_phase = phase - break # Found the immediate next phase - - if next_phase: - scheduled_item = next_phase['items'][0] # Assuming single item - scheduled_price_id = scheduled_item['price'] # Price ID might be string here - scheduled_tier_info = SUBSCRIPTION_TIERS.get(scheduled_price_id) - - status_response.has_schedule = True - status_response.status = 'scheduled_downgrade' # Override status - status_response.scheduled_plan_name = scheduled_tier_info.get('name', 'unknown') if scheduled_tier_info else 'unknown' - status_response.scheduled_price_id = scheduled_price_id - status_response.scheduled_change_date = datetime.fromtimestamp(next_phase['start_date'], tz=timezone.utc) - - except Exception as schedule_error: - logger.error(f"Error retrieving or parsing schedule {schedule_id} for sub {subscription['id']}: {schedule_error}") - # Proceed without schedule info if retrieval fails - - return status_response - - except Exception as e: - logger.exception(f"Error getting subscription status for user {current_user_id}: {str(e)}") # Use logger.exception - raise HTTPException(status_code=500, detail="Error retrieving subscription status.") - -@router.get("/check-status") -async def check_status( - current_user_id: str = Depends(get_current_user_id_from_jwt) -): - """Check if the user can run agents based on their subscription and usage.""" - try: - # Get Supabase client - db = DBConnection() - client = await db.client - - can_run, message, subscription = await check_billing_status(client, current_user_id) - - return { - "can_run": can_run, - "message": message, - "subscription": subscription - } - - except Exception as e: - logger.error(f"Error checking billing status: {str(e)}") - raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/webhook") -async def stripe_webhook(request: Request): - """Handle Stripe webhook events.""" - try: - # Get the webhook secret from config - webhook_secret = config.STRIPE_WEBHOOK_SECRET - - # Get the webhook payload - payload = await request.body() - sig_header = request.headers.get('stripe-signature') - - # Verify webhook signature - try: - event = stripe.Webhook.construct_event( - payload, sig_header, webhook_secret - ) - except ValueError as e: - raise HTTPException(status_code=400, detail="Invalid payload") - except stripe.error.SignatureVerificationError as e: - raise HTTPException(status_code=400, detail="Invalid signature") - - # Handle the event - if event.type in ['customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted']: - # Extract the subscription and customer information - subscription = event.data.object - customer_id = subscription.get('customer') - - if not customer_id: - logger.warning(f"No customer ID found in subscription event: {event.type}") - return {"status": "error", "message": "No customer ID found"} - - # Get database connection - db = DBConnection() - client = await db.client - - if event.type == 'customer.subscription.created' or event.type == 'customer.subscription.updated': - # Check if subscription is active - if subscription.get('status') in ['active', 'trialing']: - # Update customer's active status to true - await client.schema('basejump').from_('billing_customers').update( - {'active': True} - ).eq('id', customer_id).execute() - logger.info(f"Webhook: Updated customer {customer_id} active status to TRUE based on {event.type}") - else: - # Subscription is not active (e.g., past_due, canceled, etc.) - # Check if customer has any other active subscriptions before updating status - has_active = len(stripe.Subscription.list( - customer=customer_id, - status='active', - limit=1 - ).get('data', [])) > 0 - - if not has_active: - await client.schema('basejump').from_('billing_customers').update( - {'active': False} - ).eq('id', customer_id).execute() - logger.info(f"Webhook: Updated customer {customer_id} active status to FALSE based on {event.type}") - - elif event.type == 'customer.subscription.deleted': - # Check if customer has any other active subscriptions - has_active = len(stripe.Subscription.list( - customer=customer_id, - status='active', - limit=1 - ).get('data', [])) > 0 - - if not has_active: - # If no active subscriptions left, set active to false - await client.schema('basejump').from_('billing_customers').update( - {'active': False} - ).eq('id', customer_id).execute() - logger.info(f"Webhook: Updated customer {customer_id} active status to FALSE after subscription deletion") - - logger.info(f"Processed {event.type} event for customer {customer_id}") - - return {"status": "success"} - - except Exception as e: - logger.error(f"Error processing webhook: {str(e)}") - raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/available-models") -async def get_available_models( - current_user_id: str = Depends(get_current_user_id_from_jwt) -): - """Get the list of models available to the user based on their subscription tier.""" - try: - # Get Supabase client - db = DBConnection() - client = await db.client - - # Check if we're in local development mode - if config.ENV_MODE == EnvMode.LOCAL: - logger.info("Running in local development mode - billing checks are disabled") - - # In local mode, return all models from MODEL_NAME_ALIASES - model_info = [] - for short_name, full_name in MODEL_NAME_ALIASES.items(): - # Skip entries where the key is a full name to avoid duplicates - # if short_name == full_name or '/' in short_name: - # continue - - model_info.append({ - "id": full_name, - "display_name": short_name, - "short_name": short_name, - "requires_subscription": False # Always false in local dev mode - }) - - return { - "models": model_info, - "subscription_tier": "Local Development", - "total_models": len(model_info) - } - - # For non-local mode, get list of allowed models for this user - allowed_models = await get_allowed_models_for_user(client, current_user_id) - free_tier_models = MODEL_ACCESS_TIERS.get('free', []) - - # Get subscription info for context - subscription = await get_user_subscription(current_user_id) - - # Determine tier name from subscription - tier_name = 'free' - if subscription: - price_id = None - if subscription.get('items') and subscription['items'].get('data') and len(subscription['items']['data']) > 0: - price_id = subscription['items']['data'][0]['price']['id'] - else: - price_id = subscription.get('price_id', config.STRIPE_FREE_TIER_ID) - - # Get tier info for this price_id - tier_info = SUBSCRIPTION_TIERS.get(price_id) - if tier_info: - tier_name = tier_info['name'] - - # Get all unique full model names from MODEL_NAME_ALIASES - all_models = set() - model_aliases = {} - - for short_name, full_name in MODEL_NAME_ALIASES.items(): - # Add all unique full model names - all_models.add(full_name) - - # Only include short names that don't match their full names for aliases - if short_name != full_name and not short_name.startswith("openai/") and not short_name.startswith("anthropic/") and not short_name.startswith("openrouter/") and not short_name.startswith("xai/"): - if full_name not in model_aliases: - model_aliases[full_name] = short_name - - # Create model info with display names for ALL models - model_info = [] - for model in all_models: - display_name = model_aliases.get(model, model.split('/')[-1] if '/' in model else model) - - # Check if model requires subscription (not in free tier) - requires_sub = model not in free_tier_models - - # Check if model is available with current subscription - is_available = model in allowed_models - - model_info.append({ - "id": model, - "display_name": display_name, - "short_name": model_aliases.get(model), - "requires_subscription": requires_sub, - "is_available": is_available - }) - - return { - "models": model_info, - "subscription_tier": tier_name, - "total_models": len(model_info) - } - - except Exception as e: - logger.error(f"Error getting available models: {str(e)}") - raise HTTPException(status_code=500, detail=f"Error getting available models: {str(e)}") \ No newline at end of file diff --git a/app/services/docker/redis.conf b/app/services/docker/redis.conf deleted file mode 100644 index b8b418006..000000000 --- a/app/services/docker/redis.conf +++ /dev/null @@ -1 +0,0 @@ -timeout 120 diff --git a/app/services/email.py b/app/services/email.py deleted file mode 100644 index 76dcb4740..000000000 --- a/app/services/email.py +++ /dev/null @@ -1,192 +0,0 @@ -import os -import logging -from typing import Optional -import mailtrap as mt -from utils.config import config - -logger = logging.getLogger(__name__) - -class EmailService: - def __init__(self): - self.api_token = os.getenv('MAILTRAP_API_TOKEN') - self.sender_email = os.getenv('MAILTRAP_SENDER_EMAIL', 'dom@kortix.ai') - self.sender_name = os.getenv('MAILTRAP_SENDER_NAME', 'Suna Team') - - if not self.api_token: - logger.warning("MAILTRAP_API_TOKEN not found in environment variables") - self.client = None - else: - self.client = mt.MailtrapClient(token=self.api_token) - - def send_welcome_email(self, user_email: str, user_name: Optional[str] = None) -> bool: - if not self.client: - logger.error("Cannot send email: MAILTRAP_API_TOKEN not configured") - return False - - if not user_name: - user_name = user_email.split('@')[0].title() - - subject = "🎉 Welcome to Suna — Let's Get Started " - html_content = self._get_welcome_email_template(user_name) - text_content = self._get_welcome_email_text(user_name) - - return self._send_email( - to_email=user_email, - to_name=user_name, - subject=subject, - html_content=html_content, - text_content=text_content - ) - - def _send_email( - self, - to_email: str, - to_name: str, - subject: str, - html_content: str, - text_content: str - ) -> bool: - try: - mail = mt.Mail( - sender=mt.Address(email=self.sender_email, name=self.sender_name), - to=[mt.Address(email=to_email, name=to_name)], - subject=subject, - text=text_content, - html=html_content, - category="welcome" - ) - - response = self.client.send(mail) - - logger.info(f"Welcome email sent to {to_email}. Response: {response}") - return True - - except Exception as e: - logger.error(f"Error sending email to {to_email}: {str(e)}") - return False - - def _get_welcome_email_template(self, user_name: str) -> str: - return f""" - - - - - Welcome to Kortix Suna - - - -
-
- -
-

Welcome to Kortix Suna!

- -

Hi {user_name},

- -

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

- -

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

- -

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

- -

🎁 Use code WELCOME15 at checkout.

- -

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

- -

For your business: if you want to automate manual and ordinary tasks for your company, book a call with us here

- -

Thanks again, and welcome to the Suna community 🌞

- -

— The Suna Team

- - Go to the platform -
- -""" - - def _get_welcome_email_text(self, user_name: str) -> str: - return f"""Hi {user_name}, - -Welcome to Suna — we're excited to have you on board! - -To get started, we'd like to get to know you better: fill out this short form! -https://docs.google.com/forms/d/e/1FAIpQLSef1EHuqmIh_iQz-kwhjnzSC3Ml-V_5wIySDpMoMU9W_j24JQ/viewform - -To celebrate your arrival, here's a 15% discount to try out the best version of Suna (1 month): -🎁 Use code WELCOME15 at checkout. - -Let us know if you need help getting started or have questions — we're always here, and join our Discord community: https://discord.com/invite/FjD644cfcs - -For your business: if you want to automate manual and ordinary tasks for your company, book a call with us here: https://cal.com/team/kortix/enterprise-demo - -Thanks again, and welcome to the Suna community 🌞 - -— The Suna Team - -Go to the platform: https://www.suna.so/ - ---- -© 2024 Suna. All rights reserved. -You received this email because you signed up for a Suna account.""" - -email_service = EmailService() diff --git a/app/services/email_api.py b/app/services/email_api.py deleted file mode 100644 index 9834c7baf..000000000 --- a/app/services/email_api.py +++ /dev/null @@ -1,70 +0,0 @@ -from fastapi import APIRouter, HTTPException, Depends -from pydantic import BaseModel, EmailStr -from typing import Optional -import asyncio -from services.email import email_service -from utils.logger import logger - -router = APIRouter() - -class SendWelcomeEmailRequest(BaseModel): - email: EmailStr - name: Optional[str] = None - -class EmailResponse(BaseModel): - success: bool - message: str - -@router.post("/send-welcome-email", response_model=EmailResponse) -async def send_welcome_email(request: SendWelcomeEmailRequest): - try: - logger.info(f"Sending welcome email to {request.email}") - success = email_service.send_welcome_email( - user_email=request.email, - user_name=request.name - ) - - if success: - return EmailResponse( - success=True, - message="Welcome email sent successfully" - ) - else: - return EmailResponse( - success=False, - message="Failed to send welcome email" - ) - - except Exception as e: - logger.error(f"Error sending welcome email to {request.email}: {str(e)}") - raise HTTPException( - status_code=500, - detail="Internal server error while sending email" - ) - -@router.post("/send-welcome-email-background", response_model=EmailResponse) -async def send_welcome_email_background(request: SendWelcomeEmailRequest): - try: - logger.info(f"Queuing welcome email for {request.email}") - - def send_email(): - return email_service.send_welcome_email( - user_email=request.email, - user_name=request.name - ) - - import concurrent.futures - with concurrent.futures.ThreadPoolExecutor() as executor: - future = executor.submit(send_email) - - return EmailResponse( - success=True, - message="Welcome email queued for sending" - ) - - except Exception as e: - logger.error(f"Error queuing welcome email for {request.email}: {str(e)}") - raise HTTPException( - status_code=500, - detail="Internal server error while queuing email" - ) diff --git a/app/services/langfuse.py b/app/services/langfuse.py deleted file mode 100644 index cf624bf3b..000000000 --- a/app/services/langfuse.py +++ /dev/null @@ -1,12 +0,0 @@ -import os -from langfuse import Langfuse - -public_key = os.getenv("LANGFUSE_PUBLIC_KEY") -secret_key = os.getenv("LANGFUSE_SECRET_KEY") -host = os.getenv("LANGFUSE_HOST", "https://cloud.langfuse.com") - -enabled = False -if public_key and secret_key: - enabled = True - -langfuse = Langfuse(enabled=enabled) diff --git a/app/services/llm.py b/app/services/llm.py deleted file mode 100644 index f525f244a..000000000 --- a/app/services/llm.py +++ /dev/null @@ -1,411 +0,0 @@ -""" -LLM API interface for making calls to various language models. - -This module provides a unified interface for making API calls to different LLM providers -(OpenAI, Anthropic, Groq, etc.) using LiteLLM. It includes support for: -- Streaming responses -- Tool calls and function calling -- Retry logic with exponential backoff -- Model-specific configurations -- Comprehensive error handling and logging -""" - -from typing import Union, Dict, Any, Optional, AsyncGenerator, List -import os -import json -import asyncio -from openai import OpenAIError -import litellm -# from utils.logger import logger -# from utils.config import config - -# litellm.set_verbose=True -litellm.modify_params=True - -# Constants -MAX_RETRIES = 2 -RATE_LIMIT_DELAY = 30 -RETRY_DELAY = 0.1 - -class LLMError(Exception): - """Base exception for LLM-related errors.""" - pass - -class LLMRetryError(LLMError): - """Exception raised when retries are exhausted.""" - pass - -def setup_api_keys() -> None: - """Set up API keys from environment variables.""" - providers = ['OPENAI', 'ANTHROPIC', 'GROQ', 'OPENROUTER'] - for provider in providers: - key = getattr(config, f'{provider}_API_KEY') - if key: - logger.debug(f"API key set for provider: {provider}") - else: - logger.warning(f"No API key found for provider: {provider}") - - # Set up OpenRouter API base if not already set - if config.OPENROUTER_API_KEY and config.OPENROUTER_API_BASE: - os.environ['OPENROUTER_API_BASE'] = config.OPENROUTER_API_BASE - logger.debug(f"Set OPENROUTER_API_BASE to {config.OPENROUTER_API_BASE}") - - # Set up AWS Bedrock credentials - aws_access_key = config.AWS_ACCESS_KEY_ID - aws_secret_key = config.AWS_SECRET_ACCESS_KEY - aws_region = config.AWS_REGION_NAME - - if aws_access_key and aws_secret_key and aws_region: - logger.debug(f"AWS credentials set for Bedrock in region: {aws_region}") - # Configure LiteLLM to use AWS credentials - os.environ['AWS_ACCESS_KEY_ID'] = aws_access_key - os.environ['AWS_SECRET_ACCESS_KEY'] = aws_secret_key - os.environ['AWS_REGION_NAME'] = aws_region - else: - logger.warning(f"Missing AWS credentials for Bedrock integration - access_key: {bool(aws_access_key)}, secret_key: {bool(aws_secret_key)}, region: {aws_region}") - -async def handle_error(error: Exception, attempt: int, max_attempts: int) -> None: - """Handle API errors with appropriate delays and logging.""" - delay = RATE_LIMIT_DELAY if isinstance(error, litellm.exceptions.RateLimitError) else RETRY_DELAY - logger.warning(f"Error on attempt {attempt + 1}/{max_attempts}: {str(error)}") - logger.debug(f"Waiting {delay} seconds before retry...") - await asyncio.sleep(delay) - -def prepare_params( - messages: List[Dict[str, Any]], - model_name: str, - temperature: float = 0, - max_tokens: Optional[int] = None, - response_format: Optional[Any] = None, - tools: Optional[List[Dict[str, Any]]] = None, - tool_choice: str = "auto", - api_key: Optional[str] = None, - api_base: Optional[str] = None, - stream: bool = False, - top_p: Optional[float] = None, - model_id: Optional[str] = None, - enable_thinking: Optional[bool] = False, - reasoning_effort: Optional[str] = 'low' -) -> Dict[str, Any]: - """Prepare parameters for the API call.""" - params = { - "model": model_name, - "messages": messages, - "temperature": temperature, - "response_format": response_format, - "top_p": top_p, - "stream": stream, - } - - if api_key: - params["api_key"] = api_key - if api_base: - params["api_base"] = api_base - if model_id: - params["model_id"] = model_id - - # Handle token limits - if max_tokens is not None: - # For Claude 3.7 in Bedrock, do not set max_tokens or max_tokens_to_sample - # as it causes errors with inference profiles - if model_name.startswith("bedrock/") and "claude-3-7" in model_name: - logger.debug(f"Skipping max_tokens for Claude 3.7 model: {model_name}") - # Do not add any max_tokens parameter for Claude 3.7 - else: - param_name = "max_completion_tokens" if 'o1' in model_name else "max_tokens" - params[param_name] = max_tokens - - # Add tools if provided - if tools: - params.update({ - "tools": tools, - "tool_choice": tool_choice - }) - logger.debug(f"Added {len(tools)} tools to API parameters") - - # # Add Claude-specific headers - if "claude" in model_name.lower() or "anthropic" in model_name.lower(): - params["extra_headers"] = { - # "anthropic-beta": "max-tokens-3-5-sonnet-2024-07-15" - "anthropic-beta": "output-128k-2025-02-19" - } - params["fallbacks"] = [{ - "model": "openrouter/anthropic/claude-sonnet-4", - "messages": messages, - }] - logger.debug("Added Claude-specific headers") - - # Add OpenRouter-specific parameters - if model_name.startswith("openrouter/"): - logger.debug(f"Preparing OpenRouter parameters for model: {model_name}") - - # Add optional site URL and app name from config - site_url = config.OR_SITE_URL - app_name = config.OR_APP_NAME - if site_url or app_name: - extra_headers = params.get("extra_headers", {}) - if site_url: - extra_headers["HTTP-Referer"] = site_url - if app_name: - extra_headers["X-Title"] = app_name - params["extra_headers"] = extra_headers - logger.debug(f"Added OpenRouter site URL and app name to headers") - - # Add Bedrock-specific parameters - if model_name.startswith("bedrock/"): - logger.debug(f"Preparing AWS Bedrock parameters for model: {model_name}") - - if not model_id and "anthropic.claude-3-7-sonnet" in model_name: - params["model_id"] = "arn:aws:bedrock:us-west-2:935064898258:inference-profile/us.anthropic.claude-3-7-sonnet-20250219-v1:0" - logger.debug(f"Auto-set model_id for Claude 3.7 Sonnet: {params['model_id']}") - - # Apply Anthropic prompt caching (minimal implementation) - # Check model name *after* potential modifications (like adding bedrock/ prefix) - effective_model_name = params.get("model", model_name) # Use model from params if set, else original - if "claude" in effective_model_name.lower() or "anthropic" in effective_model_name.lower(): - messages = params["messages"] # Direct reference, modification affects params - - # Ensure messages is a list - if not isinstance(messages, list): - return params # Return early if messages format is unexpected - - # 1. Process the first message if it's a system prompt with string content - if messages and messages[0].get("role") == "system": - content = messages[0].get("content") - if isinstance(content, str): - # Wrap the string content in the required list structure - messages[0]["content"] = [ - {"type": "text", "text": content, "cache_control": {"type": "ephemeral"}} - ] - elif isinstance(content, list): - # If content is already a list, check if the first text block needs cache_control - for item in content: - if isinstance(item, dict) and item.get("type") == "text": - if "cache_control" not in item: - item["cache_control"] = {"type": "ephemeral"} - break # Apply to the first text block only for system prompt - - # 2. Find and process relevant user and assistant messages (limit to 4 max) - last_user_idx = -1 - second_last_user_idx = -1 - last_assistant_idx = -1 - - for i in range(len(messages) - 1, -1, -1): - role = messages[i].get("role") - if role == "user": - if last_user_idx == -1: - last_user_idx = i - elif second_last_user_idx == -1: - second_last_user_idx = i - elif role == "assistant": - if last_assistant_idx == -1: - last_assistant_idx = i - - # Stop searching if we've found all needed messages (system, last user, second last user, last assistant) - found_count = sum(idx != -1 for idx in [last_user_idx, second_last_user_idx, last_assistant_idx]) - if found_count >= 3: - break - - # Helper function to apply cache control - def apply_cache_control(message_idx: int, message_role: str): - if message_idx == -1: - return - - message = messages[message_idx] - content = message.get("content") - - if isinstance(content, str): - message["content"] = [ - {"type": "text", "text": content, "cache_control": {"type": "ephemeral"}} - ] - elif isinstance(content, list): - for item in content: - if isinstance(item, dict) and item.get("type") == "text": - if "cache_control" not in item: - item["cache_control"] = {"type": "ephemeral"} - - # Apply cache control to the identified messages (max 4: system, last user, second last user, last assistant) - # System message is always at index 0 if present - apply_cache_control(0, "system") - apply_cache_control(last_user_idx, "last user") - apply_cache_control(second_last_user_idx, "second last user") - apply_cache_control(last_assistant_idx, "last assistant") - - # Add reasoning_effort for Anthropic models if enabled - use_thinking = enable_thinking if enable_thinking is not None else False - is_anthropic = "anthropic" in effective_model_name.lower() or "claude" in effective_model_name.lower() - - if is_anthropic and use_thinking: - effort_level = reasoning_effort if reasoning_effort else 'low' - params["reasoning_effort"] = effort_level - params["temperature"] = 1.0 # Required by Anthropic when reasoning_effort is used - logger.info(f"Anthropic thinking enabled with reasoning_effort='{effort_level}'") - - return params - -async def make_llm_api_call( - messages: List[Dict[str, Any]], - model_name: str, - response_format: Optional[Any] = None, - temperature: float = 0, - max_tokens: Optional[int] = None, - tools: Optional[List[Dict[str, Any]]] = None, - tool_choice: str = "auto", - api_key: Optional[str] = None, - api_base: Optional[str] = None, - stream: bool = False, - top_p: Optional[float] = None, - model_id: Optional[str] = None, - enable_thinking: Optional[bool] = False, - reasoning_effort: Optional[str] = 'low' -) -> Union[Dict[str, Any], AsyncGenerator]: - """ - Make an API call to a language model using LiteLLM. - - Args: - messages: List of message dictionaries for the conversation - model_name: Name of the model to use (e.g., "gpt-4", "claude-3", "openrouter/openai/gpt-4", "bedrock/anthropic.claude-3-sonnet-20240229-v1:0") - response_format: Desired format for the response - temperature: Sampling temperature (0-1) - max_tokens: Maximum tokens in the response - tools: List of tool definitions for function calling - tool_choice: How to select tools ("auto" or "none") - api_key: Override default API key - api_base: Override default API base URL - stream: Whether to stream the response - top_p: Top-p sampling parameter - model_id: Optional ARN for Bedrock inference profiles - enable_thinking: Whether to enable thinking - reasoning_effort: Level of reasoning effort - - Returns: - Union[Dict[str, Any], AsyncGenerator]: API response or stream - - Raises: - LLMRetryError: If API call fails after retries - LLMError: For other API-related errors - """ - # debug .json messages - logger.info(f"Making LLM API call to model: {model_name} (Thinking: {enable_thinking}, Effort: {reasoning_effort})") - logger.info(f"📡 API Call: Using model {model_name}") - params = prepare_params( - messages=messages, - model_name=model_name, - temperature=temperature, - max_tokens=max_tokens, - response_format=response_format, - tools=tools, - tool_choice=tool_choice, - api_key=api_key, - api_base=api_base, - stream=stream, - top_p=top_p, - model_id=model_id, - enable_thinking=enable_thinking, - reasoning_effort=reasoning_effort - ) - last_error = None - for attempt in range(MAX_RETRIES): - try: - logger.debug(f"Attempt {attempt + 1}/{MAX_RETRIES}") - # logger.debug(f"API request parameters: {json.dumps(params, indent=2)}") - - response = await litellm.acompletion(**params) - logger.debug(f"Successfully received API response from {model_name}") - logger.debug(f"Response: {response}") - return response - - except (litellm.exceptions.RateLimitError, OpenAIError, json.JSONDecodeError) as e: - last_error = e - await handle_error(e, attempt, MAX_RETRIES) - - except Exception as e: - logger.error(f"Unexpected error during API call: {str(e)}", exc_info=True) - raise LLMError(f"API call failed: {str(e)}") - - error_msg = f"Failed to make API call after {MAX_RETRIES} attempts" - if last_error: - error_msg += f". Last error: {str(last_error)}" - logger.error(error_msg, exc_info=True) - raise LLMRetryError(error_msg) - -# Initialize API keys on module import -setup_api_keys() - -# Test code for OpenRouter integration -async def test_openrouter(): - """Test the OpenRouter integration with a simple query.""" - test_messages = [ - {"role": "user", "content": "Hello, can you give me a quick test response?"} - ] - - try: - # Test with standard OpenRouter model - print("\n--- Testing standard OpenRouter model ---") - response = await make_llm_api_call( - model_name="openrouter/openai/gpt-4o-mini", - messages=test_messages, - temperature=0.7, - max_tokens=100 - ) - print(f"Response: {response.choices[0].message.content}") - - # Test with deepseek model - print("\n--- Testing deepseek model ---") - response = await make_llm_api_call( - model_name="openrouter/deepseek/deepseek-r1-distill-llama-70b", - messages=test_messages, - temperature=0.7, - max_tokens=100 - ) - print(f"Response: {response.choices[0].message.content}") - print(f"Model used: {response.model}") - - # Test with Mistral model - print("\n--- Testing Mistral model ---") - response = await make_llm_api_call( - model_name="openrouter/mistralai/mixtral-8x7b-instruct", - messages=test_messages, - temperature=0.7, - max_tokens=100 - ) - print(f"Response: {response.choices[0].message.content}") - print(f"Model used: {response.model}") - - return True - except Exception as e: - print(f"Error testing OpenRouter: {str(e)}") - return False - -async def test_bedrock(): - """Test the AWS Bedrock integration with a simple query.""" - test_messages = [ - {"role": "user", "content": "Hello, can you give me a quick test response?"} - ] - - try: - response = await make_llm_api_call( - model_name="bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0", - model_id="arn:aws:bedrock:us-west-2:935064898258:inference-profile/us.anthropic.claude-3-7-sonnet-20250219-v1:0", - messages=test_messages, - temperature=0.7, - # Claude 3.7 has issues with max_tokens, so omit it - # max_tokens=100 - ) - print(f"Response: {response.choices[0].message.content}") - print(f"Model used: {response.model}") - - return True - except Exception as e: - print(f"Error testing Bedrock: {str(e)}") - return False - -if __name__ == "__main__": - import asyncio - - test_success = asyncio.run(test_bedrock()) - - if test_success: - print("\n✅ integration test completed successfully!") - else: - print("\n❌ Bedrock integration test failed!") diff --git a/app/services/mcp_custom.py b/app/services/mcp_custom.py deleted file mode 100644 index 1a9c245e1..000000000 --- a/app/services/mcp_custom.py +++ /dev/null @@ -1,129 +0,0 @@ -import os -import sys -import json -import asyncio -import subprocess -from typing import Dict, Any -from concurrent.futures import ThreadPoolExecutor -from fastapi import HTTPException # type: ignore -from utils.logger import logger -from mcp import ClientSession -from mcp.client.sse import sse_client # type: ignore -from mcp.client.streamable_http import streamablehttp_client # type: ignore - -async def connect_streamable_http_server(url): - async with streamablehttp_client(url) as ( - read_stream, - write_stream, - _, - ): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - tool_result = await session.list_tools() - print(f"Connected via HTTP ({len(tool_result.tools)} tools)") - - tools_info = [] - for tool in tool_result.tools: - tool_info = { - "name": tool.name, - "description": tool.description, - "inputSchema": tool.inputSchema - } - tools_info.append(tool_info) - - return tools_info - -async def discover_custom_tools(request_type: str, config: Dict[str, Any]): - logger.info(f"Received custom MCP discovery request: type={request_type}") - logger.debug(f"Request config: {config}") - - tools = [] - server_name = None - - if request_type == 'http': - if 'url' not in config: - raise HTTPException(status_code=400, detail="HTTP configuration must include 'url' field") - url = config['url'] - - try: - async with asyncio.timeout(15): - tools_info = await connect_streamable_http_server(url) - for tool_info in tools_info: - tools.append({ - "name": tool_info["name"], - "description": tool_info["description"], - "inputSchema": tool_info["inputSchema"] - }) - except asyncio.TimeoutError: - raise HTTPException(status_code=408, detail="Connection timeout - server took too long to respond") - except Exception as e: - logger.error(f"Error connecting to HTTP MCP server: {e}") - raise HTTPException(status_code=400, detail=f"Failed to connect to MCP server: {str(e)}") - - elif request_type == 'sse': - if 'url' not in config: - raise HTTPException(status_code=400, detail="SSE configuration must include 'url' field") - - url = config['url'] - headers = config.get('headers', {}) - - try: - async with asyncio.timeout(15): - try: - async with sse_client(url, headers=headers) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - tools_result = await session.list_tools() - tools_info = [] - for tool in tools_result.tools: - tool_info = { - "name": tool.name, - "description": tool.description, - "input_schema": tool.inputSchema - } - tools_info.append(tool_info) - - for tool_info in tools_info: - tools.append({ - "name": tool_info["name"], - "description": tool_info["description"], - "inputSchema": tool_info["input_schema"] - }) - except TypeError as e: - if "unexpected keyword argument" in str(e): - async with sse_client(url) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - tools_result = await session.list_tools() - tools_info = [] - for tool in tools_result.tools: - tool_info = { - "name": tool.name, - "description": tool.description, - "input_schema": tool.inputSchema - } - tools_info.append(tool_info) - - for tool_info in tools_info: - tools.append({ - "name": tool_info["name"], - "description": tool_info["description"], - "inputSchema": tool_info["input_schema"] - }) - else: - raise - except asyncio.TimeoutError: - raise HTTPException(status_code=408, detail="Connection timeout - server took too long to respond") - except Exception as e: - logger.error(f"Error connecting to SSE MCP server: {e}") - raise HTTPException(status_code=400, detail=f"Failed to connect to MCP server: {str(e)}") - else: - raise HTTPException(status_code=400, detail="Invalid server type. Must be 'http' or 'sse'") - - response_data = {"tools": tools, "count": len(tools)} - - if server_name: - response_data["serverName"] = server_name - - logger.info(f"Returning {len(tools)} tools for server {server_name}") - return response_data diff --git a/app/services/mcp_temp.py b/app/services/mcp_temp.py deleted file mode 100644 index 71d3c01c7..000000000 --- a/app/services/mcp_temp.py +++ /dev/null @@ -1,299 +0,0 @@ -import os -import sys -import json -import asyncio -import subprocess -from typing import Dict, Any -from concurrent.futures import ThreadPoolExecutor -from fastapi import HTTPException # type: ignore -from utils.logger import logger -from mcp import ClientSession -from mcp.client.sse import sse_client # type: ignore -from mcp.client.streamable_http import streamablehttp_client # type: ignore - -windows_executor = ThreadPoolExecutor(max_workers=4) - -# def run_mcp_stdio_sync(command, args, env_vars, timeout=30): -# try: -# env = os.environ.copy() -# env.update(env_vars) - -# full_command = [command] + args - -# process = subprocess.Popen( -# full_command, -# stdin=subprocess.PIPE, -# stdout=subprocess.PIPE, -# stderr=subprocess.PIPE, -# env=env, -# text=True, -# bufsize=0, -# creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0 -# ) - -# init_request = { -# "jsonrpc": "2.0", -# "id": 1, -# "method": "initialize", -# "params": { -# "protocolVersion": "2024-11-05", -# "capabilities": {}, -# "clientInfo": {"name": "mcp-client", "version": "1.0.0"} -# } -# } - -# process.stdin.write(json.dumps(init_request) + "\n") -# process.stdin.flush() - -# init_response_line = process.stdout.readline().strip() -# if not init_response_line: -# raise Exception("No response from MCP server during initialization") - -# init_response = json.loads(init_response_line) - -# init_notification = { -# "jsonrpc": "2.0", -# "method": "notifications/initialized" -# } -# process.stdin.write(json.dumps(init_notification) + "\n") -# process.stdin.flush() - -# tools_request = { -# "jsonrpc": "2.0", -# "id": 2, -# "method": "tools/list", -# "params": {} -# } - -# process.stdin.write(json.dumps(tools_request) + "\n") -# process.stdin.flush() - -# tools_response_line = process.stdout.readline().strip() -# if not tools_response_line: -# raise Exception("No response from MCP server for tools list") - -# tools_response = json.loads(tools_response_line) - -# tools_info = [] -# if "result" in tools_response and "tools" in tools_response["result"]: -# for tool in tools_response["result"]["tools"]: -# tool_info = { -# "name": tool["name"], -# "description": tool.get("description", ""), -# "input_schema": tool.get("inputSchema", {}) -# } -# tools_info.append(tool_info) - -# return { -# "status": "connected", -# "transport": "stdio", -# "tools": tools_info -# } - -# except subprocess.TimeoutExpired: -# return { -# "status": "error", -# "error": f"Process timeout after {timeout} seconds", -# "tools": [] -# } -# except json.JSONDecodeError as e: -# return { -# "status": "error", -# "error": f"Invalid JSON response: {str(e)}", -# "tools": [] -# } -# except Exception as e: -# return { -# "status": "error", -# "error": str(e), -# "tools": [] -# } -# finally: -# try: -# if 'process' in locals(): -# process.terminate() -# process.wait(timeout=5) -# except: -# pass - - -# async def connect_stdio_server_windows(server_name, server_config, all_tools, timeout): -# """Windows-compatible stdio connection using subprocess""" - -# logger.info(f"Connecting to {server_name} using Windows subprocess method") - -# command = server_config["command"] -# args = server_config.get("args", []) -# env_vars = server_config.get("env", {}) - -# loop = asyncio.get_event_loop() -# result = await loop.run_in_executor( -# windows_executor, -# run_mcp_stdio_sync, -# command, -# args, -# env_vars, -# timeout -# ) - -# all_tools[server_name] = result - -# if result["status"] == "connected": -# logger.info(f" {server_name}: Connected via Windows subprocess ({len(result['tools'])} tools)") -# else: -# logger.error(f" {server_name}: Error - {result['error']}") - - -# async def list_mcp_tools_mixed_windows(config, timeout=15): -# all_tools = {} - -# if "mcpServers" not in config: -# return all_tools - -# mcp_servers = config["mcpServers"] - -# for server_name, server_config in mcp_servers.items(): -# logger.info(f"Connecting to MCP server: {server_name}") -# if server_config.get("disabled", False): -# all_tools[server_name] = {"status": "disabled", "tools": []} -# logger.info(f" {server_name}: Disabled") -# continue - -# try: -# await connect_stdio_server_windows(server_name, server_config, all_tools, timeout) - -# except asyncio.TimeoutError: -# all_tools[server_name] = { -# "status": "error", -# "error": f"Connection timeout after {timeout} seconds", -# "tools": [] -# } -# logger.error(f" {server_name}: Timeout after {timeout} seconds") -# except Exception as e: -# error_msg = str(e) -# all_tools[server_name] = { -# "status": "error", -# "error": error_msg, -# "tools": [] -# } -# logger.error(f" {server_name}: Error - {error_msg}") -# import traceback -# logger.debug(f"Full traceback for {server_name}: {traceback.format_exc()}") - -# return all_tools - - -async def discover_custom_tools(request_type: str, config: Dict[str, Any]): - logger.info(f"Received custom MCP discovery request: type={request_type}") - logger.debug(f"Request config: {config}") - - tools = [] - server_name = None - - # if request_type == 'json': - # try: - # all_tools = await list_mcp_tools_mixed_windows(config, timeout=30) - # if "mcpServers" in config and config["mcpServers"]: - # server_name = list(config["mcpServers"].keys())[0] - - # if server_name in all_tools: - # server_info = all_tools[server_name] - # if server_info["status"] == "connected": - # tools = server_info["tools"] - # logger.info(f"Found {len(tools)} tools for server {server_name}") - # else: - # error_msg = server_info.get("error", "Unknown error") - # logger.error(f"Server {server_name} failed: {error_msg}") - # raise HTTPException( - # status_code=400, - # detail=f"Failed to connect to MCP server '{server_name}': {error_msg}" - # ) - # else: - # logger.error(f"Server {server_name} not found in results") - # raise HTTPException(status_code=400, detail=f"Server '{server_name}' not found in results") - # else: - # logger.error("No MCP servers configured") - # raise HTTPException(status_code=400, detail="No MCP servers configured") - - # except HTTPException: - # raise - # except Exception as e: - # logger.error(f"Error connecting to stdio MCP server: {e}") - # import traceback - # logger.error(f"Full traceback: {traceback.format_exc()}") - # raise HTTPException(status_code=400, detail=f"Failed to connect to MCP server: {str(e)}") - - # if request_type == 'http': - # if 'url' not in config: - # raise HTTPException(status_code=400, detail="HTTP configuration must include 'url' field") - # url = config['url'] - # await connect_streamable_http_server(url) - # tools = await connect_streamable_http_server(url) - - # elif request_type == 'sse': - # if 'url' not in config: - # raise HTTPException(status_code=400, detail="SSE configuration must include 'url' field") - - # url = config['url'] - # headers = config.get('headers', {}) - - # try: - # async with asyncio.timeout(15): - # try: - # async with sse_client(url, headers=headers) as (read, write): - # async with ClientSession(read, write) as session: - # await session.initialize() - # tools_result = await session.list_tools() - # tools_info = [] - # for tool in tools_result.tools: - # tool_info = { - # "name": tool.name, - # "description": tool.description, - # "input_schema": tool.inputSchema - # } - # tools_info.append(tool_info) - - # for tool_info in tools_info: - # tools.append({ - # "name": tool_info["name"], - # "description": tool_info["description"], - # "inputSchema": tool_info["input_schema"] - # }) - # except TypeError as e: - # if "unexpected keyword argument" in str(e): - # async with sse_client(url) as (read, write): - # async with ClientSession(read, write) as session: - # await session.initialize() - # tools_result = await session.list_tools() - # tools_info = [] - # for tool in tools_result.tools: - # tool_info = { - # "name": tool.name, - # "description": tool.description, - # "input_schema": tool.inputSchema - # } - # tools_info.append(tool_info) - - # for tool_info in tools_info: - # tools.append({ - # "name": tool_info["name"], - # "description": tool_info["description"], - # "inputSchema": tool_info["input_schema"] - # }) - # else: - # raise - # except asyncio.TimeoutError: - # raise HTTPException(status_code=408, detail="Connection timeout - server took too long to respond") - # except Exception as e: - # logger.error(f"Error connecting to SSE MCP server: {e}") - # raise HTTPException(status_code=400, detail=f"Failed to connect to MCP server: {str(e)}") - # else: - # raise HTTPException(status_code=400, detail="Invalid server type. Must be 'json' or 'sse'") - - # response_data = {"tools": tools, "count": len(tools)} - - # if server_name: - # response_data["serverName"] = server_name - - # logger.info(f"Returning {len(tools)} tools for server {server_name}") - # return response_data diff --git a/app/services/redis.py b/app/services/redis.py deleted file mode 100644 index 12598790c..000000000 --- a/app/services/redis.py +++ /dev/null @@ -1,153 +0,0 @@ -import redis.asyncio as redis -import os -from dotenv import load_dotenv -import asyncio -from utils.logger import logger -from typing import List, Any -from utils.retry import retry - -# Redis client -client: redis.Redis | None = None -_initialized = False -_init_lock = asyncio.Lock() - -# Constants -REDIS_KEY_TTL = 3600 * 24 # 24 hour TTL as safety mechanism - - -def initialize(): - """Initialize Redis connection using environment variables.""" - global client - - # Load environment variables if not already loaded - load_dotenv() - - # Get Redis configuration - redis_host = os.getenv("REDIS_HOST", "redis") - redis_port = int(os.getenv("REDIS_PORT", 6379)) - redis_password = os.getenv("REDIS_PASSWORD", "") - # Convert string 'True'/'False' to boolean - redis_ssl_str = os.getenv("REDIS_SSL", "False") - redis_ssl = redis_ssl_str.lower() == "true" - - logger.info(f"Initializing Redis connection to {redis_host}:{redis_port}") - - # Create Redis client with basic configuration - client = redis.Redis( - host=redis_host, - port=redis_port, - password=redis_password, - ssl=redis_ssl, - decode_responses=True, - socket_timeout=5.0, - socket_connect_timeout=5.0, - retry_on_timeout=True, - health_check_interval=30, - ) - - return client - - -async def initialize_async(): - """Initialize Redis connection asynchronously.""" - global client, _initialized - - async with _init_lock: - if not _initialized: - logger.info("Initializing Redis connection") - initialize() - - try: - await client.ping() - logger.info("Successfully connected to Redis") - _initialized = True - except Exception as e: - logger.error(f"Failed to connect to Redis: {e}") - client = None - _initialized = False - raise - - return client - - -async def close(): - """Close Redis connection.""" - global client, _initialized - if client: - logger.info("Closing Redis connection") - await client.aclose() - client = None - _initialized = False - logger.info("Redis connection closed") - - -async def get_client(): - """Get the Redis client, initializing if necessary.""" - global client, _initialized - if client is None or not _initialized: - await retry(lambda: initialize_async()) - return client - - -# Basic Redis operations -async def set(key: str, value: str, ex: int = None, nx: bool = False): - """Set a Redis key.""" - redis_client = await get_client() - return await redis_client.set(key, value, ex=ex, nx=nx) - - -async def get(key: str, default: str = None): - """Get a Redis key.""" - redis_client = await get_client() - result = await redis_client.get(key) - return result if result is not None else default - - -async def delete(key: str): - """Delete a Redis key.""" - redis_client = await get_client() - return await redis_client.delete(key) - - -async def publish(channel: str, message: str): - """Publish a message to a Redis channel.""" - redis_client = await get_client() - return await redis_client.publish(channel, message) - - -async def create_pubsub(): - """Create a Redis pubsub object.""" - redis_client = await get_client() - return redis_client.pubsub() - - -# List operations -async def rpush(key: str, *values: Any): - """Append one or more values to a list.""" - redis_client = await get_client() - return await redis_client.rpush(key, *values) - - -async def lrange(key: str, start: int, end: int) -> List[str]: - """Get a range of elements from a list.""" - redis_client = await get_client() - return await redis_client.lrange(key, start, end) - - -async def llen(key: str) -> int: - """Get the length of a list.""" - redis_client = await get_client() - return await redis_client.llen(key) - - -# Key management -async def expire(key: str, time: int): - """Set a key's time to live in seconds.""" - redis_client = await get_client() - return await redis_client.expire(key, time) - - -async def keys(pattern: str) -> List[str]: - """Get keys matching a pattern.""" - redis_client = await get_client() - return await redis_client.keys(pattern) diff --git a/app/services/supabase.py b/app/services/supabase.py deleted file mode 100644 index 0a3f8558c..000000000 --- a/app/services/supabase.py +++ /dev/null @@ -1,113 +0,0 @@ -""" -Centralized database connection management for AgentPress using Supabase. -""" - -from typing import Optional -from supabase import create_async_client, AsyncClient -from utils.logger import logger -from utils.config import config -import base64 -import uuid -from datetime import datetime - -class DBConnection: - """Singleton database connection manager using Supabase.""" - - _instance: Optional['DBConnection'] = None - _initialized = False - _client: Optional[AsyncClient] = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __init__(self): - """No initialization needed in __init__ as it's handled in __new__""" - pass - - async def initialize(self): - """Initialize the database connection.""" - if self._initialized: - return - - try: - supabase_url = config.SUPABASE_URL - # Use service role key preferentially for backend operations - supabase_key = config.SUPABASE_SERVICE_ROLE_KEY or config.SUPABASE_ANON_KEY - - if not supabase_url or not supabase_key: - logger.error("Missing required environment variables for Supabase connection") - raise RuntimeError("SUPABASE_URL and a key (SERVICE_ROLE_KEY or ANON_KEY) environment variables must be set.") - - logger.debug("Initializing Supabase connection") - self._client = await create_async_client(supabase_url, supabase_key) - self._initialized = True - key_type = "SERVICE_ROLE_KEY" if config.SUPABASE_SERVICE_ROLE_KEY else "ANON_KEY" - logger.debug(f"Database connection initialized with Supabase using {key_type}") - except Exception as e: - logger.error(f"Database initialization error: {e}") - raise RuntimeError(f"Failed to initialize database connection: {str(e)}") - - @classmethod - async def disconnect(cls): - """Disconnect from the database.""" - if cls._client: - logger.info("Disconnecting from Supabase database") - await cls._client.close() - cls._initialized = False - logger.info("Database disconnected successfully") - - @property - async def client(self) -> AsyncClient: - """Get the Supabase client instance.""" - if not self._initialized: - logger.debug("Supabase client not initialized, initializing now") - await self.initialize() - if not self._client: - logger.error("Database client is None after initialization") - raise RuntimeError("Database not initialized") - return self._client - - async def upload_base64_image(self, base64_data: str, bucket_name: str = "browser-screenshots") -> str: - """Upload a base64 encoded image to Supabase storage and return the URL. - - Args: - base64_data (str): Base64 encoded image data (with or without data URL prefix) - bucket_name (str): Name of the storage bucket to upload to - - Returns: - str: Public URL of the uploaded image - """ - try: - # Remove data URL prefix if present - if base64_data.startswith('data:'): - base64_data = base64_data.split(',')[1] - - # Decode base64 data - image_data = base64.b64decode(base64_data) - - # Generate unique filename - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - unique_id = str(uuid.uuid4())[:8] - filename = f"image_{timestamp}_{unique_id}.png" - - # Upload to Supabase storage - client = await self.client - storage_response = await client.storage.from_(bucket_name).upload( - filename, - image_data, - {"content-type": "image/png"} - ) - - # Get public URL - public_url = await client.storage.from_(bucket_name).get_public_url(filename) - - logger.debug(f"Successfully uploaded image to {public_url}") - return public_url - - except Exception as e: - logger.error(f"Error uploading base64 image: {e}") - raise RuntimeError(f"Failed to upload image: {str(e)}") - - diff --git a/app/services/transcription.py b/app/services/transcription.py deleted file mode 100644 index 2a40eec71..000000000 --- a/app/services/transcription.py +++ /dev/null @@ -1,76 +0,0 @@ -import os -import openai -import tempfile -from fastapi import APIRouter, UploadFile, File, HTTPException, Depends -from pydantic import BaseModel -from typing import Optional -from utils.logger import logger -from utils.auth_utils import get_current_user_id_from_jwt - -router = APIRouter(tags=["transcription"]) - -class TranscriptionResponse(BaseModel): - text: str - -@router.post("/transcription", response_model=TranscriptionResponse) -async def transcribe_audio( - audio_file: UploadFile = File(...), - user_id: str = Depends(get_current_user_id_from_jwt) -): - """Transcribe audio file to text using OpenAI Whisper.""" - try: - # Validate file type - OpenAI supports these formats - allowed_types = [ - 'audio/mp3', 'audio/mpeg', 'audio/mp4', 'audio/m4a', - 'audio/wav', 'audio/webm', 'audio/mpga' - ] - - logger.info(f"Received audio file: {audio_file.filename}, content_type: {audio_file.content_type}") - - if audio_file.content_type not in allowed_types: - raise HTTPException( - status_code=400, - detail=f"Unsupported file type: {audio_file.content_type}. Supported types: {', '.join(allowed_types)}" - ) - - # Check file size (25MB limit) - content = await audio_file.read() - if len(content) > 25 * 1024 * 1024: # 25MB - raise HTTPException(status_code=400, detail="File size exceeds 25MB limit") - - # Reset file pointer - await audio_file.seek(0) - - # Initialize OpenAI client - client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY")) - - # Create a temporary file with the correct extension - file_extension = audio_file.filename.split('.')[-1] if audio_file.filename and '.' in audio_file.filename else 'webm' - - with tempfile.NamedTemporaryFile(delete=False, suffix=f'.{file_extension}') as temp_file: - temp_file.write(content) - temp_file_path = temp_file.name - - try: - # Transcribe audio using the temporary file - # OpenAI Whisper API has built-in limits: 25MB file size and handles duration limits internally - with open(temp_file_path, 'rb') as f: - transcription = client.audio.transcriptions.create( - model="gpt-4o-mini-transcribe", - file=f, - response_format="text" - ) - - logger.info(f"Successfully transcribed audio for user {user_id}") - return TranscriptionResponse(text=transcription) - - finally: - # Clean up temporary file - try: - os.unlink(temp_file_path) - except Exception as e: - logger.warning(f"Failed to delete temporary file {temp_file_path}: {e}") - - except Exception as e: - logger.error(f"Error transcribing audio for user {user_id}: {str(e)}") - raise HTTPException(status_code=500, detail=f"Transcription failed: {str(e)}") \ No newline at end of file diff --git a/app/tool/sb_files_tool.py b/app/tool/sb_files_tool.py index f603814c6..405b5cd2e 100644 --- a/app/tool/sb_files_tool.py +++ b/app/tool/sb_files_tool.py @@ -21,6 +21,7 @@ * File reading: Read file contents with optional line range specification """ + class SandboxFilesTool(SandboxToolsBase): name: str = "sandbox_files" description: str = _FILES_DESCRIPTION @@ -33,7 +34,7 @@ class SandboxFilesTool(SandboxToolsBase): "create_file", "str_replace", "full_file_rewrite", - "delete_file" + "delete_file", ], "description": "The file operation to perform", }, @@ -56,8 +57,8 @@ class SandboxFilesTool(SandboxToolsBase): "permissions": { "type": "string", "description": "File permissions in octal format (e.g., '644')", - "default": "644" - } + "default": "644", + }, }, "required": ["action"], "dependencies": { @@ -71,7 +72,9 @@ class SandboxFilesTool(SandboxToolsBase): # workspace_path: str = Field(default="/workspace", exclude=True) # sandbox: Optional[Sandbox] = Field(default=None, exclude=True) - def __init__(self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data): + def __init__( + self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data + ): """Initialize with optional sandbox and thread_id.""" super().__init__(**data) if sandbox is not None: @@ -115,7 +118,7 @@ async def get_workspace_state(self) -> dict: "content": content, "is_dir": file_info.is_dir, "size": file_info.size, - "modified": file_info.mod_time + "modified": file_info.mod_time, } except Exception as e: print(f"Error reading file {rel_path}: {e}") @@ -136,7 +139,7 @@ async def execute( old_str: Optional[str] = None, new_str: Optional[str] = None, permissions: Optional[str] = "644", - **kwargs + **kwargs, ) -> ToolResult: """ Execute a file operation in the sandbox environment. @@ -155,25 +158,37 @@ async def execute( # File creation if action == "create_file": if not file_path or not file_contents: - return self.fail_response("file_path and file_contents are required for create_file") - return await self._create_file(file_path, file_contents, permissions) + return self.fail_response( + "file_path and file_contents are required for create_file" + ) + return await self._create_file( + file_path, file_contents, permissions + ) # String replacement elif action == "str_replace": if not file_path or not old_str or not new_str: - return self.fail_response("file_path, old_str, and new_str are required for str_replace") + return self.fail_response( + "file_path, old_str, and new_str are required for str_replace" + ) return await self._str_replace(file_path, old_str, new_str) # Full file rewrite elif action == "full_file_rewrite": if not file_path or not file_contents: - return self.fail_response("file_path and file_contents are required for full_file_rewrite") - return await self._full_file_rewrite(file_path, file_contents, permissions) + return self.fail_response( + "file_path and file_contents are required for full_file_rewrite" + ) + return await self._full_file_rewrite( + file_path, file_contents, permissions + ) # File deletion elif action == "delete_file": if not file_path: - return self.fail_response("file_path is required for delete_file") + return self.fail_response( + "file_path is required for delete_file" + ) return await self._delete_file(file_path) else: @@ -183,7 +198,9 @@ async def execute( logger.error(f"Error executing file action: {e}") return self.fail_response(f"Error executing file action: {e}") - async def _create_file(self, file_path: str, file_contents: str, permissions: str = "644") -> ToolResult: + async def _create_file( + self, file_path: str, file_contents: str, permissions: str = "644" + ) -> ToolResult: """Create a new file with the provided contents""" try: # Ensure sandbox is initialized @@ -192,10 +209,12 @@ async def _create_file(self, file_path: str, file_contents: str, permissions: st file_path = self.clean_path(file_path) full_path = f"{self.workspace_path}/{file_path}" if self._file_exists(full_path): - return self.fail_response(f"File '{file_path}' already exists. Use full_file_rewrite to modify existing files.") + return self.fail_response( + f"File '{file_path}' already exists. Use full_file_rewrite to modify existing files." + ) # Create parent directories if needed - parent_dir = '/'.join(full_path.split('/')[:-1]) + parent_dir = "/".join(full_path.split("/")[:-1]) if parent_dir: self.sandbox.fs.create_folder(parent_dir, "755") @@ -206,20 +225,28 @@ async def _create_file(self, file_path: str, file_contents: str, permissions: st message = f"File '{file_path}' created successfully." # Check if index.html was created and add 8080 server info (only in root workspace) - if file_path.lower() == 'index.html': + if file_path.lower() == "index.html": try: website_link = self.sandbox.get_preview_link(8080) - website_url = website_link.url if hasattr(website_link, 'url') else str(website_link).split("url='")[1].split("'")[0] + website_url = ( + website_link.url + if hasattr(website_link, "url") + else str(website_link).split("url='")[1].split("'")[0] + ) message += f"\n\n[Auto-detected index.html - HTTP server available at: {website_url}]" message += "\n[Note: Use the provided HTTP server URL above instead of starting a new server]" except Exception as e: - logger.warning(f"Failed to get website URL for index.html: {str(e)}") + logger.warning( + f"Failed to get website URL for index.html: {str(e)}" + ) return self.success_response(message) except Exception as e: return self.fail_response(f"Error creating file: {str(e)}") - async def _str_replace(self, file_path: str, old_str: str, new_str: str) -> ToolResult: + async def _str_replace( + self, file_path: str, old_str: str, new_str: str + ) -> ToolResult: """Replace specific text in a file""" try: # Ensure sandbox is initialized @@ -238,18 +265,24 @@ async def _str_replace(self, file_path: str, old_str: str, new_str: str) -> Tool if occurrences == 0: return self.fail_response(f"String '{old_str}' not found in file") if occurrences > 1: - lines = [i+1 for i, line in enumerate(content.split('\n')) if old_str in line] - return self.fail_response(f"Multiple occurrences found in lines {lines}. Please ensure string is unique") + lines = [ + i + 1 + for i, line in enumerate(content.split("\n")) + if old_str in line + ] + return self.fail_response( + f"Multiple occurrences found in lines {lines}. Please ensure string is unique" + ) # Perform replacement new_content = content.replace(old_str, new_str) self.sandbox.fs.upload_file(new_content.encode(), full_path) # Show snippet around the edit - replacement_line = content.split(old_str)[0].count('\n') + replacement_line = content.split(old_str)[0].count("\n") start_line = max(0, replacement_line - self.SNIPPET_LINES) - end_line = replacement_line + self.SNIPPET_LINES + new_str.count('\n') - snippet = '\n'.join(new_content.split('\n')[start_line:end_line + 1]) + end_line = replacement_line + self.SNIPPET_LINES + new_str.count("\n") + snippet = "\n".join(new_content.split("\n")[start_line : end_line + 1]) message = f"Replacement successful." @@ -258,7 +291,9 @@ async def _str_replace(self, file_path: str, old_str: str, new_str: str) -> Tool except Exception as e: return self.fail_response(f"Error replacing string: {str(e)}") - async def _full_file_rewrite(self, file_path: str, file_contents: str, permissions: str = "644") -> ToolResult: + async def _full_file_rewrite( + self, file_path: str, file_contents: str, permissions: str = "644" + ) -> ToolResult: """Completely rewrite an existing file with new content""" try: # Ensure sandbox is initialized @@ -267,7 +302,9 @@ async def _full_file_rewrite(self, file_path: str, file_contents: str, permissio file_path = self.clean_path(file_path) full_path = f"{self.workspace_path}/{file_path}" if not self._file_exists(full_path): - return self.fail_response(f"File '{file_path}' does not exist. Use create_file to create a new file.") + return self.fail_response( + f"File '{file_path}' does not exist. Use create_file to create a new file." + ) self.sandbox.fs.upload_file(file_contents.encode(), full_path) self.sandbox.fs.set_file_permissions(full_path, permissions) @@ -275,14 +312,20 @@ async def _full_file_rewrite(self, file_path: str, file_contents: str, permissio message = f"File '{file_path}' completely rewritten successfully." # Check if index.html was rewritten and add 8080 server info (only in root workspace) - if file_path.lower() == 'index.html': + if file_path.lower() == "index.html": try: website_link = self.sandbox.get_preview_link(8080) - website_url = website_link.url if hasattr(website_link, 'url') else str(website_link).split("url='")[1].split("'")[0] + website_url = ( + website_link.url + if hasattr(website_link, "url") + else str(website_link).split("url='")[1].split("'")[0] + ) message += f"\n\n[Auto-detected index.html - HTTP server available at: {website_url}]" message += "\n[Note: Use the provided HTTP server URL above instead of starting a new server]" except Exception as e: - logger.warning(f"Failed to get website URL for index.html: {str(e)}") + logger.warning( + f"Failed to get website URL for index.html: {str(e)}" + ) return self.success_response(message) except Exception as e: @@ -311,4 +354,6 @@ async def cleanup(self): @classmethod def create_with_context(cls, context: Context) -> "SandboxFilesTool[Context]": """Factory method to create a SandboxFilesTool with a specific context.""" - raise NotImplementedError("create_with_context not implemented for SandboxFilesTool") \ No newline at end of file + raise NotImplementedError( + "create_with_context not implemented for SandboxFilesTool" + ) diff --git a/app/tool/sb_shell_tool.py b/app/tool/sb_shell_tool.py index 135e7f5c9..cfd3a85f2 100644 --- a/app/tool/sb_shell_tool.py +++ b/app/tool/sb_shell_tool.py @@ -18,7 +18,9 @@ class SandboxShellTool(SandboxToolsBase): """Tool for executing tasks in a Daytona sandbox with browser-use capabilities. - Uses sessions for maintaining state between commands and provides comprehensive process management.""" + Uses sessions for maintaining state between commands and provides comprehensive process management. + """ + name: str = "sandbox_shell" description: str = _SHELL_DESCRIPTION parameters: dict = { @@ -30,54 +32,56 @@ class SandboxShellTool(SandboxToolsBase): "execute_command", "check_command_output", "terminate_command", - "list_commands" + "list_commands", ], "description": "The shell action to perform", }, "command": { "type": "string", "description": "The shell command to execute. Use this for running CLI tools, installing packages, " - "or system operations. Commands can be chained using &&, ||, and | operators." + "or system operations. Commands can be chained using &&, ||, and | operators.", }, "folder": { "type": "string", "description": "Optional relative path to a subdirectory of /workspace where the command should be " - "executed. Example: 'data/pdfs'" + "executed. Example: 'data/pdfs'", }, "session_name": { "type": "string", "description": "Optional name of the tmux session to use. Use named sessions for related commands " - "that need to maintain state. Defaults to a random session name.", + "that need to maintain state. Defaults to a random session name.", }, "blocking": { "type": "boolean", "description": "Whether to wait for the command to complete. Defaults to false for non-blocking " - "execution.", - "default": False + "execution.", + "default": False, }, "timeout": { "type": "integer", "description": "Optional timeout in seconds for blocking commands. Defaults to 60. Ignored for " - "non-blocking commands.", - "default": 60 + "non-blocking commands.", + "default": 60, }, "kill_session": { "type": "boolean", "description": "Whether to terminate the tmux session after checking. Set to true when you're done " - "with the command.", - "default": False - } + "with the command.", + "default": False, + }, }, "required": ["action"], "dependencies": { "execute_command": ["command"], "check_command_output": ["session_name"], "terminate_command": ["session_name"], - "list_commands": [] + "list_commands": [], }, } - def __init__(self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data): + def __init__( + self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data + ): """Initialize with optional sandbox and thread_id.""" super().__init__(**data) if sandbox is not None: @@ -112,35 +116,30 @@ async def _execute_raw_command(self, command: str) -> Dict[str, Any]: # Execute command in session from app.daytona.sandbox import SessionExecuteRequest + req = SessionExecuteRequest( - command=command, - run_async=False, - cwd=self.workspace_path + command=command, run_async=False, cwd=self.workspace_path ) response = self.sandbox.process.execute_session_command( session_id=session_id, req=req, - timeout=30 # Short timeout for utility commands + timeout=30, # Short timeout for utility commands ) logs = self.sandbox.process.get_session_command_logs( - session_id=session_id, - command_id=response.cmd_id + session_id=session_id, command_id=response.cmd_id ) - return { - "output": logs, - "exit_code": response.exit_code - } + return {"output": logs, "exit_code": response.exit_code} async def _execute_command( - self, - command: str, - folder: Optional[str] = None, - session_name: Optional[str] = None, - blocking: bool = False, - timeout: int = 60 + self, + command: str, + folder: Optional[str] = None, + session_name: Optional[str] = None, + blocking: bool = False, + timeout: int = 60, ) -> ToolResult: try: # Ensure sandbox is initialized @@ -149,7 +148,7 @@ async def _execute_command( # Set up working directory cwd = self.workspace_path if folder: - folder = folder.strip('/') + folder = folder.strip("/") cwd = f"{self.workspace_path}/{folder}" # Generate a session name if not provided @@ -158,19 +157,24 @@ async def _execute_command( # Check if tmux session already exists check_session = await self._execute_raw_command( - f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") + f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'" + ) session_exists = "not_exists" not in check_session.get("output", "") if not session_exists: # Create a new tmux session - await self._execute_raw_command(f"tmux new-session -d -s {session_name}") + await self._execute_raw_command( + f"tmux new-session -d -s {session_name}" + ) # Ensure we're in the correct directory and send command to tmux full_command = f"cd {cwd} && {command}" wrapped_command = full_command.replace('"', '\\"') # Escape double quotes # Send command to tmux session - await self._execute_raw_command(f'tmux send-keys -t {session_name} "{wrapped_command}" Enter') + await self._execute_raw_command( + f'tmux send-keys -t {session_name} "{wrapped_command}" Enter' + ) if blocking: # For blocking execution, wait and capture output @@ -181,56 +185,76 @@ async def _execute_command( # Check if session still exists (command might have exited) check_result = await self._execute_raw_command( - f"tmux has-session -t {session_name} 2>/dev/null || echo 'ended'") + f"tmux has-session -t {session_name} 2>/dev/null || echo 'ended'" + ) if "ended" in check_result.get("output", ""): break # Get current output and check for common completion indicators - output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") + output_result = await self._execute_raw_command( + f"tmux capture-pane -t {session_name} -p -S - -E -" + ) current_output = output_result.get("output", "") # Check for prompt indicators that suggest command completion - last_lines = current_output.split('\n')[-3:] - completion_indicators = ['$', '#', '>', 'Done', 'Completed', 'Finished', '✓'] - if any(indicator in line for indicator in completion_indicators for line in last_lines): + last_lines = current_output.split("\n")[-3:] + completion_indicators = [ + "$", + "#", + ">", + "Done", + "Completed", + "Finished", + "✓", + ] + if any( + indicator in line + for indicator in completion_indicators + for line in last_lines + ): break # Capture final output - output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") + output_result = await self._execute_raw_command( + f"tmux capture-pane -t {session_name} -p -S - -E -" + ) final_output = output_result.get("output", "") # Kill the session after capture await self._execute_raw_command(f"tmux kill-session -t {session_name}") - return self.success_response({ - "output": final_output, - "session_name": session_name, - "cwd": cwd, - "completed": True - }) + return self.success_response( + { + "output": final_output, + "session_name": session_name, + "cwd": cwd, + "completed": True, + } + ) else: # For non-blocking, just return immediately - return self.success_response({ - "session_name": session_name, - "cwd": cwd, - "message": - f"Command sent to tmux session '{session_name}'. Use check_command_output to view results.", - "completed": False - }) + return self.success_response( + { + "session_name": session_name, + "cwd": cwd, + "message": f"Command sent to tmux session '{session_name}'. Use check_command_output to view results.", + "completed": False, + } + ) except Exception as e: # Attempt to clean up session in case of error if session_name: try: - await self._execute_raw_command(f"tmux kill-session -t {session_name}") + await self._execute_raw_command( + f"tmux kill-session -t {session_name}" + ) except: pass return self.fail_response(f"Error executing command: {str(e)}") async def _check_command_output( - self, - session_name: str, - kill_session: bool = False + self, session_name: str, kill_session: bool = False ) -> ToolResult: try: # Ensure sandbox is initialized @@ -238,12 +262,17 @@ async def _check_command_output( # Check if session exists check_result = await self._execute_raw_command( - f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") + f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'" + ) if "not_exists" in check_result.get("output", ""): - return self.fail_response(f"Tmux session '{session_name}' does not exist.") + return self.fail_response( + f"Tmux session '{session_name}' does not exist." + ) # Get output from tmux pane - output_result = await self._execute_raw_command(f"tmux capture-pane -t {session_name} -p -S - -E -") + output_result = await self._execute_raw_command( + f"tmux capture-pane -t {session_name} -p -S - -E -" + ) output = output_result.get("output", "") # Kill session if requested @@ -253,35 +282,37 @@ async def _check_command_output( else: termination_status = "Session still running." - return self.success_response({ - "output": output, - "session_name": session_name, - "status": termination_status - }) + return self.success_response( + { + "output": output, + "session_name": session_name, + "status": termination_status, + } + ) except Exception as e: return self.fail_response(f"Error checking command output: {str(e)}") - async def _terminate_command( - self, - session_name: str - ) -> ToolResult: + async def _terminate_command(self, session_name: str) -> ToolResult: try: # Ensure sandbox is initialized await self._ensure_sandbox() # Check if session exists check_result = await self._execute_raw_command( - f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'") + f"tmux has-session -t {session_name} 2>/dev/null || echo 'not_exists'" + ) if "not_exists" in check_result.get("output", ""): - return self.fail_response(f"Tmux session '{session_name}' does not exist.") + return self.fail_response( + f"Tmux session '{session_name}' does not exist." + ) # Kill the session await self._execute_raw_command(f"tmux kill-session -t {session_name}") - return self.success_response({ - "message": f"Tmux session '{session_name}' terminated successfully." - }) + return self.success_response( + {"message": f"Tmux session '{session_name}' terminated successfully."} + ) except Exception as e: return self.fail_response(f"Error terminating command: {str(e)}") @@ -292,41 +323,44 @@ async def _list_commands(self) -> ToolResult: await self._ensure_sandbox() # List all tmux sessions - result = await self._execute_raw_command("tmux list-sessions 2>/dev/null || echo 'No sessions'") + result = await self._execute_raw_command( + "tmux list-sessions 2>/dev/null || echo 'No sessions'" + ) output = result.get("output", "") if "No sessions" in output or not output.strip(): - return self.success_response({ - "message": "No active tmux sessions found.", - "sessions": [] - }) + return self.success_response( + {"message": "No active tmux sessions found.", "sessions": []} + ) # Parse session list sessions = [] - for line in output.split('\n'): + for line in output.split("\n"): if line.strip(): - parts = line.split(':') + parts = line.split(":") if parts: session_name = parts[0].strip() sessions.append(session_name) - return self.success_response({ - "message": f"Found {len(sessions)} active sessions.", - "sessions": sessions - }) + return self.success_response( + { + "message": f"Found {len(sessions)} active sessions.", + "sessions": sessions, + } + ) except Exception as e: return self.fail_response(f"Error listing commands: {str(e)}") async def execute( - self, - action: str, - command: str, - folder: Optional[str] = None, - session_name: Optional[str] = None, - blocking: bool = False, - timeout: int = 60, - kill_session: bool = False, + self, + action: str, + command: str, + folder: Optional[str] = None, + session_name: Optional[str] = None, + blocking: bool = False, + timeout: int = 60, + kill_session: bool = False, ) -> ToolResult: """ Execute a browser action in the sandbox environment. @@ -347,14 +381,20 @@ async def execute( if action == "execute_command": if not command: return self.fail_response("command is required for navigation") - return await self._execute_command(command, folder, session_name, blocking, timeout) + return await self._execute_command( + command, folder, session_name, blocking, timeout + ) elif action == "check_command_output": if session_name is None: - return self.fail_response("session_name is required for navigation") + return self.fail_response( + "session_name is required for navigation" + ) return await self._check_command_output(session_name, kill_session) elif action == "terminate_command": if session_name is None: - return self.fail_response("session_name is required for click_element") + return self.fail_response( + "session_name is required for click_element" + ) return await self._terminate_command(session_name) elif action == "list_commands": return await self._list_commands() @@ -364,7 +404,6 @@ async def execute( logger.error(f"Error executing shell action: {e}") return self.fail_response(f"Error executing shell action: {e}") - async def cleanup(self): """Clean up all sessions.""" for session_name in list(self._sessions.keys()): diff --git a/app/tool/sb_vision_tool.py b/app/tool/sb_vision_tool.py index a7b902241..e55062623 100644 --- a/app/tool/sb_vision_tool.py +++ b/app/tool/sb_vision_tool.py @@ -26,6 +26,7 @@ * Supported formats: JPG, PNG, GIF, WEBP. Maximum size: 10MB. """ + class SandboxVisionTool(SandboxToolsBase): name: str = "sandbox_vision" description: str = _VISION_DESCRIPTION @@ -35,17 +36,15 @@ class SandboxVisionTool(SandboxToolsBase): "action": { "type": "string", "enum": ["see_image"], - "description": "要执行的视觉动作,目前仅支持 see_image" + "description": "要执行的视觉动作,目前仅支持 see_image", }, "file_path": { "type": "string", - "description": "图片在 /workspace 下的相对路径,如 'screenshots/image.png'" - } + "description": "图片在 /workspace 下的相对路径,如 'screenshots/image.png'", + }, }, "required": ["action", "file_path"], - "dependencies": { - "see_image": ["file_path"] - } + "dependencies": {"see_image": ["file_path"]}, } # def __init__(self, project_id: str, thread_id: str, thread_manager: ThreadManager): @@ -54,7 +53,10 @@ class SandboxVisionTool(SandboxToolsBase): # self.thread_manager = thread_manager vision_message: Optional[ThreadMessage] = Field(default=None, exclude=True) - def __init__(self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data): + + def __init__( + self, sandbox: Optional[Sandbox] = None, thread_id: Optional[str] = None, **data + ): """Initialize with optional sandbox and thread_id.""" super().__init__(**data) if sandbox is not None: @@ -64,11 +66,13 @@ def compress_image(self, image_bytes: bytes, mime_type: str, file_path: str): """压缩图片,保持合理质量。""" try: img = Image.open(BytesIO(image_bytes)) - if img.mode in ('RGBA', 'LA', 'P'): - background = Image.new('RGB', img.size, (255, 255, 255)) - if img.mode == 'P': - img = img.convert('RGBA') - background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + if img.mode in ("RGBA", "LA", "P"): + background = Image.new("RGB", img.size, (255, 255, 255)) + if img.mode == "P": + img = img.convert("RGBA") + background.paste( + img, mask=img.split()[-1] if img.mode == "RGBA" else None + ) img = background width, height = img.size if width > DEFAULT_MAX_WIDTH or height > DEFAULT_MAX_HEIGHT: @@ -77,21 +81,30 @@ def compress_image(self, image_bytes: bytes, mime_type: str, file_path: str): new_height = int(height * ratio) img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) output = BytesIO() - if mime_type == 'image/gif': - img.save(output, format='GIF', optimize=True) - output_mime = 'image/gif' - elif mime_type == 'image/png': - img.save(output, format='PNG', optimize=True, compress_level=DEFAULT_PNG_COMPRESS_LEVEL) - output_mime = 'image/png' + if mime_type == "image/gif": + img.save(output, format="GIF", optimize=True) + output_mime = "image/gif" + elif mime_type == "image/png": + img.save( + output, + format="PNG", + optimize=True, + compress_level=DEFAULT_PNG_COMPRESS_LEVEL, + ) + output_mime = "image/png" else: - img.save(output, format='JPEG', quality=DEFAULT_JPEG_QUALITY, optimize=True) - output_mime = 'image/jpeg' + img.save( + output, format="JPEG", quality=DEFAULT_JPEG_QUALITY, optimize=True + ) + output_mime = "image/jpeg" compressed_bytes = output.getvalue() return compressed_bytes, output_mime except Exception as e: return image_bytes, mime_type - async def execute(self, action: str, file_path: Optional[str] = None, **kwargs) -> ToolResult: + async def execute( + self, action: str, file_path: Optional[str] = None, **kwargs + ) -> ToolResult: """ 执行视觉动作,目前仅支持 see_image。 参数: @@ -109,42 +122,56 @@ async def execute(self, action: str, file_path: Optional[str] = None, **kwargs) try: file_info = self.sandbox.fs.get_file_info(full_path) if file_info.is_dir: - return self.fail_response(f"路径 '{cleaned_path}' 是目录,不是图片文件。") + return self.fail_response( + f"路径 '{cleaned_path}' 是目录,不是图片文件。" + ) except Exception: return self.fail_response(f"图片文件未找到: '{cleaned_path}'") if file_info.size > MAX_IMAGE_SIZE: - return self.fail_response(f"图片文件 '{cleaned_path}' 过大 ({file_info.size / (1024*1024):.2f}MB),最大允许 {MAX_IMAGE_SIZE / (1024*1024)}MB。") + return self.fail_response( + f"图片文件 '{cleaned_path}' 过大 ({file_info.size / (1024*1024):.2f}MB),最大允许 {MAX_IMAGE_SIZE / (1024*1024)}MB。" + ) try: image_bytes = self.sandbox.fs.download_file(full_path) except Exception: return self.fail_response(f"无法读取图片文件: {cleaned_path}") mime_type, _ = mimetypes.guess_type(full_path) - if not mime_type or not mime_type.startswith('image/'): + if not mime_type or not mime_type.startswith("image/"): ext = os.path.splitext(cleaned_path)[1].lower() - if ext == '.jpg' or ext == '.jpeg': mime_type = 'image/jpeg' - elif ext == '.png': mime_type = 'image/png' - elif ext == '.gif': mime_type = 'image/gif' - elif ext == '.webp': mime_type = 'image/webp' + if ext == ".jpg" or ext == ".jpeg": + mime_type = "image/jpeg" + elif ext == ".png": + mime_type = "image/png" + elif ext == ".gif": + mime_type = "image/gif" + elif ext == ".webp": + mime_type = "image/webp" else: - return self.fail_response(f"不支持或未知的图片格式: '{cleaned_path}'。支持: JPG, PNG, GIF, WEBP。") - compressed_bytes, compressed_mime_type = self.compress_image(image_bytes, mime_type, cleaned_path) + return self.fail_response( + f"不支持或未知的图片格式: '{cleaned_path}'。支持: JPG, PNG, GIF, WEBP。" + ) + compressed_bytes, compressed_mime_type = self.compress_image( + image_bytes, mime_type, cleaned_path + ) if len(compressed_bytes) > MAX_COMPRESSED_SIZE: - return self.fail_response(f"图片文件 '{cleaned_path}' 压缩后仍过大 ({len(compressed_bytes) / (1024*1024):.2f}MB),最大允许 {MAX_COMPRESSED_SIZE / (1024*1024)}MB。") - base64_image = base64.b64encode(compressed_bytes).decode('utf-8') + return self.fail_response( + f"图片文件 '{cleaned_path}' 压缩后仍过大 ({len(compressed_bytes) / (1024*1024):.2f}MB),最大允许 {MAX_COMPRESSED_SIZE / (1024*1024)}MB。" + ) + base64_image = base64.b64encode(compressed_bytes).decode("utf-8") image_context_data = { "mime_type": compressed_mime_type, "base64": base64_image, "file_path": cleaned_path, "original_size": file_info.size, - "compressed_size": len(compressed_bytes) + "compressed_size": len(compressed_bytes), } message = ThreadMessage( - type="image_context", - content=image_context_data, - is_llm_message=False + type="image_context", content=image_context_data, is_llm_message=False ) self.vision_message = message # return self.success_response(f"成功加载并压缩图片 '{cleaned_path}' (由 {file_info.size / 1024:.1f}KB 压缩到 {len(compressed_bytes) / 1024:.1f}KB)。") - return self.success_response(f"成功加载并压缩图片 '{cleaned_path}',压缩后的内容为:{base64_image}") + return self.success_response( + f"成功加载并压缩图片 '{cleaned_path}',压缩后的内容为:{base64_image}" + ) except Exception as e: - return self.fail_response(f"see_image 执行异常: {str(e)}") \ No newline at end of file + return self.fail_response(f"see_image 执行异常: {str(e)}") diff --git a/app/utils/auth_utils.py b/app/utils/auth_utils.py deleted file mode 100644 index 62de82055..000000000 --- a/app/utils/auth_utils.py +++ /dev/null @@ -1,231 +0,0 @@ -import sentry -from fastapi import HTTPException, Request -from typing import Optional -import jwt -from jwt.exceptions import PyJWTError -from utils.logger import structlog - -# This function extracts the user ID from Supabase JWT -async def get_current_user_id_from_jwt(request: Request) -> str: - """ - Extract and verify the user ID from the JWT in the Authorization header. - - This function is used as a dependency in FastAPI routes to ensure the user - is authenticated and to provide the user ID for authorization checks. - - Args: - request: The FastAPI request object - - Returns: - str: The user ID extracted from the JWT - - Raises: - HTTPException: If no valid token is found or if the token is invalid - """ - auth_header = request.headers.get('Authorization') - - if not auth_header or not auth_header.startswith('Bearer '): - raise HTTPException( - status_code=401, - detail="No valid authentication credentials found", - headers={"WWW-Authenticate": "Bearer"} - ) - - token = auth_header.split(' ')[1] - - try: - # For Supabase JWT, we just need to decode and extract the user ID - # The actual validation is handled by Supabase's RLS - payload = jwt.decode(token, options={"verify_signature": False}) - - # Supabase stores the user ID in the 'sub' claim - user_id = payload.get('sub') - - if not user_id: - raise HTTPException( - status_code=401, - detail="Invalid token payload", - headers={"WWW-Authenticate": "Bearer"} - ) - - sentry.sentry.set_user({ "id": user_id }) - structlog.contextvars.bind_contextvars( - user_id=user_id - ) - return user_id - - except PyJWTError: - raise HTTPException( - status_code=401, - detail="Invalid token", - headers={"WWW-Authenticate": "Bearer"} - ) - -async def get_account_id_from_thread(client, thread_id: str) -> str: - """ - Extract and verify the account ID from the thread. - - Args: - client: The Supabase client - thread_id: The ID of the thread - - Returns: - str: The account ID associated with the thread - - Raises: - HTTPException: If the thread is not found or if there's an error - """ - try: - response = await client.table('threads').select('account_id').eq('thread_id', thread_id).execute() - - if not response.data or len(response.data) == 0: - raise HTTPException( - status_code=404, - detail="Thread not found" - ) - - account_id = response.data[0].get('account_id') - - if not account_id: - raise HTTPException( - status_code=500, - detail="Thread has no associated account" - ) - - return account_id - - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"Error retrieving thread information: {str(e)}" - ) - -async def get_user_id_from_stream_auth( - request: Request, - token: Optional[str] = None -) -> str: - """ - Extract and verify the user ID from either the Authorization header or query parameter token. - This function is specifically designed for streaming endpoints that need to support both - header-based and query parameter-based authentication (for EventSource compatibility). - - Args: - request: The FastAPI request object - token: Optional token from query parameters - - Returns: - str: The user ID extracted from the JWT - - Raises: - HTTPException: If no valid token is found or if the token is invalid - """ - # Try to get user_id from token in query param (for EventSource which can't set headers) - if token: - try: - # For Supabase JWT, we just need to decode and extract the user ID - payload = jwt.decode(token, options={"verify_signature": False}) - user_id = payload.get('sub') - if user_id: - sentry.sentry.set_user({ "id": user_id }) - structlog.contextvars.bind_contextvars( - user_id=user_id - ) - return user_id - except Exception: - pass - - # If no valid token in query param, try to get it from the Authorization header - auth_header = request.headers.get('Authorization') - if auth_header and auth_header.startswith('Bearer '): - try: - # Extract token from header - header_token = auth_header.split(' ')[1] - payload = jwt.decode(header_token, options={"verify_signature": False}) - user_id = payload.get('sub') - if user_id: - return user_id - except Exception: - pass - - # If we still don't have a user_id, return authentication error - raise HTTPException( - status_code=401, - detail="No valid authentication credentials found", - headers={"WWW-Authenticate": "Bearer"} - ) - -async def verify_thread_access(client, thread_id: str, user_id: str): - """ - Verify that a user has access to a specific thread based on account membership. - - Args: - client: The Supabase client - thread_id: The thread ID to check access for - user_id: The user ID to check permissions for - - Returns: - bool: True if the user has access - - Raises: - HTTPException: If the user doesn't have access to the thread - """ - # Query the thread to get account information - thread_result = await client.table('threads').select('*,project_id').eq('thread_id', thread_id).execute() - - if not thread_result.data or len(thread_result.data) == 0: - raise HTTPException(status_code=404, detail="Thread not found") - - thread_data = thread_result.data[0] - - # Check if project is public - project_id = thread_data.get('project_id') - if project_id: - project_result = await client.table('projects').select('is_public').eq('project_id', project_id).execute() - if project_result.data and len(project_result.data) > 0: - if project_result.data[0].get('is_public'): - return True - - account_id = thread_data.get('account_id') - # When using service role, we need to manually check account membership instead of using current_user_account_role - if account_id: - account_user_result = await client.schema('basejump').from_('account_user').select('account_role').eq('user_id', user_id).eq('account_id', account_id).execute() - if account_user_result.data and len(account_user_result.data) > 0: - return True - raise HTTPException(status_code=403, detail="Not authorized to access this thread") - -async def get_optional_user_id(request: Request) -> Optional[str]: - """ - Extract the user ID from the JWT in the Authorization header if present, - but don't require authentication. Returns None if no valid token is found. - - This function is used for endpoints that support both authenticated and - unauthenticated access (like public projects). - - Args: - request: The FastAPI request object - - Returns: - Optional[str]: The user ID extracted from the JWT, or None if no valid token - """ - auth_header = request.headers.get('Authorization') - - if not auth_header or not auth_header.startswith('Bearer '): - return None - - token = auth_header.split(' ')[1] - - try: - # For Supabase JWT, we just need to decode and extract the user ID - payload = jwt.decode(token, options={"verify_signature": False}) - - # Supabase stores the user ID in the 'sub' claim - user_id = payload.get('sub') - if user_id: - sentry.sentry.set_user({ "id": user_id }) - structlog.contextvars.bind_contextvars( - user_id=user_id - ) - - return user_id - except PyJWTError: - return None diff --git a/app/utils/config.py b/app/utils/config.py deleted file mode 100644 index ab2853727..000000000 --- a/app/utils/config.py +++ /dev/null @@ -1,253 +0,0 @@ -""" -Configuration management. - -This module provides a centralized way to access configuration settings and -environment variables across the application. It supports different environment -modes (development, staging, production) and provides validation for required -values. - -Usage: - from utils.config import config - - # Access configuration values - api_key = config.OPENAI_API_KEY - env_mode = config.ENV_MODE -""" - -import os -from enum import Enum -from typing import Dict, Any, Optional, get_type_hints, Union -from dotenv import load_dotenv -import logging - -logger = logging.getLogger(__name__) - -class EnvMode(Enum): - """Environment mode enumeration.""" - LOCAL = "local" - STAGING = "staging" - PRODUCTION = "production" - -class Configuration: - """ - Centralized configuration for AgentPress backend. - - This class loads environment variables and provides type checking and validation. - Default values can be specified for optional configuration items. - """ - - # Environment mode - ENV_MODE: EnvMode = EnvMode.LOCAL - - # Subscription tier IDs - Production - STRIPE_FREE_TIER_ID_PROD: str = 'price_1RILb4G6l1KZGqIrK4QLrx9i' - STRIPE_TIER_2_20_ID_PROD: str = 'price_1RILb4G6l1KZGqIrhomjgDnO' - STRIPE_TIER_6_50_ID_PROD: str = 'price_1RILb4G6l1KZGqIr5q0sybWn' - STRIPE_TIER_12_100_ID_PROD: str = 'price_1RILb4G6l1KZGqIr5Y20ZLHm' - STRIPE_TIER_25_200_ID_PROD: str = 'price_1RILb4G6l1KZGqIrGAD8rNjb' - STRIPE_TIER_50_400_ID_PROD: str = 'price_1RILb4G6l1KZGqIruNBUMTF1' - STRIPE_TIER_125_800_ID_PROD: str = 'price_1RILb3G6l1KZGqIrbJA766tN' - STRIPE_TIER_200_1000_ID_PROD: str = 'price_1RILb3G6l1KZGqIrmauYPOiN' - - # Subscription tier IDs - Staging - STRIPE_FREE_TIER_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrw14abxeL' - STRIPE_TIER_2_20_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrCRu0E4Gi' - STRIPE_TIER_6_50_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrvjlz5p5V' - STRIPE_TIER_12_100_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrT6UfgblC' - STRIPE_TIER_25_200_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrOVLKlOMj' - STRIPE_TIER_50_400_ID_STAGING: str = 'price_1RIKNgG6l1KZGqIrvsat5PW7' - STRIPE_TIER_125_800_ID_STAGING: str = 'price_1RIKNrG6l1KZGqIrjKT0yGvI' - STRIPE_TIER_200_1000_ID_STAGING: str = 'price_1RIKQ2G6l1KZGqIrum9n8SI7' - - # Computed subscription tier IDs based on environment - @property - def STRIPE_FREE_TIER_ID(self) -> str: - if self.ENV_MODE == EnvMode.STAGING: - return self.STRIPE_FREE_TIER_ID_STAGING - return self.STRIPE_FREE_TIER_ID_PROD - - @property - def STRIPE_TIER_2_20_ID(self) -> str: - if self.ENV_MODE == EnvMode.STAGING: - return self.STRIPE_TIER_2_20_ID_STAGING - return self.STRIPE_TIER_2_20_ID_PROD - - @property - def STRIPE_TIER_6_50_ID(self) -> str: - if self.ENV_MODE == EnvMode.STAGING: - return self.STRIPE_TIER_6_50_ID_STAGING - return self.STRIPE_TIER_6_50_ID_PROD - - @property - def STRIPE_TIER_12_100_ID(self) -> str: - if self.ENV_MODE == EnvMode.STAGING: - return self.STRIPE_TIER_12_100_ID_STAGING - return self.STRIPE_TIER_12_100_ID_PROD - - @property - def STRIPE_TIER_25_200_ID(self) -> str: - if self.ENV_MODE == EnvMode.STAGING: - return self.STRIPE_TIER_25_200_ID_STAGING - return self.STRIPE_TIER_25_200_ID_PROD - - @property - def STRIPE_TIER_50_400_ID(self) -> str: - if self.ENV_MODE == EnvMode.STAGING: - return self.STRIPE_TIER_50_400_ID_STAGING - return self.STRIPE_TIER_50_400_ID_PROD - - @property - def STRIPE_TIER_125_800_ID(self) -> str: - if self.ENV_MODE == EnvMode.STAGING: - return self.STRIPE_TIER_125_800_ID_STAGING - return self.STRIPE_TIER_125_800_ID_PROD - - @property - def STRIPE_TIER_200_1000_ID(self) -> str: - if self.ENV_MODE == EnvMode.STAGING: - return self.STRIPE_TIER_200_1000_ID_STAGING - return self.STRIPE_TIER_200_1000_ID_PROD - - # LLM API keys - ANTHROPIC_API_KEY: Optional[str] = None - OPENAI_API_KEY: Optional[str] = None - GROQ_API_KEY: Optional[str] = None - OPENROUTER_API_KEY: Optional[str] = None - OPENROUTER_API_BASE: Optional[str] = "https://openrouter.ai/api/v1" - OR_SITE_URL: Optional[str] = "https://kortix.ai" - OR_APP_NAME: Optional[str] = "Kortix AI" - - # AWS Bedrock credentials - AWS_ACCESS_KEY_ID: Optional[str] = None - AWS_SECRET_ACCESS_KEY: Optional[str] = None - AWS_REGION_NAME: Optional[str] = None - - # Model configuration - MODEL_TO_USE: Optional[str] = "anthropic/claude-sonnet-4-20250514" - - # Supabase configuration - SUPABASE_URL: str - SUPABASE_ANON_KEY: str - SUPABASE_SERVICE_ROLE_KEY: str - - # Redis configuration - REDIS_HOST: str - REDIS_PORT: int = 6379 - REDIS_PASSWORD: Optional[str] = None - REDIS_SSL: bool = True - - # Daytona sandbox configuration - DAYTONA_API_KEY: str - DAYTONA_SERVER_URL: str - DAYTONA_TARGET: str - - # Search and other API keys - TAVILY_API_KEY: str - RAPID_API_KEY: str - CLOUDFLARE_API_TOKEN: Optional[str] = None - FIRECRAWL_API_KEY: str - FIRECRAWL_URL: Optional[str] = "https://api.firecrawl.dev" - - # Stripe configuration - STRIPE_SECRET_KEY: Optional[str] = None - STRIPE_WEBHOOK_SECRET: Optional[str] = None - STRIPE_DEFAULT_PLAN_ID: Optional[str] = None - STRIPE_DEFAULT_TRIAL_DAYS: int = 14 - - # Stripe Product IDs - STRIPE_PRODUCT_ID_PROD: str = 'prod_SCl7AQ2C8kK1CD' - STRIPE_PRODUCT_ID_STAGING: str = 'prod_SCgIj3G7yPOAWY' - - # Sandbox configuration - SANDBOX_IMAGE_NAME = "kortix/suna:0.1.3" - SANDBOX_ENTRYPOINT = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf" - - # LangFuse configuration - LANGFUSE_PUBLIC_KEY: Optional[str] = None - LANGFUSE_SECRET_KEY: Optional[str] = None - LANGFUSE_HOST: str = "https://cloud.langfuse.com" - - @property - def STRIPE_PRODUCT_ID(self) -> str: - if self.ENV_MODE == EnvMode.STAGING: - return self.STRIPE_PRODUCT_ID_STAGING - return self.STRIPE_PRODUCT_ID_PROD - - def __init__(self): - """Initialize configuration by loading from environment variables.""" - # Load environment variables from .env file if it exists - load_dotenv() - - # Set environment mode first - env_mode_str = os.getenv("ENV_MODE", EnvMode.LOCAL.value) - try: - self.ENV_MODE = EnvMode(env_mode_str.lower()) - except ValueError: - logger.warning(f"Invalid ENV_MODE: {env_mode_str}, defaulting to LOCAL") - self.ENV_MODE = EnvMode.LOCAL - - logger.info(f"Environment mode: {self.ENV_MODE.value}") - - # Load configuration from environment variables - self._load_from_env() - - # Perform validation - self._validate() - - def _load_from_env(self): - """Load configuration values from environment variables.""" - for key, expected_type in get_type_hints(self.__class__).items(): - env_val = os.getenv(key) - - if env_val is not None: - # Convert environment variable to the expected type - if expected_type == bool: - # Handle boolean conversion - setattr(self, key, env_val.lower() in ('true', 't', 'yes', 'y', '1')) - elif expected_type == int: - # Handle integer conversion - try: - setattr(self, key, int(env_val)) - except ValueError: - logger.warning(f"Invalid value for {key}: {env_val}, using default") - elif expected_type == EnvMode: - # Already handled for ENV_MODE - pass - else: - # String or other type - setattr(self, key, env_val) - - def _validate(self): - """Validate configuration based on type hints.""" - # Get all configuration fields and their type hints - type_hints = get_type_hints(self.__class__) - - # Find missing required fields - missing_fields = [] - for field, field_type in type_hints.items(): - # Check if the field is Optional - is_optional = hasattr(field_type, "__origin__") and field_type.__origin__ is Union and type(None) in field_type.__args__ - - # If not optional and value is None, add to missing fields - if not is_optional and getattr(self, field) is None: - missing_fields.append(field) - - if missing_fields: - error_msg = f"Missing required configuration fields: {', '.join(missing_fields)}" - logger.error(error_msg) - raise ValueError(error_msg) - - def get(self, key: str, default: Any = None) -> Any: - """Get a configuration value with an optional default.""" - return getattr(self, key, default) - - def as_dict(self) -> Dict[str, Any]: - """Return configuration as a dictionary.""" - return { - key: getattr(self, key) - for key in get_type_hints(self.__class__).keys() - if not key.startswith('_') - } - -# Create a singleton instance -config = Configuration() \ No newline at end of file diff --git a/app/utils/constants.py b/app/utils/constants.py deleted file mode 100644 index e24578bda..000000000 --- a/app/utils/constants.py +++ /dev/null @@ -1,145 +0,0 @@ -MODEL_ACCESS_TIERS = { - "free": [ - "openrouter/deepseek/deepseek-chat", - "openrouter/qwen/qwen3-235b-a22b", - "openrouter/google/gemini-2.5-flash-preview-05-20", - ], - "tier_2_20": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-sonnet-4-20250514", - # "openai/gpt-4.1-2025-04-14", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], - "tier_6_50": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-sonnet-4-20250514", - # "openai/gpt-4.1-2025-04-14", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], - "tier_12_100": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-sonnet-4-20250514", - # "openai/gpt-4.1-2025-04-14", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], - "tier_25_200": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-sonnet-4-20250514", - # "openai/gpt-4.1-2025-04-14", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], - "tier_50_400": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-sonnet-4-20250514", - # "openai/gpt-4.1-2025-04-14", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], - "tier_125_800": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-sonnet-4-20250514", - # "openai/gpt-4.1-2025-04-14", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], - "tier_200_1000": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-sonnet-4-20250514", - # "openai/gpt-4.1-2025-04-14", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], -} -MODEL_NAME_ALIASES = { - # Short names to full names - "sonnet-3.7": "anthropic/claude-3-7-sonnet-latest", - "sonnet-3.5": "anthropic/claude-3-5-sonnet-latest", - "haiku-3.5": "anthropic/claude-3-5-haiku-latest", - "claude-sonnet-4": "anthropic/claude-sonnet-4-20250514", - # "gpt-4.1": "openai/gpt-4.1-2025-04-14", # Commented out in constants.py - "gpt-4o": "openai/gpt-4o", - "gpt-4.1": "openai/gpt-4.1", - "gpt-4.1-mini": "gpt-4.1-mini", - # "gpt-4-turbo": "openai/gpt-4-turbo", # Commented out in constants.py - # "gpt-4": "openai/gpt-4", # Commented out in constants.py - # "gemini-flash-2.5": "openrouter/google/gemini-2.5-flash-preview", # Commented out in constants.py - # "grok-3": "xai/grok-3-fast-latest", # Commented out in constants.py - "deepseek": "openrouter/deepseek/deepseek-chat", - # "deepseek-r1": "openrouter/deepseek/deepseek-r1", - # "grok-3-mini": "xai/grok-3-mini-fast-beta", # Commented out in constants.py - "qwen3": "openrouter/qwen/qwen3-235b-a22b", # Commented out in constants.py - "gemini-flash-2.5": "openrouter/google/gemini-2.5-flash-preview-05-20", - "gemini-2.5-flash:thinking":"openrouter/google/gemini-2.5-flash-preview-05-20:thinking", - - # "google/gemini-2.5-flash-preview":"openrouter/google/gemini-2.5-flash-preview", - # "google/gemini-2.5-flash-preview:thinking":"openrouter/google/gemini-2.5-flash-preview:thinking", - "google/gemini-2.5-pro-preview":"openrouter/google/gemini-2.5-pro-preview", - "deepseek/deepseek-chat-v3-0324":"openrouter/deepseek/deepseek-chat-v3-0324", - - # Also include full names as keys to ensure they map to themselves - # "anthropic/claude-3-7-sonnet-latest": "anthropic/claude-3-7-sonnet-latest", - # "openai/gpt-4.1-2025-04-14": "openai/gpt-4.1-2025-04-14", # Commented out in constants.py - # "openai/gpt-4o": "openai/gpt-4o", - # "openai/gpt-4-turbo": "openai/gpt-4-turbo", # Commented out in constants.py - # "openai/gpt-4": "openai/gpt-4", # Commented out in constants.py - # "openrouter/google/gemini-2.5-flash-preview": "openrouter/google/gemini-2.5-flash-preview", # Commented out in constants.py - # "xai/grok-3-fast-latest": "xai/grok-3-fast-latest", # Commented out in constants.py - # "deepseek/deepseek-chat": "openrouter/deepseek/deepseek-chat", - # "deepseek/deepseek-r1": "openrouter/deepseek/deepseek-r1", - - # "qwen/qwen3-235b-a22b": "openrouter/qwen/qwen3-235b-a22b", - # "xai/grok-3-mini-fast-beta": "xai/grok-3-mini-fast-beta", # Commented out in constants.py -} \ No newline at end of file diff --git a/app/utils/retry.py b/app/utils/retry.py deleted file mode 100644 index 992c04543..000000000 --- a/app/utils/retry.py +++ /dev/null @@ -1,58 +0,0 @@ -import asyncio -from typing import TypeVar, Callable, Awaitable, Optional - -T = TypeVar("T") - - -async def retry( - fn: Callable[[], Awaitable[T]], - max_attempts: int = 3, - delay_seconds: int = 1, -) -> T: - """ - Retry an async function with exponential backoff. - - Args: - fn: The async function to retry - max_attempts: Maximum number of attempts - delay_seconds: Delay between attempts in seconds - - Returns: - The result of the function call - - Raises: - The last exception if all attempts fail - - Example: - ```python - async def fetch_data(): - # Some operation that might fail - return await api_call() - - try: - result = await retry(fetch_data, max_attempts=3, delay_seconds=2) - print(f"Success: {result}") - except Exception as e: - print(f"Failed after all retries: {e}") - ``` - """ - if max_attempts <= 0: - raise ValueError("max_attempts must be greater than zero") - - last_error: Optional[Exception] = None - - for attempt in range(1, max_attempts + 1): - try: - return await fn() - except Exception as error: - last_error = error - - if attempt == max_attempts: - break - - await asyncio.sleep(delay_seconds) - - if last_error: - raise last_error - - raise RuntimeError("Unexpected: last_error is None") diff --git a/app/utils/s3_upload_utils.py b/app/utils/s3_upload_utils.py deleted file mode 100644 index 65722640a..000000000 --- a/app/utils/s3_upload_utils.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Utility functions for handling image operations. -""" - -import base64 -import uuid -from datetime import datetime -from utils.logger import logger -from services.supabase import DBConnection - -async def upload_base64_image(base64_data: str, bucket_name: str = "browser-screenshots") -> str: - """Upload a base64 encoded image to Supabase storage and return the URL. - - Args: - base64_data (str): Base64 encoded image data (with or without data URL prefix) - bucket_name (str): Name of the storage bucket to upload to - - Returns: - str: Public URL of the uploaded image - """ - try: - # Remove data URL prefix if present - if base64_data.startswith('data:'): - base64_data = base64_data.split(',')[1] - - # Decode base64 data - image_data = base64.b64decode(base64_data) - - # Generate unique filename - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - unique_id = str(uuid.uuid4())[:8] - filename = f"image_{timestamp}_{unique_id}.png" - - # Upload to Supabase storage - db = DBConnection() - client = await db.client - storage_response = await client.storage.from_(bucket_name).upload( - filename, - image_data, - {"content-type": "image/png"} - ) - - # Get public URL - public_url = await client.storage.from_(bucket_name).get_public_url(filename) - - logger.debug(f"Successfully uploaded image to {public_url}") - return public_url - - except Exception as e: - logger.error(f"Error uploading base64 image: {e}") - raise RuntimeError(f"Failed to upload image: {str(e)}") \ No newline at end of file diff --git a/app/utils/scripts/archive_inactive_sandboxes.py b/app/utils/scripts/archive_inactive_sandboxes.py deleted file mode 100644 index 1a56dbeba..000000000 --- a/app/utils/scripts/archive_inactive_sandboxes.py +++ /dev/null @@ -1,350 +0,0 @@ -#!/usr/bin/env python -""" -Script to archive sandboxes for projects whose account_id is not associated with an active billing customer. - -Usage: - python archive_inactive_sandboxes.py - -This script: -1. Gets all active account_ids from basejump.billing_customers (active=TRUE) -2. Gets all projects from the projects table -3. Archives sandboxes for any project whose account_id is not in the active billing customers list - -Make sure your environment variables are properly set: -- SUPABASE_URL -- SUPABASE_SERVICE_ROLE_KEY -- DAYTONA_SERVER_URL -""" - -import asyncio -import sys -import os -import argparse -from typing import List, Dict, Any, Set -from dotenv import load_dotenv - -# Load script-specific environment variables -load_dotenv(".env") - -from services.supabase import DBConnection -from sandbox.sandbox import daytona -from utils.logger import logger - -# Global DB connection to reuse -db_connection = None - - -async def get_active_billing_customer_account_ids() -> Set[str]: - """ - Query all account_ids from the basejump.billing_customers table where active=TRUE. - - Returns: - Set of account_ids that have an active billing customer record - """ - global db_connection - if db_connection is None: - db_connection = DBConnection() - - client = await db_connection.client - - # Print the Supabase URL being used - print(f"Using Supabase URL: {os.getenv('SUPABASE_URL')}") - - # Query all account_ids from billing_customers where active=true - result = await client.schema('basejump').from_('billing_customers').select('account_id, active').execute() - - # Print the query result - print(f"Found {len(result.data)} billing customers in database") - print(result.data) - - if not result.data: - logger.info("No billing customers found in database") - return set() - - # Extract account_ids for active customers and return as a set for fast lookups - active_account_ids = {customer.get('account_id') for customer in result.data - if customer.get('account_id') and customer.get('active') is True} - - print(f"Found {len(active_account_ids)} active billing customers") - return active_account_ids - - -async def get_all_projects() -> List[Dict[str, Any]]: - """ - Query all projects with sandbox information. - - Returns: - List of projects with their sandbox information - """ - global db_connection - if db_connection is None: - db_connection = DBConnection() - - client = await db_connection.client - - # Initialize variables for pagination - all_projects = [] - page_size = 1000 - current_page = 0 - has_more = True - - logger.info("Starting to fetch all projects (paginated)") - - # Paginate through all projects - while has_more: - # Query projects with pagination - start_range = current_page * page_size - end_range = start_range + page_size - 1 - - logger.info(f"Fetching projects page {current_page+1} (range: {start_range}-{end_range})") - - result = await client.table('projects').select( - 'project_id', - 'name', - 'account_id', - 'sandbox' - ).range(start_range, end_range).execute() - - if not result.data: - has_more = False - else: - all_projects.extend(result.data) - current_page += 1 - - # Progress update - logger.info(f"Loaded {len(all_projects)} projects so far") - print(f"Loaded {len(all_projects)} projects so far...") - - # Check if we've reached the end - if len(result.data) < page_size: - has_more = False - - # Print the query result - total_projects = len(all_projects) - print(f"Found {total_projects} projects in database") - logger.info(f"Total projects found in database: {total_projects}") - - if not all_projects: - logger.info("No projects found in database") - return [] - - # Filter projects that have sandbox information - projects_with_sandboxes = [ - project for project in all_projects - if project.get('sandbox') and project['sandbox'].get('id') - ] - - logger.info(f"Found {len(projects_with_sandboxes)} projects with sandboxes") - return projects_with_sandboxes - - -async def archive_sandbox(project: Dict[str, Any], dry_run: bool) -> bool: - """ - Archive a single sandbox. - - Args: - project: Project information containing sandbox to archive - dry_run: If True, only simulate archiving - - Returns: - True if successful, False otherwise - """ - sandbox_id = project['sandbox'].get('id') - project_name = project.get('name', 'Unknown') - project_id = project.get('project_id', 'Unknown') - - try: - logger.info(f"Checking sandbox {sandbox_id} for project '{project_name}' (ID: {project_id})") - - if dry_run: - logger.info(f"DRY RUN: Would archive sandbox {sandbox_id}") - print(f"Would archive sandbox {sandbox_id} for project '{project_name}'") - return True - - # Get the sandbox - sandbox = daytona.get(sandbox_id) - - # Check sandbox state - it must be stopped before archiving - sandbox_info = sandbox.info() - - # Log the current state - logger.info(f"Sandbox {sandbox_id} is in '{sandbox_info.state}' state") - - # Only archive if the sandbox is in the stopped state - if sandbox_info.state == "stopped": - logger.info(f"Archiving sandbox {sandbox_id} as it is in stopped state") - sandbox.archive() - logger.info(f"Successfully archived sandbox {sandbox_id}") - return True - else: - logger.info(f"Skipping sandbox {sandbox_id} as it is not in stopped state (current: {sandbox_info.state})") - return True - - except Exception as e: - import traceback - error_type = type(e).__name__ - stack_trace = traceback.format_exc() - - # Log detailed error information - logger.error(f"Error processing sandbox {sandbox_id}: {str(e)}") - logger.error(f"Error type: {error_type}") - logger.error(f"Stack trace:\n{stack_trace}") - - # If the exception has a response attribute (like in HTTP errors), log it - if hasattr(e, 'response'): - try: - response_data = e.response.json() if hasattr(e.response, 'json') else str(e.response) - logger.error(f"Response data: {response_data}") - except Exception: - logger.error(f"Could not parse response data from error") - - print(f"Failed to process sandbox {sandbox_id}: {error_type} - {str(e)}") - return False - - -async def process_sandboxes(inactive_projects: List[Dict[str, Any]], dry_run: bool) -> tuple[int, int]: - """ - Process all sandboxes sequentially. - - Args: - inactive_projects: List of projects without active billing - dry_run: Whether to actually archive sandboxes or just simulate - - Returns: - Tuple of (processed_count, failed_count) - """ - processed_count = 0 - failed_count = 0 - - if dry_run: - logger.info(f"DRY RUN: Would archive {len(inactive_projects)} sandboxes") - else: - logger.info(f"Archiving {len(inactive_projects)} sandboxes") - - print(f"Processing {len(inactive_projects)} sandboxes...") - - # Process each sandbox sequentially - for i, project in enumerate(inactive_projects): - success = await archive_sandbox(project, dry_run) - - if success: - processed_count += 1 - else: - failed_count += 1 - - # Print progress periodically - if (i + 1) % 20 == 0 or (i + 1) == len(inactive_projects): - progress = (i + 1) / len(inactive_projects) * 100 - print(f"Progress: {i + 1}/{len(inactive_projects)} sandboxes processed ({progress:.1f}%)") - print(f" - Processed: {processed_count}, Failed: {failed_count}") - - return processed_count, failed_count - - -async def main(): - """Main function to run the script.""" - # Parse command line arguments - parser = argparse.ArgumentParser(description='Archive sandboxes for projects without active billing') - parser.add_argument('--dry-run', action='store_true', help='Show what would be archived without actually archiving') - args = parser.parse_args() - - logger.info("Starting sandbox cleanup for projects without active billing") - if args.dry_run: - logger.info("DRY RUN MODE - No sandboxes will be archived") - - # Print environment info - print(f"Environment Mode: {os.getenv('ENV_MODE', 'Not set')}") - print(f"Daytona Server: {os.getenv('DAYTONA_SERVER_URL', 'Not set')}") - - try: - # Initialize global DB connection - global db_connection - db_connection = DBConnection() - - # Get all account_ids that have an active billing customer - active_billing_customer_account_ids = await get_active_billing_customer_account_ids() - - # Get all projects with sandboxes - all_projects = await get_all_projects() - - if not all_projects: - logger.info("No projects with sandboxes to process") - return - - # Filter projects whose account_id is not in the active billing customers list - inactive_projects = [ - project for project in all_projects - if project.get('account_id') not in active_billing_customer_account_ids - ] - - # Print summary of what will be processed - active_projects_count = len(all_projects) - len(inactive_projects) - print("\n===== SANDBOX CLEANUP SUMMARY =====") - print(f"Total projects found: {len(all_projects)}") - print(f"Projects with active billing accounts: {active_projects_count}") - print(f"Projects without active billing accounts: {len(inactive_projects)}") - print(f"Sandboxes that will be archived: {len(inactive_projects)}") - print("===================================") - - logger.info(f"Found {len(inactive_projects)} projects without an active billing customer account") - - if not inactive_projects: - logger.info("No projects to archive sandboxes for") - return - - # Ask for confirmation before proceeding - if not args.dry_run: - print("\n⚠️ WARNING: You are about to archive sandboxes for inactive accounts ⚠️") - print("This action cannot be undone!") - confirmation = input("\nAre you sure you want to proceed with archiving? (TRUE/FALSE): ").strip().upper() - - if confirmation != "TRUE": - print("Archiving cancelled. Exiting script.") - logger.info("Archiving cancelled by user") - return - - print("\nProceeding with sandbox archiving...\n") - logger.info("User confirmed sandbox archiving") - - # List all projects to be processed - for i, project in enumerate(inactive_projects[:5]): # Just show first 5 for brevity - account_id = project.get('account_id', 'Unknown') - project_name = project.get('name', 'Unknown') - project_id = project.get('project_id', 'Unknown') - sandbox_id = project['sandbox'].get('id') - - print(f"{i+1}. Project: {project_name}") - print(f" Project ID: {project_id}") - print(f" Account ID: {account_id}") - print(f" Sandbox ID: {sandbox_id}") - - if len(inactive_projects) > 5: - print(f" ... and {len(inactive_projects) - 5} more projects") - - # Process all sandboxes - processed_count, failed_count = await process_sandboxes(inactive_projects, args.dry_run) - - # Print final summary - print("\nSandbox Cleanup Summary:") - print(f"Total projects without active billing: {len(inactive_projects)}") - print(f"Total sandboxes processed: {len(inactive_projects)}") - - if args.dry_run: - print(f"DRY RUN: No sandboxes were actually archived") - else: - print(f"Successfully processed: {processed_count}") - print(f"Failed to process: {failed_count}") - - logger.info("Sandbox cleanup completed") - - except Exception as e: - logger.error(f"Error during sandbox cleanup: {str(e)}") - sys.exit(1) - finally: - # Clean up database connection - if db_connection: - await DBConnection.disconnect() - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/app/utils/scripts/archive_old_sandboxes.py b/app/utils/scripts/archive_old_sandboxes.py deleted file mode 100644 index ae020d89a..000000000 --- a/app/utils/scripts/archive_old_sandboxes.py +++ /dev/null @@ -1,344 +0,0 @@ -#!/usr/bin/env python -""" -Script to archive sandboxes for projects that are older than 1 day. - -Usage: - python archive_old_sandboxes.py [--days N] [--dry-run] - -This script: -1. Gets all projects from the projects table -2. Filters projects created more than N days ago (default: 1 day) -3. Archives the sandboxes for those projects - -Make sure your environment variables are properly set: -- SUPABASE_URL -- SUPABASE_SERVICE_ROLE_KEY -- DAYTONA_SERVER_URL -""" - -# TODO: SAVE THE LATEST SANDBOX STATE SOMEWHERE OR LIKE MASS CHECK THE STATE BEFORE STARTING TO ARCHIVE - AS ITS GOING TO GO OVER A BUNCH THAT ARE ALREADY ARCHIVED – MAYBE BEST TO GET ALL FROM DAYTONA AND THEN RUN THE ARCHIVE ONLY ON THE ONES THAT MEET THE CRITERIA (STOPPED STATE) - -import asyncio -import sys -import os -import argparse -from typing import List, Dict, Any -from datetime import datetime, timedelta -from dotenv import load_dotenv - -# Load script-specific environment variables -load_dotenv(".env") - -from services.supabase import DBConnection -from sandbox.sandbox import daytona -from utils.logger import logger - -# Global DB connection to reuse -db_connection = None - - -async def get_old_projects(days_threshold: int = 1) -> List[Dict[str, Any]]: - """ - Query all projects created more than N days ago. - - Args: - days_threshold: Number of days threshold (default: 1) - - Returns: - List of projects with their sandbox information - """ - global db_connection - if db_connection is None: - db_connection = DBConnection() - - client = await db_connection.client - - # Print the Supabase URL being used - print(f"Using Supabase URL: {os.getenv('SUPABASE_URL')}") - - # Calculate the date threshold - threshold_date = (datetime.now() - timedelta(days=days_threshold)).isoformat() - - # Initialize variables for pagination - all_projects = [] - page_size = 1000 - current_page = 0 - has_more = True - - logger.info(f"Starting to fetch projects older than {days_threshold} day(s)") - print(f"Looking for projects created before: {threshold_date}") - - # Paginate through all projects - while has_more: - # Query projects with pagination - start_range = current_page * page_size - end_range = start_range + page_size - 1 - - logger.info(f"Fetching projects page {current_page+1} (range: {start_range}-{end_range})") - - try: - result = await client.table('projects').select( - 'project_id', - 'name', - 'created_at', - 'account_id', - 'sandbox' - ).order('created_at', desc=True).range(start_range, end_range).execute() - - # Debug info - print raw response - print(f"Response data length: {len(result.data)}") - - if not result.data: - print("No more data returned from query, ending pagination") - has_more = False - else: - # Print a sample project to see the actual data structure - if current_page == 0 and result.data: - print(f"Sample project data: {result.data[0]}") - - all_projects.extend(result.data) - current_page += 1 - - # Progress update - logger.info(f"Loaded {len(all_projects)} projects so far") - print(f"Loaded {len(all_projects)} projects so far...") - - # Check if we've reached the end - if we got fewer results than the page size - if len(result.data) < page_size: - print(f"Got {len(result.data)} records which is less than page size {page_size}, ending pagination") - has_more = False - else: - print(f"Full page returned ({len(result.data)} records), continuing to next page") - - except Exception as e: - logger.error(f"Error during pagination: {str(e)}") - print(f"Error during pagination: {str(e)}") - has_more = False # Stop on error - - # Print the query result summary - total_projects = len(all_projects) - print(f"Found {total_projects} total projects in database") - logger.info(f"Total projects found in database: {total_projects}") - - if not all_projects: - logger.info("No projects found in database") - return [] - - # Filter projects that are older than the threshold and have sandbox information - old_projects_with_sandboxes = [ - project for project in all_projects - if project.get('created_at') and project.get('created_at') < threshold_date - and project.get('sandbox') and project['sandbox'].get('id') - ] - - logger.info(f"Found {len(old_projects_with_sandboxes)} old projects with sandboxes") - - # Print a few sample old projects for debugging - if old_projects_with_sandboxes: - print("\nSample of old projects with sandboxes:") - for i, project in enumerate(old_projects_with_sandboxes[:3]): - print(f" {i+1}. {project.get('name')} (Created: {project.get('created_at')})") - print(f" Sandbox ID: {project['sandbox'].get('id')}") - if i >= 2: - break - - return old_projects_with_sandboxes - - -async def archive_sandbox(project: Dict[str, Any], dry_run: bool) -> bool: - """ - Archive a single sandbox. - - Args: - project: Project information containing sandbox to archive - dry_run: If True, only simulate archiving - - Returns: - True if successful, False otherwise - """ - sandbox_id = project['sandbox'].get('id') - project_name = project.get('name', 'Unknown') - project_id = project.get('project_id', 'Unknown') - created_at = project.get('created_at', 'Unknown') - - try: - logger.info(f"Checking sandbox {sandbox_id} for project '{project_name}' (ID: {project_id}, Created: {created_at})") - - if dry_run: - logger.info(f"DRY RUN: Would archive sandbox {sandbox_id}") - print(f"Would archive sandbox {sandbox_id} for project '{project_name}' (Created: {created_at})") - return True - - # Get the sandbox - sandbox = daytona.get(sandbox_id) - - # Check sandbox state - it must be stopped before archiving - sandbox_info = sandbox.info() - - # Log the current state - logger.info(f"Sandbox {sandbox_id} is in '{sandbox_info.state}' state") - - # Only archive if the sandbox is in the stopped state - if sandbox_info.state == "stopped": - logger.info(f"Archiving sandbox {sandbox_id} as it is in stopped state") - sandbox.archive() - logger.info(f"Successfully archived sandbox {sandbox_id}") - return True - else: - logger.info(f"Skipping sandbox {sandbox_id} as it is not in stopped state (current: {sandbox_info.state})") - return True - - except Exception as e: - import traceback - error_type = type(e).__name__ - stack_trace = traceback.format_exc() - - # Log detailed error information - logger.error(f"Error processing sandbox {sandbox_id}: {str(e)}") - logger.error(f"Error type: {error_type}") - logger.error(f"Stack trace:\n{stack_trace}") - - # If the exception has a response attribute (like in HTTP errors), log it - if hasattr(e, 'response'): - try: - response_data = e.response.json() if hasattr(e.response, 'json') else str(e.response) - logger.error(f"Response data: {response_data}") - except Exception: - logger.error(f"Could not parse response data from error") - - print(f"Failed to process sandbox {sandbox_id}: {error_type} - {str(e)}") - return False - - -async def process_sandboxes(old_projects: List[Dict[str, Any]], dry_run: bool) -> tuple[int, int]: - """ - Process all sandboxes sequentially. - - Args: - old_projects: List of projects older than the threshold - dry_run: Whether to actually archive sandboxes or just simulate - - Returns: - Tuple of (processed_count, failed_count) - """ - processed_count = 0 - failed_count = 0 - - if dry_run: - logger.info(f"DRY RUN: Would archive {len(old_projects)} sandboxes") - else: - logger.info(f"Archiving {len(old_projects)} sandboxes") - - print(f"Processing {len(old_projects)} sandboxes...") - - # Process each sandbox sequentially - for i, project in enumerate(old_projects): - success = await archive_sandbox(project, dry_run) - - if success: - processed_count += 1 - else: - failed_count += 1 - - # Print progress periodically - if (i + 1) % 20 == 0 or (i + 1) == len(old_projects): - progress = (i + 1) / len(old_projects) * 100 - print(f"Progress: {i + 1}/{len(old_projects)} sandboxes processed ({progress:.1f}%)") - print(f" - Processed: {processed_count}, Failed: {failed_count}") - - return processed_count, failed_count - - -async def main(): - """Main function to run the script.""" - # Parse command line arguments - parser = argparse.ArgumentParser(description='Archive sandboxes for projects older than N days') - parser.add_argument('--days', type=int, default=1, help='Age threshold in days (default: 1)') - parser.add_argument('--dry-run', action='store_true', help='Show what would be archived without actually archiving') - args = parser.parse_args() - - logger.info(f"Starting sandbox cleanup for projects older than {args.days} day(s)") - if args.dry_run: - logger.info("DRY RUN MODE - No sandboxes will be archived") - - # Print environment info - print(f"Environment Mode: {os.getenv('ENV_MODE', 'Not set')}") - print(f"Daytona Server: {os.getenv('DAYTONA_SERVER_URL', 'Not set')}") - - try: - # Initialize global DB connection - global db_connection - db_connection = DBConnection() - - # Get all projects older than the threshold - old_projects = await get_old_projects(args.days) - - if not old_projects: - logger.info(f"No projects older than {args.days} day(s) with sandboxes to process") - print(f"No projects older than {args.days} day(s) with sandboxes to archive.") - return - - # Print summary of what will be processed - print("\n===== SANDBOX CLEANUP SUMMARY =====") - print(f"Projects older than {args.days} day(s): {len(old_projects)}") - print(f"Sandboxes that will be archived: {len(old_projects)}") - print("===================================") - - logger.info(f"Found {len(old_projects)} projects older than {args.days} day(s)") - - # Ask for confirmation before proceeding - if not args.dry_run: - print("\n⚠️ WARNING: You are about to archive sandboxes for old projects ⚠️") - print("This action cannot be undone!") - confirmation = input("\nAre you sure you want to proceed with archiving? (TRUE/FALSE): ").strip().upper() - - if confirmation != "TRUE": - print("Archiving cancelled. Exiting script.") - logger.info("Archiving cancelled by user") - return - - print("\nProceeding with sandbox archiving...\n") - logger.info("User confirmed sandbox archiving") - - # List a sample of projects to be processed - for i, project in enumerate(old_projects[:5]): # Just show first 5 for brevity - created_at = project.get('created_at', 'Unknown') - project_name = project.get('name', 'Unknown') - project_id = project.get('project_id', 'Unknown') - sandbox_id = project['sandbox'].get('id') - - print(f"{i+1}. Project: {project_name}") - print(f" Project ID: {project_id}") - print(f" Created At: {created_at}") - print(f" Sandbox ID: {sandbox_id}") - - if len(old_projects) > 5: - print(f" ... and {len(old_projects) - 5} more projects") - - # Process all sandboxes - processed_count, failed_count = await process_sandboxes(old_projects, args.dry_run) - - # Print final summary - print("\nSandbox Cleanup Summary:") - print(f"Total projects older than {args.days} day(s): {len(old_projects)}") - print(f"Total sandboxes processed: {len(old_projects)}") - - if args.dry_run: - print(f"DRY RUN: No sandboxes were actually archived") - else: - print(f"Successfully processed: {processed_count}") - print(f"Failed to process: {failed_count}") - - logger.info("Sandbox cleanup completed") - - except Exception as e: - logger.error(f"Error during sandbox cleanup: {str(e)}") - sys.exit(1) - finally: - # Clean up database connection - if db_connection: - await DBConnection.disconnect() - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/app/utils/scripts/copy_project.py b/app/utils/scripts/copy_project.py deleted file mode 100644 index e35cdf0d1..000000000 --- a/app/utils/scripts/copy_project.py +++ /dev/null @@ -1,388 +0,0 @@ -import asyncio -import argparse -from dotenv import load_dotenv - -load_dotenv(".env") - -from services.supabase import DBConnection -from daytona_sdk import Sandbox -from sandbox.sandbox import daytona, create_sandbox, delete_sandbox -from utils.logger import logger - -db_connection = None -db = None - - -async def get_db(): - global db_connection, db - if db_connection is None or db is None: - db_connection = DBConnection() - db = await db_connection.client - return db - - -async def get_project(project_id: str): - db = await get_db() - project = ( - await db.schema("public") - .from_("projects") - .select("*") - .eq("project_id", project_id) - .maybe_single() - .execute() - ) - return project.data - - -async def get_threads(project_id: str): - db = await get_db() - threads = ( - await db.schema("public") - .from_("threads") - .select("*") - .eq("project_id", project_id) - .execute() - ) - return threads.data - - -async def copy_thread(thread_id: str, account_id: str, project_id: str): - db = await get_db() - thread = ( - await db.schema("public") - .from_("threads") - .select("*") - .eq("thread_id", thread_id) - .maybe_single() - .execute() - ) - - if not thread.data: - raise Exception(f"Thread {thread_id} not found") - - thread_data = thread.data - new_thread = ( - await db.schema("public") - .from_("threads") - .insert( - { - "account_id": account_id, - "project_id": project_id, - "is_public": thread_data["is_public"], - "agent_id": thread_data["agent_id"], - "metadata": thread_data["metadata"] or {}, - } - ) - .execute() - ) - return new_thread.data[0] - - -async def copy_project(project_id: str, to_user_id: str, sandbox_data: dict): - db = await get_db() - project = await get_project(project_id) - to_user = await get_user(to_user_id) - - if not project: - raise Exception(f"Project {project_id} not found") - if not to_user: - raise Exception(f"User {to_user_id} not found") - - result = ( - await db.schema("public") - .from_("projects") - .insert( - { - "name": project["name"], - "description": project["description"], - "account_id": to_user["id"], - "is_public": project["is_public"], - "sandbox": sandbox_data, - } - ) - .execute() - ) - return result.data[0] - - -async def copy_agent_runs(thread_id: str, new_thread_id: str): - db = await get_db() - agent_runs = ( - await db.schema("public") - .from_("agent_runs") - .select("*") - .eq("thread_id", thread_id) - .execute() - ) - - async def copy_single_agent_run(agent_run, new_thread_id, db): - new_agent_run = ( - await db.schema("public") - .from_("agent_runs") - .insert( - { - "thread_id": new_thread_id, - "status": agent_run["status"], - "started_at": agent_run["started_at"], - "completed_at": agent_run["completed_at"], - "responses": agent_run["responses"], - "error": agent_run["error"], - } - ) - .execute() - ) - return new_agent_run.data[0] - - tasks = [ - copy_single_agent_run(agent_run, new_thread_id, db) - for agent_run in agent_runs.data - ] - new_agent_runs = await asyncio.gather(*tasks) - return new_agent_runs - - -async def copy_messages(thread_id: str, new_thread_id: str): - db = await get_db() - messages_data = [] - offset = 0 - batch_size = 1000 - - while True: - batch = ( - await db.schema("public") - .from_("messages") - .select("*") - .eq("thread_id", thread_id) - .range(offset, offset + batch_size - 1) - .execute() - ) - - if not batch.data: - break - - messages_data.extend(batch.data) - - if len(batch.data) < batch_size: - break - - offset += batch_size - - async def copy_single_message(message, new_thread_id, db): - new_message = ( - await db.schema("public") - .from_("messages") - .insert( - { - "thread_id": new_thread_id, - "type": message["type"], - "is_llm_message": message["is_llm_message"], - "content": message["content"], - "metadata": message["metadata"], - "created_at": message["created_at"], - "updated_at": message["updated_at"], - } - ) - .execute() - ) - return new_message.data[0] - - tasks = [] - for message in messages_data: - tasks.append(copy_single_message(message, new_thread_id, db)) - - # Process tasks in batches to avoid overwhelming the database - batch_size = 100 - new_messages = [] - for i in range(0, len(tasks), batch_size): - batch_tasks = tasks[i : i + batch_size] - batch_results = await asyncio.gather(*batch_tasks) - new_messages.extend(batch_results) - # Add delay between batches - if i + batch_size < len(tasks): - await asyncio.sleep(1) - - return new_messages - - -async def get_user(user_id: str): - db = await get_db() - user = await db.auth.admin.get_user_by_id(user_id) - return user.user.model_dump() - - -async def copy_sandbox(sandbox_id: str, password: str, project_id: str) -> Sandbox: - sandbox = daytona.find_one(sandbox_id=sandbox_id) - if not sandbox: - raise Exception(f"Sandbox {sandbox_id} not found") - - # TODO: Currently there's no way to create a copy of a sandbox, so we will create a new one - new_sandbox = create_sandbox(password, project_id) - return new_sandbox - - -async def main(): - """Main function to run the script.""" - # Parse command line arguments - parser = argparse.ArgumentParser(description="Create copy of a project") - parser.add_argument( - "--project-id", type=str, help="Project ID to copy", required=True - ) - parser.add_argument( - "--new-user-id", - type=str, - default=None, - help="[OPTIONAL] User ID to copy the project to", - required=False, - ) - args = parser.parse_args() - - # Initialize variables for cleanup - new_sandbox = None - new_project = None - new_threads = [] - new_agent_runs = [] - new_messages = [] - - try: - project = await get_project(args.project_id) - if not project: - raise Exception(f"Project {args.project_id} not found") - - to_user_id = args.new_user_id or project["account_id"] - to_user = await get_user(to_user_id) - - logger.info( - f"Project: {project['project_id']} ({project['name']}) -> User: {to_user['id']} ({to_user['email']})" - ) - - new_sandbox = await copy_sandbox( - project["sandbox"]["id"], project["sandbox"]["pass"], args.project_id - ) - if new_sandbox: - vnc_link = new_sandbox.get_preview_link(6080) - website_link = new_sandbox.get_preview_link(8080) - vnc_url = ( - vnc_link.url - if hasattr(vnc_link, "url") - else str(vnc_link).split("url='")[1].split("'")[0] - ) - website_url = ( - website_link.url - if hasattr(website_link, "url") - else str(website_link).split("url='")[1].split("'")[0] - ) - token = None - if hasattr(vnc_link, "token"): - token = vnc_link.token - elif "token='" in str(vnc_link): - token = str(vnc_link).split("token='")[1].split("'")[0] - else: - raise Exception("Failed to create new sandbox") - - sandbox_data = { - "id": new_sandbox.id, - "pass": project["sandbox"]["pass"], - "token": token, - "vnc_preview": vnc_url, - "sandbox_url": website_url, - } - logger.info(f"New sandbox: {new_sandbox.id}") - - new_project = await copy_project( - project["project_id"], to_user["id"], sandbox_data - ) - logger.info(f"New project: {new_project['project_id']} ({new_project['name']})") - - threads = await get_threads(project["project_id"]) - if threads: - for thread in threads: - new_thread = await copy_thread( - thread["thread_id"], to_user["id"], new_project["project_id"] - ) - new_threads.append(new_thread) - logger.info(f"New threads: {len(new_threads)}") - - for i in range(len(new_threads)): - runs = await copy_agent_runs( - threads[i]["thread_id"], new_threads[i]["thread_id"] - ) - new_agent_runs.extend(runs) - logger.info(f"New agent runs: {len(new_agent_runs)}") - - for i in range(len(new_threads)): - messages = await copy_messages( - threads[i]["thread_id"], new_threads[i]["thread_id"] - ) - new_messages.extend(messages) - logger.info(f"New messages: {len(new_messages)}") - else: - logger.info("No threads found for this project") - - except Exception as e: - db = await get_db() - # Clean up any resources that were created before the error - if new_sandbox: - try: - logger.info(f"Cleaning up sandbox: {new_sandbox.id}") - await delete_sandbox(new_sandbox.id) - except Exception as cleanup_error: - logger.error( - f"Error cleaning up sandbox {new_sandbox.id}: {cleanup_error}" - ) - - if new_messages: - for message in new_messages: - try: - logger.info(f"Cleaning up message: {message['message_id']}") - await db.table("messages").delete().eq( - "message_id", message["message_id"] - ).execute() - except Exception as cleanup_error: - logger.error( - f"Error cleaning up message {message['message_id']}: {cleanup_error}" - ) - - if new_agent_runs: - for agent_run in new_agent_runs: - try: - logger.info(f"Cleaning up agent run: {agent_run['id']}") - await db.table("agent_runs").delete().eq( - "id", agent_run["id"] - ).execute() - except Exception as cleanup_error: - logger.error( - f"Error cleaning up agent run {agent_run['id']}: {cleanup_error}" - ) - - if new_threads: - for thread in new_threads: - try: - logger.info(f"Cleaning up thread: {thread['thread_id']}") - await db.table("threads").delete().eq( - "thread_id", thread["thread_id"] - ).execute() - except Exception as cleanup_error: - logger.error( - f"Error cleaning up thread {thread['thread_id']}: {cleanup_error}" - ) - - if new_project: - try: - logger.info(f"Cleaning up project: {new_project['project_id']}") - await db.table("projects").delete().eq( - "project_id", new_project["project_id"] - ).execute() - except Exception as cleanup_error: - logger.error( - f"Error cleaning up project {new_project['project_id']}: {cleanup_error}" - ) - - await DBConnection.disconnect() - raise e - - finally: - await DBConnection.disconnect() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/app/utils/scripts/delete_user_sandboxes.py b/app/utils/scripts/delete_user_sandboxes.py deleted file mode 100644 index fe1254ef4..000000000 --- a/app/utils/scripts/delete_user_sandboxes.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env python -""" -Script to query and delete sandboxes for a given account ID. - -Usage: - python delete_user_sandboxes.py -""" - -import asyncio -import sys -import os -from typing import List, Dict, Any -from dotenv import load_dotenv - -# Load script-specific environment variables -load_dotenv(".env") - -from services.supabase import DBConnection -from sandbox.sandbox import daytona -from utils.logger import logger - - -async def get_user_sandboxes(account_id: str) -> List[Dict[str, Any]]: - """ - Query all projects and their sandboxes associated with a specific account ID. - - Args: - account_id: The account ID to query - - Returns: - List of projects with sandbox information - """ - db = DBConnection() - client = await db.client - - # Print the Supabase URL being used - print(f"Using Supabase URL: {os.getenv('SUPABASE_URL')}") - - # Query projects by account_id - result = await client.table('projects').select( - 'project_id', - 'name', - 'sandbox' - ).eq('account_id', account_id).execute() - - # Print the query result for debugging - print(f"Query result: {result}") - - if not result.data: - logger.info(f"No projects found for account ID: {account_id}") - return [] - - # Filter projects with sandbox information - projects_with_sandboxes = [ - project for project in result.data - if project.get('sandbox') and project['sandbox'].get('id') - ] - - logger.info(f"Found {len(projects_with_sandboxes)} projects with sandboxes for account ID: {account_id}") - return projects_with_sandboxes - - -async def delete_sandboxes(projects: List[Dict[str, Any]]) -> None: - """ - Delete all sandboxes from the provided list of projects. - - Args: - projects: List of projects with sandbox information - """ - if not projects: - logger.info("No sandboxes to delete") - return - - for project in projects: - sandbox_id = project['sandbox'].get('id') - project_name = project.get('name', 'Unknown') - project_id = project.get('project_id', 'Unknown') - - if not sandbox_id: - continue - - try: - logger.info(f"Deleting sandbox {sandbox_id} for project '{project_name}' (ID: {project_id})") - - # Get the sandbox and delete it - sandbox = daytona.get(sandbox_id) - daytona.delete(sandbox) - - logger.info(f"Successfully deleted sandbox {sandbox_id}") - except Exception as e: - logger.error(f"Error deleting sandbox {sandbox_id}: {str(e)}") - - -async def main(): - """Main function to run the script.""" - if len(sys.argv) != 2: - print(f"Usage: python {sys.argv[0]} ") - sys.exit(1) - - account_id = sys.argv[1] - logger.info(f"Starting sandbox cleanup for account ID: {account_id}") - - # Print environment info - print(f"Environment Mode: {os.getenv('ENV_MODE', 'Not set')}") - print(f"Daytona Server: {os.getenv('DAYTONA_SERVER_URL', 'Not set')}") - - try: - # Query projects with sandboxes - projects = await get_user_sandboxes(account_id) - - # Print sandbox information - for i, project in enumerate(projects): - sandbox_id = project['sandbox'].get('id', 'N/A') - print(f"{i+1}. Project: {project.get('name', 'Unknown')}") - print(f" Project ID: {project.get('project_id', 'Unknown')}") - print(f" Sandbox ID: {sandbox_id}") - - # Confirm deletion - if projects: - confirm = input(f"\nDelete {len(projects)} sandboxes? (y/n): ") - if confirm.lower() == 'y': - await delete_sandboxes(projects) - logger.info("Sandbox cleanup completed") - else: - logger.info("Sandbox deletion cancelled") - else: - logger.info("No sandboxes found for deletion") - - except Exception as e: - logger.error(f"Error during sandbox cleanup: {str(e)}") - sys.exit(1) - finally: - # Clean up database connection - await DBConnection.disconnect() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/app/utils/scripts/export_import_project.py b/app/utils/scripts/export_import_project.py deleted file mode 100644 index b5632ff20..000000000 --- a/app/utils/scripts/export_import_project.py +++ /dev/null @@ -1,400 +0,0 @@ -import asyncio -import argparse -import json -import os -from datetime import datetime -from dotenv import load_dotenv - -load_dotenv(".env") - -from services.supabase import DBConnection -from daytona_sdk import Sandbox -from sandbox.sandbox import daytona, create_sandbox, delete_sandbox -from utils.logger import logger - -db_connection = None -db = None - - -async def get_db(): - global db_connection, db - if db_connection is None or db is None: - db_connection = DBConnection() - db = await db_connection.client - return db - - -async def get_project(project_id: str): - db = await get_db() - project = ( - await db.schema("public") - .from_("projects") - .select("*") - .eq("project_id", project_id) - .maybe_single() - .execute() - ) - return project.data - - -async def get_threads(project_id: str): - db = await get_db() - threads = ( - await db.schema("public") - .from_("threads") - .select("*") - .eq("project_id", project_id) - .execute() - ) - return threads.data - - -async def get_agent_runs(thread_id: str): - db = await get_db() - agent_runs = ( - await db.schema("public") - .from_("agent_runs") - .select("*") - .eq("thread_id", thread_id) - .execute() - ) - return agent_runs.data - - -async def get_messages(thread_id: str): - db = await get_db() - messages_data = [] - offset = 0 - batch_size = 1000 - - while True: - batch = ( - await db.schema("public") - .from_("messages") - .select("*") - .eq("thread_id", thread_id) - .range(offset, offset + batch_size - 1) - .execute() - ) - - if not batch.data: - break - - messages_data.extend(batch.data) - - if len(batch.data) < batch_size: - break - - offset += batch_size - - return messages_data - - -async def get_user(user_id: str): - db = await get_db() - user = await db.auth.admin.get_user_by_id(user_id) - return user.user.model_dump() - - -async def export_project_to_file(project_id: str, output_file: str): - """Export all project data to a JSON file.""" - try: - logger.info(f"Starting export of project {project_id}") - - # Get project data - project = await get_project(project_id) - if not project: - raise Exception(f"Project {project_id} not found") - - logger.info(f"Exporting project: {project['name']}") - - # Get threads - threads = await get_threads(project_id) - logger.info(f"Found {len(threads)} threads") - - # Get agent runs and messages for each thread - threads_data = [] - for thread in threads: - thread_data = dict(thread) - - # Get agent runs for this thread - agent_runs = await get_agent_runs(thread["thread_id"]) - thread_data["agent_runs"] = agent_runs - - # Get messages for this thread - messages = await get_messages(thread["thread_id"]) - thread_data["messages"] = messages - - threads_data.append(thread_data) - logger.info(f"Thread {thread['thread_id']}: {len(agent_runs)} runs, {len(messages)} messages") - - # Prepare export data - export_data = { - "export_metadata": { - "export_date": datetime.now().isoformat(), - "project_id": project_id, - "project_name": project["name"] - }, - "project": project, - "threads": threads_data - } - - # Write to file - os.makedirs(os.path.dirname(output_file), exist_ok=True) - with open(output_file, 'w', encoding='utf-8') as f: - json.dump(export_data, f, indent=2, ensure_ascii=False, default=str) - - logger.info(f"Project exported successfully to {output_file}") - logger.info(f"Export summary: 1 project, {len(threads_data)} threads") - - return export_data - - except Exception as e: - logger.error(f"Error exporting project: {e}") - raise e - finally: - await DBConnection.disconnect() - - -async def import_project_from_file(input_file: str, to_user_id: str = None, create_new_sandbox: bool = True): - """Import project data from a JSON file and create a new project.""" - new_sandbox = None - new_project = None - new_threads = [] - new_agent_runs = [] - new_messages = [] - - try: - logger.info(f"Starting import from {input_file}") - - # Read data from file - with open(input_file, 'r', encoding='utf-8') as f: - import_data = json.load(f) - - project_data = import_data["project"] - threads_data = import_data["threads"] - - logger.info(f"Importing project: {project_data['name']}") - logger.info(f"Found {len(threads_data)} threads to import") - - # Determine target user - to_user_id = to_user_id or project_data["account_id"] - to_user = await get_user(to_user_id) - - logger.info(f"Target user: {to_user['id']} ({to_user['email']})") - - # Create new sandbox if requested - if create_new_sandbox: - logger.info("Creating new sandbox...") - new_sandbox = create_sandbox(project_data["sandbox"]["pass"], project_data["project_id"]) - - if new_sandbox: - vnc_link = new_sandbox.get_preview_link(6080) - website_link = new_sandbox.get_preview_link(8080) - vnc_url = ( - vnc_link.url - if hasattr(vnc_link, "url") - else str(vnc_link).split("url='")[1].split("'")[0] - ) - website_url = ( - website_link.url - if hasattr(website_link, "url") - else str(website_link).split("url='")[1].split("'")[0] - ) - token = None - if hasattr(vnc_link, "token"): - token = vnc_link.token - elif "token='" in str(vnc_link): - token = str(vnc_link).split("token='")[1].split("'")[0] - - sandbox_data = { - "id": new_sandbox.id, - "pass": project_data["sandbox"]["pass"], - "token": token, - "vnc_preview": vnc_url, - "sandbox_url": website_url, - } - logger.info(f"New sandbox created: {new_sandbox.id}") - else: - raise Exception("Failed to create new sandbox") - else: - # Use existing sandbox data - sandbox_data = project_data["sandbox"] - logger.info("Using existing sandbox data") - - # Create new project - db = await get_db() - result = ( - await db.schema("public") - .from_("projects") - .insert( - { - "name": project_data["name"], - "description": project_data["description"], - "account_id": to_user["id"], - "is_public": project_data["is_public"], - "sandbox": sandbox_data, - } - ) - .execute() - ) - new_project = result.data[0] - logger.info(f"New project created: {new_project['project_id']} ({new_project['name']})") - - # Import threads - for thread_data in threads_data: - # Create new thread - new_thread = ( - await db.schema("public") - .from_("threads") - .insert( - { - "account_id": to_user["id"], - "project_id": new_project["project_id"], - "is_public": thread_data["is_public"], - "agent_id": thread_data["agent_id"], - "metadata": thread_data["metadata"] or {}, - } - ) - .execute() - ) - new_thread = new_thread.data[0] - new_threads.append(new_thread) - - # Create agent runs for this thread - for agent_run_data in thread_data.get("agent_runs", []): - new_agent_run = ( - await db.schema("public") - .from_("agent_runs") - .insert( - { - "thread_id": new_thread["thread_id"], - "status": agent_run_data["status"], - "started_at": agent_run_data["started_at"], - "completed_at": agent_run_data["completed_at"], - "responses": agent_run_data["responses"], - "error": agent_run_data["error"], - } - ) - .execute() - ) - new_agent_runs.append(new_agent_run.data[0]) - - # Create messages for this thread in batches - messages = thread_data.get("messages", []) - batch_size = 100 - for i in range(0, len(messages), batch_size): - batch_messages = messages[i:i + batch_size] - message_inserts = [] - - for message_data in batch_messages: - message_inserts.append({ - "thread_id": new_thread["thread_id"], - "type": message_data["type"], - "is_llm_message": message_data["is_llm_message"], - "content": message_data["content"], - "metadata": message_data["metadata"], - "created_at": message_data["created_at"], - "updated_at": message_data["updated_at"], - }) - - if message_inserts: - batch_result = ( - await db.schema("public") - .from_("messages") - .insert(message_inserts) - .execute() - ) - new_messages.extend(batch_result.data) - - # Add delay between batches - if i + batch_size < len(messages): - await asyncio.sleep(0.5) - - logger.info(f"Thread imported: {len(thread_data.get('agent_runs', []))} runs, {len(messages)} messages") - - logger.info(f"Import completed successfully!") - logger.info(f"Summary: 1 project, {len(new_threads)} threads, {len(new_agent_runs)} agent runs, {len(new_messages)} messages") - - return { - "project": new_project, - "threads": new_threads, - "agent_runs": new_agent_runs, - "messages": new_messages - } - - except Exception as e: - logger.error(f"Error importing project: {e}") - - # Clean up any resources that were created before the error - db = await get_db() - - if new_sandbox: - try: - logger.info(f"Cleaning up sandbox: {new_sandbox.id}") - await delete_sandbox(new_sandbox.id) - except Exception as cleanup_error: - logger.error(f"Error cleaning up sandbox {new_sandbox.id}: {cleanup_error}") - - if new_messages: - for message in new_messages: - try: - await db.table("messages").delete().eq("message_id", message["message_id"]).execute() - except Exception as cleanup_error: - logger.error(f"Error cleaning up message {message['message_id']}: {cleanup_error}") - - if new_agent_runs: - for agent_run in new_agent_runs: - try: - await db.table("agent_runs").delete().eq("id", agent_run["id"]).execute() - except Exception as cleanup_error: - logger.error(f"Error cleaning up agent run {agent_run['id']}: {cleanup_error}") - - if new_threads: - for thread in new_threads: - try: - await db.table("threads").delete().eq("thread_id", thread["thread_id"]).execute() - except Exception as cleanup_error: - logger.error(f"Error cleaning up thread {thread['thread_id']}: {cleanup_error}") - - if new_project: - try: - await db.table("projects").delete().eq("project_id", new_project["project_id"]).execute() - except Exception as cleanup_error: - logger.error(f"Error cleaning up project {new_project['project_id']}: {cleanup_error}") - - await DBConnection.disconnect() - raise e - - finally: - await DBConnection.disconnect() - - -async def main(): - """Main function to run the script.""" - parser = argparse.ArgumentParser(description="Export/Import project data") - parser.add_argument("action", choices=["export", "import"], help="Action to perform") - parser.add_argument("--project-id", type=str, help="Project ID to export (required for export)") - parser.add_argument("--file", type=str, help="File path for export/import", required=True) - parser.add_argument("--user-id", type=str, help="User ID to import project to (optional for import)") - parser.add_argument("--no-sandbox", action="store_true", help="Don't create new sandbox during import") - - args = parser.parse_args() - - try: - if args.action == "export": - if not args.project_id: - raise Exception("--project-id is required for export") - await export_project_to_file(args.project_id, args.file) - - elif args.action == "import": - create_new_sandbox = not args.no_sandbox - await import_project_from_file(args.file, args.user_id, create_new_sandbox) - - except Exception as e: - logger.error(f"Script failed: {e}") - raise e - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/app/utils/scripts/generate_share_links.py b/app/utils/scripts/generate_share_links.py deleted file mode 100644 index 6607eeb7d..000000000 --- a/app/utils/scripts/generate_share_links.py +++ /dev/null @@ -1,166 +0,0 @@ -import asyncio -import sys -import os -from typing import List, Dict, Any -from datetime import datetime -import random -from dotenv import load_dotenv - -load_dotenv(".env") - -from services.supabase import DBConnection -from utils.logger import logger - -db_connection = None - - -async def get_random_thread_ids(n: int) -> List[str]: - global db_connection - if db_connection is None: - db_connection = DBConnection() - - client = await db_connection.client - - print(f"Using Supabase URL: {os.getenv('SUPABASE_URL')}") - - all_thread_ids = [] - page_size = 1000 - current_page = 0 - has_more = True - - print("Fetching all thread IDs from database (paginated)...") - - while has_more: - start_range = current_page * page_size - end_range = start_range + page_size - 1 - - print(f"Fetching page {current_page + 1} (rows {start_range}-{end_range})...") - - try: - result = await client.table('threads').select('thread_id').range(start_range, end_range).execute() - - if not result.data: - has_more = False - else: - page_thread_ids = [thread['thread_id'] for thread in result.data] - all_thread_ids.extend(page_thread_ids) - - print(f"Loaded {len(page_thread_ids)} thread IDs (total so far: {len(all_thread_ids)})") - if len(result.data) < page_size: - has_more = False - else: - current_page += 1 - - except Exception as e: - logger.error(f"Error during pagination: {str(e)}") - has_more = False - - print(f"Found {len(all_thread_ids)} total thread IDs in database") - - if not all_thread_ids: - logger.info("No threads found in database") - return [] - - if len(all_thread_ids) <= n: - logger.warning(f"Requested {n} threads but only {len(all_thread_ids)} available. Returning all.") - selected_thread_ids = all_thread_ids - else: - selected_thread_ids = random.sample(all_thread_ids, n) - - logger.info(f"Retrieved {len(selected_thread_ids)} random thread IDs") - return selected_thread_ids - - -async def generate_share_links(n: int) -> List[str]: - try: - thread_ids = await get_random_thread_ids(n) - - if not thread_ids: - logger.warning("No thread IDs found, returning empty list") - return [] - - share_links = [f"suna.so/share/{thread_id}" for thread_id in thread_ids] - - logger.info(f"Generated {len(share_links)} share links") - return share_links - - except Exception as e: - logger.error(f"Error generating share links: {str(e)}") - raise - - -def save_links_to_file(share_links: List[str], filename: str = None) -> str: - """Save share links to a text file and return the filename.""" - if filename is None: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"share_links_{timestamp}.txt" - - try: - with open(filename, 'w', encoding='utf-8') as f: - f.write(f"Share Links Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") - f.write("=" * 60 + "\n\n") - - for i, link in enumerate(share_links, 1): - f.write(f"{i}. {link}\n") - - f.write(f"\nTotal: {len(share_links)} share links generated\n") - - logger.info(f"Share links saved to {filename}") - return filename - - except Exception as e: - logger.error(f"Error saving links to file: {str(e)}") - raise - - -async def main(): - logger.info("Starting share link generation process") - - try: - global db_connection - db_connection = DBConnection() - - try: - n = int(input("Enter the number of share links to generate: ")) - if n <= 0: - print("Number must be positive") - return - except ValueError: - print("Please enter a valid number") - return - - custom_filename = input("Enter filename (press Enter for auto-generated): ").strip() - if not custom_filename: - custom_filename = None - elif not custom_filename.endswith('.txt'): - custom_filename += '.txt' - - print(f"\nGenerating {n} random share links...") - - share_links = await generate_share_links(n) - - if not share_links: - print("No share links were generated") - return - - print(f"\nGenerated {len(share_links)} share links:") - print("-" * 50) - for i, link in enumerate(share_links, 1): - print(f"{i}. {link}") - - saved_filename = save_links_to_file(share_links, custom_filename) - - print(f"\nTotal: {len(share_links)} share links generated") - print(f"Links saved to: {saved_filename}") - logger.info("Share link generation completed") - - except Exception as e: - logger.error(f"Error during share link generation: {str(e)}") - sys.exit(1) - finally: - if db_connection: - await DBConnection.disconnect() - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/app/utils/scripts/get_monthly_usage.py b/app/utils/scripts/get_monthly_usage.py deleted file mode 100644 index 4fdb1e019..000000000 --- a/app/utils/scripts/get_monthly_usage.py +++ /dev/null @@ -1,248 +0,0 @@ -""" -Monthly Usage Script - -This script calculates the monthly usage (in agent run minutes) for a specific user during a specific month. - -Usage: - python backend/utils/scripts/get_monthly_usage.py --user-id --year --month [--verbose] - -Arguments: - --user-id The user ID to get usage for (required) - --year The year (e.g., 2024) (required) - --month The month (1-12) (required) - --verbose Enable verbose logging (optional) - -Examples: - # Get usage for December 2024 - python backend/utils/scripts/get_monthly_usage.py --user-id "user123" --year 2024 --month 12 - - # Get usage with verbose logging - python backend/utils/scripts/get_monthly_usage.py --user-id "user123" --year 2024 --month 11 --verbose - -Output: - The script will output: - - User information (email and ID) - - Month and year - - Total usage in minutes and hours - - Average usage per day (if any usage exists) - - Example output: - === Monthly Usage Report === - User: user@example.com (user123) - Month: December 2024 - Total Usage: 150.45 minutes - Total Usage: 2.51 hours - Average per day: 5.02 minutes - -Features: - - Validates agent runs to exclude invalid durations (>2 hours) - - Handles incomplete agent runs appropriately - - Provides detailed logging for debugging - - Calculates usage only for the specified month - - Shows average daily usage - -Notes: - - The script requires access to the Supabase database - - Make sure the .env file is properly configured - - The script uses the same logic as the billing system for consistency -""" - -import asyncio -import argparse -from datetime import datetime, timezone -from dotenv import load_dotenv - -load_dotenv(".env") - -from services.supabase import DBConnection -from utils.logger import logger - -db_connection = None -db = None - - -async def get_db(): - global db_connection, db - if db_connection is None or db is None: - db_connection = DBConnection() - db = await db_connection.client - return db - - -async def get_user(user_id: str): - """Get user information by user ID.""" - db = await get_db() - user = await db.auth.admin.get_user_by_id(user_id) - return user.user.model_dump() - - -async def calculate_monthly_usage(client, user_id: str, year: int, month: int): - """Calculate total agent run minutes for a specific month for a user.""" - # Get start and end of specified month in UTC - start_of_month = datetime(year, month, 1, tzinfo=timezone.utc) - - # Calculate start of next month for end boundary - if month == 12: - end_of_month = datetime(year + 1, 1, 1, tzinfo=timezone.utc) - else: - end_of_month = datetime(year, month + 1, 1, tzinfo=timezone.utc) - - # First get all threads for this user - threads_result = ( - await client.table("threads") - .select("thread_id") - .eq("account_id", user_id) - .execute() - ) - - if not threads_result.data: - return 0.0, [] - - thread_ids = [t["thread_id"] for t in threads_result.data] - logger.info(f"Found {len(thread_ids)} threads for user {user_id}") - - # Then get all agent runs for these threads in specified month - runs_result = ( - await client.table("agent_runs") - .select("id, started_at, completed_at, thread_id") - .in_("thread_id", thread_ids) - .gte("started_at", start_of_month.isoformat()) - .lt("started_at", end_of_month.isoformat()) - .execute() - ) - - if not runs_result.data: - return 0.0, [] - - logger.info(f"Found {len(runs_result.data)} agent runs in {year}-{month:02d}") - - # Calculate total minutes and collect run details - total_seconds = 0 - valid_runs = 0 - run_details = [] - - for run in runs_result.data: - start_time = datetime.fromisoformat( - run["started_at"].replace("Z", "+00:00") - ).timestamp() - - if run["completed_at"]: - end_time = datetime.fromisoformat( - run["completed_at"].replace("Z", "+00:00") - ).timestamp() - # Skip runs that seem invalid (more than 2 hours) - if start_time < end_time - 7200: - logger.warning(f"Skipping run with duration > 2 hours: {run}") - continue - status = "completed" - else: - # For incomplete runs, use end of month as boundary if run started in that month - end_time = min( - end_of_month.timestamp(), datetime.now(timezone.utc).timestamp() - ) - # Skip runs that started more than 1 hour ago and are still incomplete - if start_time < datetime.now(timezone.utc).timestamp() - 3600: - logger.warning(f"Skipping incomplete run started > 1 hour ago: {run}") - continue - status = "incomplete" - - duration = end_time - start_time - total_seconds += duration - valid_runs += 1 - - # Store run details - run_details.append( - { - "id": run["id"], - "thread_id": run["thread_id"], - "started_at": run["started_at"], - "completed_at": run["completed_at"], - "duration_minutes": duration / 60, - "status": status, - } - ) - - logger.debug(f"Run duration: {duration/60:.2f} minutes") - - logger.info( - f"Processed {valid_runs} valid runs out of {len(runs_result.data)} total runs" - ) - - # Sort runs by duration (longest first) - run_details.sort(key=lambda x: x["duration_minutes"], reverse=True) - - return total_seconds / 60, run_details # Convert to minutes - - -async def main(): - """Main function to run the script.""" - # Parse command line arguments - parser = argparse.ArgumentParser( - description="Get monthly usage for a specific user and month" - ) - parser.add_argument( - "--user-id", type=str, help="User ID to get usage for", required=True - ) - parser.add_argument("--year", type=int, help="Year (e.g., 2024)", required=True) - parser.add_argument("--month", type=int, help="Month (1-12)", required=True) - parser.add_argument( - "--verbose", "-v", action="store_true", help="Enable verbose logging" - ) - - args = parser.parse_args() - - # Validate month - if args.month < 1 or args.month > 12: - raise ValueError("Month must be between 1 and 12") - - try: - # Get user information - try: - user = await get_user(args.user_id) - logger.info(f"User: {user['id']} ({user['email']})") - except Exception as e: - logger.warning(f"Could not fetch user details: {e}") - user = {"id": args.user_id, "email": "unknown"} - - # Get database connection - db = await get_db() - - # Calculate monthly usage - usage_minutes, run_details = await calculate_monthly_usage( - db, args.user_id, args.year, args.month - ) - - # Display results - month_name = datetime(args.year, args.month, 1).strftime("%B") - print(f"\n=== Monthly Usage Report ===") - print(f"User: {user['email']} ({user['id']})") - print(f"Month: {month_name} {args.year}") - print(f"Total Usage: {usage_minutes:.2f} minutes") - print(f"Total Usage: {usage_minutes/60:.2f} hours") - - if usage_minutes > 0: - print(f"Average per day: {usage_minutes/30:.2f} minutes") - - # Display top 10 runs - if run_details: - print(f"\n=== Top Longest Runs ===") - for i, run in enumerate(run_details, 1): - started_at = datetime.fromisoformat( - run["started_at"].replace("Z", "+00:00") - ) - print( - f"{i:2d}. {run['duration_minutes']:6.2f} min | {started_at.strftime('%Y-%m-%d %H:%M')} | {run['status']:10} | Thread: {run['thread_id']} | Run: {run['id']}" - ) - else: - print("\nNo runs found for this period.") - - except Exception as e: - logger.error(f"Error: {e}") - raise e - - finally: - await DBConnection.disconnect() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/app/utils/scripts/set_all_customers_active.py b/app/utils/scripts/set_all_customers_active.py deleted file mode 100644 index a64cf75cc..000000000 --- a/app/utils/scripts/set_all_customers_active.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python -""" -Script to set all Stripe customers in the database to active status. - -Usage: - python update_customer_status.py - -This script: -1. Queries all customer IDs from basejump.billing_customers -2. Sets all customers' active field to True in the database - -Make sure your environment variables are properly set: -- SUPABASE_URL -- SUPABASE_SERVICE_ROLE_KEY -""" - -import asyncio -import sys -import os -from typing import List, Dict, Any -from dotenv import load_dotenv - -# Load script-specific environment variables -load_dotenv(".env") - -from services.supabase import DBConnection -from utils.logger import logger - -# Semaphore to limit concurrent database connections -DB_CONNECTION_LIMIT = 20 -db_semaphore = asyncio.Semaphore(DB_CONNECTION_LIMIT) - -# Global DB connection to reuse -db_connection = None - - -async def get_all_customers() -> List[Dict[str, Any]]: - """ - Query all customers from the database. - - Returns: - List of customers with their ID and account_id - """ - global db_connection - if db_connection is None: - db_connection = DBConnection() - - client = await db_connection.client - - # Print the Supabase URL being used - print(f"Using Supabase URL: {os.getenv('SUPABASE_URL')}") - - # Query all customers from billing_customers - result = await client.schema('basejump').from_('billing_customers').select( - 'id', - 'account_id', - 'active' - ).execute() - - # Print the query result - print(f"Found {len(result.data)} customers in database") - print(result.data) - - if not result.data: - logger.info("No customers found in database") - return [] - - return result.data - - -async def update_all_customers_to_active() -> Dict[str, int]: - """ - Update all customers to active status in the database. - - Returns: - Dict with count of updated customers - """ - try: - global db_connection - if db_connection is None: - db_connection = DBConnection() - - client = await db_connection.client - - # Update all customers to active - result = await client.schema('basejump').from_('billing_customers').update( - {'active': True} - ).filter('id', 'neq', None).execute() - - updated_count = len(result.data) if hasattr(result, 'data') else 0 - logger.info(f"Updated {updated_count} customers to active status") - print(f"Updated {updated_count} customers to active status") - print("Result:", result) - - return {'updated': updated_count} - except Exception as e: - logger.error(f"Error updating customers in database: {str(e)}") - return {'updated': 0, 'error': str(e)} - - -async def main(): - """Main function to run the script.""" - logger.info("Starting customer status update process") - - try: - # Initialize global DB connection - global db_connection - db_connection = DBConnection() - - # Get all customers from the database - customers = await get_all_customers() - - if not customers: - logger.info("No customers to process") - return - - # Ask for confirmation before proceeding - confirm = input(f"\nSet all {len(customers)} customers to active? (y/n): ") - if confirm.lower() != 'y': - logger.info("Operation cancelled by user") - return - - # Update all customers to active - results = await update_all_customers_to_active() - - # Print summary - print("\nCustomer Status Update Summary:") - print(f"Total customers set to active: {results.get('updated', 0)}") - - logger.info("Customer status update completed") - - except Exception as e: - logger.error(f"Error during customer status update: {str(e)}") - sys.exit(1) - finally: - # Clean up database connection - if db_connection: - await DBConnection.disconnect() - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/app/utils/scripts/update_customer_active_status.py b/app/utils/scripts/update_customer_active_status.py deleted file mode 100644 index ced3ad2e2..000000000 --- a/app/utils/scripts/update_customer_active_status.py +++ /dev/null @@ -1,326 +0,0 @@ -#!/usr/bin/env python -""" -Script to check Stripe subscriptions for all customers and update their active status. - -Usage: - python update_customer_active_status.py - -This script: -1. Queries all customers from basejump.billing_customers -2. Checks subscription status directly on Stripe using customer_id -3. Updates customer active status in database - -Make sure your environment variables are properly set: -- SUPABASE_URL -- SUPABASE_SERVICE_ROLE_KEY -- STRIPE_SECRET_KEY -""" - -import asyncio -import sys -import os -import time -from typing import List, Dict, Any, Tuple -from dotenv import load_dotenv -import stripe - -# Load script-specific environment variables -load_dotenv(".env") - -# Import relative modules -from services.supabase import DBConnection -from utils.logger import logger -from utils.config import config - -# Initialize Stripe with the API key -stripe.api_key = config.STRIPE_SECRET_KEY - -# Batch size settings -BATCH_SIZE = 100 # Process customers in batches -MAX_CONCURRENCY = 20 # Maximum concurrent Stripe API calls - -# Global DB connection to reuse -db_connection = None - -async def get_all_customers() -> List[Dict[str, Any]]: - """ - Query all customers from the database. - - Returns: - List of customers with their ID (customer_id is used for Stripe) - """ - global db_connection - if db_connection is None: - db_connection = DBConnection() - - client = await db_connection.client - - # Print the Supabase URL being used - print(f"Using Supabase URL: {os.getenv('SUPABASE_URL')}") - - # Query all customers from billing_customers - result = await client.schema('basejump').from_('billing_customers').select( - 'id', - 'active' - ).execute() - - # Print the query result - print(f"Found {len(result.data)} customers in database") - - if not result.data: - logger.info("No customers found in database") - return [] - - return result.data - -async def check_stripe_subscription(customer_id: str) -> bool: - """ - Check if a customer has an active subscription directly on Stripe. - - Args: - customer_id: Customer ID (billing_customers.id) which is the Stripe customer ID - - Returns: - True if customer has at least one active subscription, False otherwise - """ - if not customer_id: - print(f"⚠️ Empty customer_id") - return False - - try: - # Print what we're checking for debugging - print(f"Checking Stripe subscriptions for customer: {customer_id}") - - # List all subscriptions for this customer directly on Stripe - subscriptions = stripe.Subscription.list( - customer=customer_id, - status='active', # Only get active subscriptions - limit=1 # We only need to know if there's at least one - ) - - # Print the raw data for debugging - print(f"Stripe returned data: {subscriptions.data}") - - # If there's at least one active subscription, the customer is active - has_active_subscription = len(subscriptions.data) > 0 - - if has_active_subscription: - print(f"✅ Customer {customer_id} has ACTIVE subscription") - else: - print(f"❌ Customer {customer_id} has NO active subscription") - - return has_active_subscription - - except Exception as e: - logger.error(f"Error checking Stripe subscription for customer {customer_id}: {str(e)}") - print(f"⚠️ Error checking subscription for {customer_id}: {str(e)}") - return False - -async def process_customer_batch(batch: List[Dict[str, Any]], batch_number: int, total_batches: int) -> Dict[str, bool]: - """ - Process a batch of customers by checking their Stripe subscriptions concurrently. - - Args: - batch: List of customer records in this batch - batch_number: Current batch number (for logging) - total_batches: Total number of batches (for logging) - - Returns: - Dictionary mapping customer IDs to subscription status (True/False) - """ - start_time = time.time() - batch_size = len(batch) - print(f"Processing batch {batch_number}/{total_batches} ({batch_size} customers)...") - - # Create a semaphore to limit concurrency within the batch to avoid rate limiting - semaphore = asyncio.Semaphore(MAX_CONCURRENCY) - - async def check_single_customer(customer: Dict[str, Any]) -> Tuple[str, bool]: - async with semaphore: # Limit concurrent API calls - customer_id = customer['id'] - - # Check directly on Stripe - customer_id IS the Stripe customer ID - is_active = await check_stripe_subscription(customer_id) - return customer_id, is_active - - # Create tasks for all customers in this batch - tasks = [check_single_customer(customer) for customer in batch] - - # Run all tasks in this batch concurrently - results = await asyncio.gather(*tasks) - - # Convert results to dictionary - subscription_status = {customer_id: status for customer_id, status in results} - - end_time = time.time() - - # Count active/inactive in this batch - active_count = sum(1 for status in subscription_status.values() if status) - inactive_count = batch_size - active_count - - print(f"Batch {batch_number} completed in {end_time - start_time:.2f} seconds") - print(f"Results (batch {batch_number}): {active_count} active, {inactive_count} inactive subscriptions") - - return subscription_status - -async def update_customer_batch(subscription_status: Dict[str, bool]) -> Dict[str, int]: - """ - Update a batch of customers in the database. - - Args: - subscription_status: Dictionary mapping customer IDs to active status - - Returns: - Dictionary with statistics about the update - """ - start_time = time.time() - - global db_connection - if db_connection is None: - db_connection = DBConnection() - - client = await db_connection.client - - # Separate customers into active and inactive groups - active_customers = [cid for cid, status in subscription_status.items() if status] - inactive_customers = [cid for cid, status in subscription_status.items() if not status] - - total_count = len(active_customers) + len(inactive_customers) - - # Update statistics - stats = { - 'total': total_count, - 'active_updated': 0, - 'inactive_updated': 0, - 'errors': 0 - } - - # Update active customers in a single operation - if active_customers: - try: - print(f"Updating {len(active_customers)} customers to ACTIVE status") - await client.schema('basejump').from_('billing_customers').update( - {'active': True} - ).in_('id', active_customers).execute() - - stats['active_updated'] = len(active_customers) - logger.info(f"Updated {len(active_customers)} customers to ACTIVE status") - except Exception as e: - logger.error(f"Error updating active customers: {str(e)}") - stats['errors'] += 1 - - # Update inactive customers in a single operation - if inactive_customers: - try: - print(f"Updating {len(inactive_customers)} customers to INACTIVE status") - await client.schema('basejump').from_('billing_customers').update( - {'active': False} - ).in_('id', inactive_customers).execute() - - stats['inactive_updated'] = len(inactive_customers) - logger.info(f"Updated {len(inactive_customers)} customers to INACTIVE status") - except Exception as e: - logger.error(f"Error updating inactive customers: {str(e)}") - stats['errors'] += 1 - - end_time = time.time() - print(f"Database updates completed in {end_time - start_time:.2f} seconds") - - return stats - -async def main(): - """Main function to run the script.""" - total_start_time = time.time() - logger.info("Starting customer active status update process") - - try: - # Check Stripe API key - print(f"Stripe API key configured: {'Yes' if config.STRIPE_SECRET_KEY else 'No'}") - if not config.STRIPE_SECRET_KEY: - print("ERROR: Stripe API key not configured. Please set STRIPE_SECRET_KEY in your environment.") - return - - # Initialize global DB connection - global db_connection - db_connection = DBConnection() - - # Get all customers from the database - all_customers = await get_all_customers() - - if not all_customers: - logger.info("No customers to process") - return - - # Print a small sample of the customer data - print("\nCustomer data sample (customer_id = Stripe customer ID):") - for i, customer in enumerate(all_customers[:5]): # Show first 5 only - print(f" {i+1}. ID: {customer['id']}, Active: {customer.get('active')}") - if len(all_customers) > 5: - print(f" ... and {len(all_customers) - 5} more") - - # Split customers into batches - batches = [all_customers[i:i + BATCH_SIZE] for i in range(0, len(all_customers), BATCH_SIZE)] - total_batches = len(batches) - - # Ask for confirmation before proceeding - confirm = input(f"\nProcess {len(all_customers)} customers in {total_batches} batches of {BATCH_SIZE}? (y/n): ") - if confirm.lower() != 'y': - logger.info("Operation cancelled by user") - return - - # Overall statistics - all_stats = { - 'total': 0, - 'active_updated': 0, - 'inactive_updated': 0, - 'errors': 0 - } - - # Process each batch - for i, batch in enumerate(batches): - batch_number = i + 1 - - # STEP 1: Process this batch of customers - subscription_status = await process_customer_batch(batch, batch_number, total_batches) - - # STEP 2: Update this batch in the database - batch_stats = await update_customer_batch(subscription_status) - - # Accumulate statistics - all_stats['total'] += batch_stats['total'] - all_stats['active_updated'] += batch_stats['active_updated'] - all_stats['inactive_updated'] += batch_stats['inactive_updated'] - all_stats['errors'] += batch_stats['errors'] - - # Show batch completion - print(f"Completed batch {batch_number}/{total_batches}") - - # Brief pause between batches to avoid Stripe rate limiting - if batch_number < total_batches: - await asyncio.sleep(1) # 1 second pause between batches - - # Print summary - total_end_time = time.time() - total_time = total_end_time - total_start_time - - print("\nCustomer Status Update Summary:") - print(f"Total customers processed: {all_stats['total']}") - print(f"Customers set to active: {all_stats['active_updated']}") - print(f"Customers set to inactive: {all_stats['inactive_updated']}") - if all_stats['errors'] > 0: - print(f"Update errors: {all_stats['errors']}") - print(f"Total processing time: {total_time:.2f} seconds") - - logger.info(f"Customer active status update completed in {total_time:.2f} seconds") - - except Exception as e: - logger.error(f"Error during customer status update: {str(e)}") - sys.exit(1) - finally: - # Clean up database connection - if db_connection: - await DBConnection.disconnect() - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file From a010fe7048f90476f28773d6e7c9c9b440056923 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Sun, 27 Jul 2025 22:26:50 +0800 Subject: [PATCH 23/33] Apply pre-commit formatting fixes --- app/agent/browser.py | 5 +- app/agent/manus.py | 2 - app/agent/sandbox_agent.py | 13 +- app/daytona/README.md | 1 - app/daytona/api.py | 21 +- app/daytona/sandbox.py | 2 +- app/daytona/tool_base.py | 10 +- app/tool/base.py | 12 +- app/tool/computer_use_tool.py | 317 ++++++++++++++++-------- app/tool/sb_browser_tool.py | 1 + app/tool/sb_files_tool.py | 12 +- app/tool/sb_shell_tool.py | 15 +- app/tool/sb_vision_tool.py | 16 +- app/utils/__init__.py | 2 +- app/utils/files_utils.py | 24 +- app/utils/logger.py | 6 +- protocol/a2a/app/README.md | 1 - protocol/a2a/app/README_zh.md | 1 - protocol/a2a/app/agent.py | 6 +- protocol/a2a/app/agent_executor.py | 14 +- protocol/a2a/app/main.py | 25 +- tests/daytona/test_computer_use_tool.py | 9 +- tests/daytona/test_daytona.py | 9 +- tests/daytona/test_sb_browser_use.py | 6 +- tests/daytona/test_sb_files_tool.py | 18 +- tests/daytona/test_sb_shell_tools.py | 10 +- tests/daytona/test_sb_vision_tool.py | 18 +- 27 files changed, 336 insertions(+), 240 deletions(-) diff --git a/app/agent/browser.py b/app/agent/browser.py index 5ddf34540..9d20827bf 100644 --- a/app/agent/browser.py +++ b/app/agent/browser.py @@ -10,6 +10,7 @@ from app.tool import BrowserUseTool, Terminate, ToolCollection from app.tool.sb_browser_tool import SandboxBrowserTool + # Avoid circular import if BrowserAgent needs BrowserContextHelper if TYPE_CHECKING: from app.agent.base import BaseAgent # Or wherever memory is defined @@ -23,7 +24,9 @@ def __init__(self, agent: "BaseAgent"): async def get_browser_state(self) -> Optional[dict]: browser_tool = self.agent.available_tools.get_tool(BrowserUseTool().name) if not browser_tool: - browser_tool = self.agent.available_tools.get_tool(SandboxBrowserTool().name) + browser_tool = self.agent.available_tools.get_tool( + SandboxBrowserTool().name + ) if not browser_tool or not hasattr(browser_tool, "get_current_state"): logger.warning("BrowserUseTool not found or doesn't have get_current_state") return None diff --git a/app/agent/manus.py b/app/agent/manus.py index 960ab08a0..df40edbba 100644 --- a/app/agent/manus.py +++ b/app/agent/manus.py @@ -14,8 +14,6 @@ from app.tool.python_execute import PythonExecute from app.tool.str_replace_editor import StrReplaceEditor -from app.tool.computer_use_tool import ComputerUseTool -from app.daytona.sandbox import create_sandbox class Manus(ToolCallAgent): """A versatile general-purpose agent with support for both local and MCP tools.""" diff --git a/app/agent/sandbox_agent.py b/app/agent/sandbox_agent.py index e1716b2cd..a510f68b6 100644 --- a/app/agent/sandbox_agent.py +++ b/app/agent/sandbox_agent.py @@ -1,36 +1,29 @@ -import json -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import Dict, List, Optional -import daytona from pydantic import Field, model_validator from app.agent.browser import BrowserContextHelper from app.agent.toolcall import ToolCallAgent from app.config import config -from app.daytona.sandbox import create_sandbox, delete_sandbox, get_or_start_sandbox +from app.daytona.sandbox import create_sandbox, delete_sandbox from app.daytona.tool_base import SandboxToolsBase from app.logger import logger from app.prompt.manus import NEXT_STEP_PROMPT, SYSTEM_PROMPT -from app.schema import Message from app.tool import Terminate, ToolCollection from app.tool.ask_human import AskHuman from app.tool.browser_use_tool import BrowserUseTool from app.tool.mcp import MCPClients, MCPClientTool -from app.tool.python_execute import PythonExecute from app.tool.sb_browser_tool import SandboxBrowserTool from app.tool.sb_files_tool import SandboxFilesTool from app.tool.sb_shell_tool import SandboxShellTool from app.tool.sb_vision_tool import SandboxVisionTool -from app.tool.str_replace_editor import StrReplaceEditor class SandboxManus(ToolCallAgent): """A versatile general-purpose agent with support for both local and MCP tools.""" name: str = "SandboxManus" - description: str = ( - "A versatile agent that can solve various tasks using multiple sandbox-tools including MCP-based tools" - ) + description: str = "A versatile agent that can solve various tasks using multiple sandbox-tools including MCP-based tools" system_prompt: str = SYSTEM_PROMPT.format(directory=config.workspace_root) next_step_prompt: str = NEXT_STEP_PROMPT diff --git a/app/daytona/README.md b/app/daytona/README.md index 9a707b9d7..6f8020618 100644 --- a/app/daytona/README.md +++ b/app/daytona/README.md @@ -55,4 +55,3 @@ ## Learn More - [Daytona Documentation](https://www.daytona.io/docs/) - diff --git a/app/daytona/api.py b/app/daytona/api.py index 8f90bb53d..e1fdc16a9 100644 --- a/app/daytona/api.py +++ b/app/daytona/api.py @@ -2,23 +2,14 @@ import urllib.parse from typing import Optional -from fastapi import ( - FastAPI, - UploadFile, - File, - HTTPException, - APIRouter, - Form, - Depends, - Request, -) +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile from fastapi.responses import Response from pydantic import BaseModel - -from sandbox.sandbox import get_or_start_sandbox, delete_sandbox -from utils.logger import logger -from utils.auth_utils import get_optional_user_id +from sandbox.sandbox import delete_sandbox, get_or_start_sandbox from services.supabase import DBConnection +from utils.auth_utils import get_optional_user_id +from utils.logger import logger + # Initialize shared resources router = APIRouter(tags=["sandbox"]) @@ -459,7 +450,7 @@ async def ensure_project_sandbox_active( # Get or start the sandbox logger.info(f"Ensuring sandbox is active for project {project_id}") - sandbox = await get_or_start_sandbox(sandbox_id) + await get_or_start_sandbox(sandbox_id) logger.info( f"Successfully ensured sandbox {sandbox_id} is active for project {project_id}" diff --git a/app/daytona/sandbox.py b/app/daytona/sandbox.py index a801a7fb6..6901a75c2 100644 --- a/app/daytona/sandbox.py +++ b/app/daytona/sandbox.py @@ -7,11 +7,11 @@ SandboxState, SessionExecuteRequest, ) -from dotenv import load_dotenv from app.config import config from app.utils.logger import logger + # load_dotenv() daytona_settings = config.daytona logger.info("Initializing Daytona sandbox configuration") diff --git a/app/daytona/tool_base.py b/app/daytona/tool_base.py index 58fa62950..043578a9a 100644 --- a/app/daytona/tool_base.py +++ b/app/daytona/tool_base.py @@ -1,4 +1,3 @@ -import asyncio from dataclasses import dataclass, field from datetime import datetime from typing import Any, ClassVar, Dict, Optional @@ -7,15 +6,12 @@ from pydantic import Field from app.config import config -from app.daytona.sandbox import ( - create_sandbox, - get_or_start_sandbox, - start_supervisord_session, -) -from app.tool.base import BaseTool, ToolResult +from app.daytona.sandbox import create_sandbox, start_supervisord_session +from app.tool.base import BaseTool from app.utils.files_utils import clean_path from app.utils.logger import logger + # load_dotenv() daytona_settings = config.daytona daytona_config = DaytonaConfig( diff --git a/app/tool/base.py b/app/tool/base.py index bcd9f0012..fdb8b7d3a 100644 --- a/app/tool/base.py +++ b/app/tool/base.py @@ -1,10 +1,12 @@ +import json from abc import ABC, abstractmethod -from pydantic import BaseModel, Field from typing import Any, Dict, Optional, Union -import inspect -import json + +from pydantic import BaseModel, Field + from app.utils.logger import logger + # class BaseTool(ABC, BaseModel): # name: str # description: str @@ -33,7 +35,6 @@ # } - class ToolResult(BaseModel): """Represents the result of a tool execution.""" @@ -73,6 +74,7 @@ def replace(self, **kwargs): # return self.copy(update=kwargs) return type(self)(**{**self.dict(), **kwargs}) + class BaseTool(ABC, BaseModel): """Consolidated base class for all tools combining BaseModel and Tool functionality. @@ -169,6 +171,8 @@ def fail_response(self, msg: str) -> ToolResult: """ logger.debug(f"Tool {self.__class__.__name__} returned failed result: {msg}") return ToolResult(error=msg) + + class CLIResult(ToolResult): """A ToolResult that can be rendered as a CLI output.""" diff --git a/app/tool/computer_use_tool.py b/app/tool/computer_use_tool.py index fd979e229..0ea57a734 100644 --- a/app/tool/computer_use_tool.py +++ b/app/tool/computer_use_tool.py @@ -1,26 +1,89 @@ -import os -import time -import base64 -import aiohttp import asyncio +import base64 import logging -from typing import Optional, Dict,Literal import os +import time +from typing import Dict, Literal, Optional + +import aiohttp from pydantic import Field -from app.daytona.tool_base import SandboxToolsBase, Sandbox + +from app.daytona.tool_base import Sandbox, SandboxToolsBase from app.tool.base import ToolResult KEYBOARD_KEYS = [ - 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', - 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', - 'enter', 'esc', 'backspace', 'tab', 'space', 'delete', - 'ctrl', 'alt', 'shift', 'win', - 'up', 'down', 'left', 'right', - 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', - 'ctrl+c', 'ctrl+v', 'ctrl+x', 'ctrl+z', 'ctrl+a', 'ctrl+s', - 'alt+tab', 'alt+f4', 'ctrl+alt+delete' + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "enter", + "esc", + "backspace", + "tab", + "space", + "delete", + "ctrl", + "alt", + "shift", + "win", + "up", + "down", + "left", + "right", + "f1", + "f2", + "f3", + "f4", + "f5", + "f6", + "f7", + "f8", + "f9", + "f10", + "f11", + "f12", + "ctrl+c", + "ctrl+v", + "ctrl+x", + "ctrl+z", + "ctrl+a", + "ctrl+s", + "alt+tab", + "alt+f4", + "ctrl+alt+delete", ] MOUSE_BUTTONS = ["left", "right", "middle"] _COMPUTER_USE_DESCRIPTION = """\ @@ -34,8 +97,11 @@ * Screenshots: Capture and save screen images * Waiting: Pause execution for specified duration """ + + class ComputerUseTool(SandboxToolsBase): """Computer automation tool for controlling the desktop environment.""" + name: str = "computer_use" description: str = _COMPUTER_USE_DESCRIPTION parameters: dict = { @@ -54,55 +120,46 @@ class ComputerUseTool(SandboxToolsBase): "mouse_up", "drag_to", "hotkey", - "screenshot" + "screenshot", ], "description": "The computer action to perform", }, - "x": { - "type": "number", - "description": "X coordinate for mouse actions" - }, - "y": { - "type": "number", - "description": "Y coordinate for mouse actions" - }, + "x": {"type": "number", "description": "X coordinate for mouse actions"}, + "y": {"type": "number", "description": "Y coordinate for mouse actions"}, "button": { "type": "string", "enum": MOUSE_BUTTONS, "description": "Mouse button for click/drag actions", - "default": "left" + "default": "left", }, "num_clicks": { "type": "integer", "description": "Number of clicks", "enum": [1, 2, 3], - "default": 1 + "default": 1, }, "amount": { "type": "integer", "description": "Scroll amount (positive for up, negative for down)", "minimum": -10, - "maximum": 10 - }, - "text": { - "type": "string", - "description": "Text to type" + "maximum": 10, }, + "text": {"type": "string", "description": "Text to type"}, "key": { "type": "string", "enum": KEYBOARD_KEYS, - "description": "Key to press" + "description": "Key to press", }, "keys": { "type": "string", "enum": KEYBOARD_KEYS, - "description": "Key combination to press" + "description": "Key combination to press", }, "duration": { "type": "number", "description": "Duration in seconds to wait", - "default": 0.5 - } + "default": 0.5, + }, }, "required": ["action"], "dependencies": { @@ -116,20 +173,23 @@ class ComputerUseTool(SandboxToolsBase): "mouse_up": [], "drag_to": ["x", "y"], "hotkey": ["keys"], - "screenshot": [] - } + "screenshot": [], + }, } session: Optional[aiohttp.ClientSession] = Field(default=None, exclude=True) mouse_x: int = Field(default=0, exclude=True) mouse_y: int = Field(default=0, exclude=True) api_base_url: Optional[str] = Field(default=None, exclude=True) + def __init__(self, sandbox: Optional[Sandbox] = None, **data): """Initialize with optional sandbox.""" super().__init__(**data) if sandbox is not None: self._sandbox = sandbox # 直接操作基类的私有属性 self.api_base_url = sandbox.get_preview_link(8000).url - logging.info(f"Initialized ComputerUseTool with API URL: {self.api_base_url}") + logging.info( + f"Initialized ComputerUseTool with API URL: {self.api_base_url}" + ) @classmethod def create_with_sandbox(cls, sandbox: Sandbox) -> "ComputerUseTool": @@ -141,7 +201,10 @@ async def _get_session(self) -> aiohttp.ClientSession: if self.session is None or self.session.closed: self.session = aiohttp.ClientSession() return self.session - async def _api_request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Dict: + + async def _api_request( + self, method: str, endpoint: str, data: Optional[Dict] = None + ) -> Dict: """Send request to automation service API.""" try: session = await self._get_session() @@ -158,11 +221,21 @@ async def _api_request(self, method: str, endpoint: str, data: Optional[Dict] = except Exception as e: logging.error(f"API request failed: {str(e)}") return {"success": False, "error": str(e)} + async def execute( self, action: Literal[ - "move_to", "click", "scroll", "typing", "press", - "wait", "mouse_down", "mouse_up", "drag_to", "hotkey", "screenshot" + "move_to", + "click", + "scroll", + "typing", + "press", + "wait", + "mouse_down", + "mouse_up", + "drag_to", + "hotkey", + "screenshot", ], x: Optional[float] = None, y: Optional[float] = None, @@ -173,7 +246,7 @@ async def execute( key: Optional[str] = None, keys: Optional[str] = None, duration: float = 0.5, - **kwargs + **kwargs, ) -> ToolResult: """ Execute a specified computer automation action. @@ -198,74 +271,91 @@ async def execute( return ToolResult(error="x and y coordinates are required") x_int = int(round(float(x))) y_int = int(round(float(y))) - result = await self._api_request("POST", "/automation/mouse/move", { - "x": x_int, - "y": y_int - }) + result = await self._api_request( + "POST", "/automation/mouse/move", {"x": x_int, "y": y_int} + ) if result.get("success", False): self.mouse_x = x_int self.mouse_y = y_int return ToolResult(output=f"Moved to ({x_int}, {y_int})") else: - return ToolResult(error=f"Failed to move: {result.get('error', 'Unknown error')}") + return ToolResult( + error=f"Failed to move: {result.get('error', 'Unknown error')}" + ) elif action == "click": x_val = x if x is not None else self.mouse_x y_val = y if y is not None else self.mouse_y x_int = int(round(float(x_val))) y_int = int(round(float(y_val))) num_clicks = int(num_clicks) - result = await self._api_request("POST", "/automation/mouse/click", { - "x": x_int, - "y": y_int, - "clicks": num_clicks, - "button": button.lower() - }) + result = await self._api_request( + "POST", + "/automation/mouse/click", + { + "x": x_int, + "y": y_int, + "clicks": num_clicks, + "button": button.lower(), + }, + ) if result.get("success", False): self.mouse_x = x_int self.mouse_y = y_int - return ToolResult(output=f"{num_clicks} {button} click(s) performed at ({x_int}, {y_int})") + return ToolResult( + output=f"{num_clicks} {button} click(s) performed at ({x_int}, {y_int})" + ) else: - return ToolResult(error=f"Failed to click: {result.get('error', 'Unknown error')}") + return ToolResult( + error=f"Failed to click: {result.get('error', 'Unknown error')}" + ) elif action == "scroll": if amount is None: return ToolResult(error="Scroll amount is required") amount = int(float(amount)) amount = max(-10, min(10, amount)) - result = await self._api_request("POST", "/automation/mouse/scroll", { - "clicks": amount, - "x": self.mouse_x, - "y": self.mouse_y - }) + result = await self._api_request( + "POST", + "/automation/mouse/scroll", + {"clicks": amount, "x": self.mouse_x, "y": self.mouse_y}, + ) if result.get("success", False): direction = "up" if amount > 0 else "down" steps = abs(amount) - return ToolResult(output=f"Scrolled {direction} {steps} step(s) at position ({self.mouse_x}, {self.mouse_y})") + return ToolResult( + output=f"Scrolled {direction} {steps} step(s) at position ({self.mouse_x}, {self.mouse_y})" + ) else: - return ToolResult(error=f"Failed to scroll: {result.get('error', 'Unknown error')}") + return ToolResult( + error=f"Failed to scroll: {result.get('error', 'Unknown error')}" + ) elif action == "typing": if text is None: return ToolResult(error="Text is required for typing") text = str(text) - result = await self._api_request("POST", "/automation/keyboard/write", { - "message": text, - "interval": 0.01 - }) + result = await self._api_request( + "POST", + "/automation/keyboard/write", + {"message": text, "interval": 0.01}, + ) if result.get("success", False): return ToolResult(output=f"Typed: {text}") else: - return ToolResult(error=f"Failed to type: {result.get('error', 'Unknown error')}") + return ToolResult( + error=f"Failed to type: {result.get('error', 'Unknown error')}" + ) elif action == "press": if key is None: return ToolResult(error="Key is required for press action") key = str(key).lower() - result = await self._api_request("POST", "/automation/keyboard/press", { - "keys": key, - "presses": 1 - }) + result = await self._api_request( + "POST", "/automation/keyboard/press", {"keys": key, "presses": 1} + ) if result.get("success", False): return ToolResult(output=f"Pressed key: {key}") else: - return ToolResult(error=f"Failed to press key: {result.get('error', 'Unknown error')}") + return ToolResult( + error=f"Failed to press key: {result.get('error', 'Unknown error')}" + ) elif action == "wait": duration = float(duration) duration = max(0, min(10, duration)) @@ -276,33 +366,41 @@ async def execute( y_val = y if y is not None else self.mouse_y x_int = int(round(float(x_val))) y_int = int(round(float(y_val))) - result = await self._api_request("POST", "/automation/mouse/down", { - "x": x_int, - "y": y_int, - "button": button.lower() - }) + result = await self._api_request( + "POST", + "/automation/mouse/down", + {"x": x_int, "y": y_int, "button": button.lower()}, + ) if result.get("success", False): self.mouse_x = x_int self.mouse_y = y_int - return ToolResult(output=f"{button} button pressed at ({x_int}, {y_int})") + return ToolResult( + output=f"{button} button pressed at ({x_int}, {y_int})" + ) else: - return ToolResult(error=f"Failed to press button: {result.get('error', 'Unknown error')}") + return ToolResult( + error=f"Failed to press button: {result.get('error', 'Unknown error')}" + ) elif action == "mouse_up": x_val = x if x is not None else self.mouse_x y_val = y if y is not None else self.mouse_y x_int = int(round(float(x_val))) y_int = int(round(float(y_val))) - result = await self._api_request("POST", "/automation/mouse/up", { - "x": x_int, - "y": y_int, - "button": button.lower() - }) + result = await self._api_request( + "POST", + "/automation/mouse/up", + {"x": x_int, "y": y_int, "button": button.lower()}, + ) if result.get("success", False): self.mouse_x = x_int self.mouse_y = y_int - return ToolResult(output=f"{button} button released at ({x_int}, {y_int})") + return ToolResult( + output=f"{button} button released at ({x_int}, {y_int})" + ) else: - return ToolResult(error=f"Failed to release button: {result.get('error', 'Unknown error')}") + return ToolResult( + error=f"Failed to release button: {result.get('error', 'Unknown error')}" + ) elif action == "drag_to": if x is None or y is None: return ToolResult(error="x and y coordinates are required") @@ -310,31 +408,37 @@ async def execute( target_y = int(round(float(y))) start_x = self.mouse_x start_y = self.mouse_y - result = await self._api_request("POST", "/automation/mouse/drag", { - "x": target_x, - "y": target_y, - "duration": 0.3, - "button": "left" - }) + result = await self._api_request( + "POST", + "/automation/mouse/drag", + {"x": target_x, "y": target_y, "duration": 0.3, "button": "left"}, + ) if result.get("success", False): self.mouse_x = target_x self.mouse_y = target_y - return ToolResult(output=f"Dragged from ({start_x}, {start_y}) to ({target_x}, {target_y})") + return ToolResult( + output=f"Dragged from ({start_x}, {start_y}) to ({target_x}, {target_y})" + ) else: - return ToolResult(error=f"Failed to drag: {result.get('error', 'Unknown error')}") + return ToolResult( + error=f"Failed to drag: {result.get('error', 'Unknown error')}" + ) elif action == "hotkey": if keys is None: return ToolResult(error="Keys are required for hotkey action") keys = str(keys).lower().strip() - key_sequence = keys.split('+') - result = await self._api_request("POST", "/automation/keyboard/hotkey", { - "keys": key_sequence, - "interval": 0.01 - }) + key_sequence = keys.split("+") + result = await self._api_request( + "POST", + "/automation/keyboard/hotkey", + {"keys": key_sequence, "interval": 0.01}, + ) if result.get("success", False): return ToolResult(output=f"Pressed key combination: {keys}") else: - return ToolResult(error=f"Failed to press keys: {result.get('error', 'Unknown error')}") + return ToolResult( + error=f"Failed to press keys: {result.get('error', 'Unknown error')}" + ) elif action == "screenshot": result = await self._api_request("POST", "/automation/screenshot") if "image" in result: @@ -344,18 +448,20 @@ async def execute( screenshots_dir = "screenshots" if not os.path.exists(screenshots_dir): os.makedirs(screenshots_dir) - timestamped_filename = os.path.join(screenshots_dir, f"screenshot_{timestamp}.png") + timestamped_filename = os.path.join( + screenshots_dir, f"screenshot_{timestamp}.png" + ) latest_filename = "latest_screenshot.png" # Decode base64 string and save to file img_data = base64.b64decode(base64_str) - with open(timestamped_filename, 'wb') as f: + with open(timestamped_filename, "wb") as f: f.write(img_data) # Save a copy as the latest screenshot - with open(latest_filename, 'wb') as f: + with open(latest_filename, "wb") as f: f.write(img_data) return ToolResult( output=f"Screenshot saved as {timestamped_filename}", - base64_image=base64_str + base64_image=base64_str, ) else: return ToolResult(error="Failed to capture screenshot") @@ -363,6 +469,7 @@ async def execute( return ToolResult(error=f"Unknown action: {action}") except Exception as e: return ToolResult(error=f"Computer action failed: {str(e)}") + async def cleanup(self): """Clean up resources.""" if self.session and not self.session.closed: @@ -371,7 +478,7 @@ async def cleanup(self): def __del__(self): """Ensure cleanup on destruction.""" - if hasattr(self, 'session') and self.session is not None: + if hasattr(self, "session") and self.session is not None: try: asyncio.run(self.cleanup()) except RuntimeError: diff --git a/app/tool/sb_browser_tool.py b/app/tool/sb_browser_tool.py index 01cffa308..3725cbf64 100644 --- a/app/tool/sb_browser_tool.py +++ b/app/tool/sb_browser_tool.py @@ -15,6 +15,7 @@ from app.tool.base import ToolResult from app.utils.logger import logger + # Context = TypeVar("Context") _BROWSER_DESCRIPTION = """\ A sandbox-based browser automation tool that allows interaction with web pages through various actions. diff --git a/app/tool/sb_files_tool.py b/app/tool/sb_files_tool.py index 405b5cd2e..be558b0cb 100644 --- a/app/tool/sb_files_tool.py +++ b/app/tool/sb_files_tool.py @@ -1,10 +1,13 @@ -from pydantic import Field +import asyncio from typing import Optional, TypeVar + +from pydantic import Field + +from app.daytona.tool_base import Sandbox, SandboxToolsBase from app.tool.base import ToolResult -from app.daytona.tool_base import SandboxToolsBase, Sandbox -from app.utils.files_utils import should_exclude_file, clean_path +from app.utils.files_utils import clean_path, should_exclude_file from app.utils.logger import logger -import asyncio + Context = TypeVar("Context") @@ -349,7 +352,6 @@ async def _delete_file(self, file_path: str) -> ToolResult: async def cleanup(self): """Clean up sandbox resources.""" - pass @classmethod def create_with_context(cls, context: Context) -> "SandboxFilesTool[Context]": diff --git a/app/tool/sb_shell_tool.py b/app/tool/sb_shell_tool.py index cfd3a85f2..8a45244d0 100644 --- a/app/tool/sb_shell_tool.py +++ b/app/tool/sb_shell_tool.py @@ -1,17 +1,19 @@ import asyncio -from typing import Optional, Dict, Any, TypeVar import time +from typing import Any, Dict, Optional, TypeVar from uuid import uuid4 + +from app.daytona.tool_base import Sandbox, SandboxToolsBase from app.tool.base import ToolResult -from app.daytona.tool_base import SandboxToolsBase, Sandbox from app.utils.logger import logger + Context = TypeVar("Context") _SHELL_DESCRIPTION = """\ -Execute a shell command in the workspace directory. -IMPORTANT: Commands are non-blocking by default and run in a tmux session. -This is ideal for long-running operations like starting servers or build processes. -Uses sessions to maintain state between commands. +Execute a shell command in the workspace directory. +IMPORTANT: Commands are non-blocking by default and run in a tmux session. +This is ideal for long-running operations like starting servers or build processes. +Uses sessions to maintain state between commands. This tool is essential for running CLI tools, installing packages, and managing system operations. """ @@ -415,4 +417,3 @@ async def cleanup(self): await self._execute_raw_command("tmux kill-server 2>/dev/null || true") except Exception as e: logger.error(f"Error shell box cleanup action: {e}") - pass diff --git a/app/tool/sb_vision_tool.py b/app/tool/sb_vision_tool.py index e55062623..7559450b9 100644 --- a/app/tool/sb_vision_tool.py +++ b/app/tool/sb_vision_tool.py @@ -1,13 +1,15 @@ -from pydantic import Field -import os import base64 import mimetypes -from typing import Optional +import os from io import BytesIO +from typing import Optional + from PIL import Image +from pydantic import Field +from app.daytona.tool_base import Sandbox, SandboxToolsBase, ThreadMessage from app.tool.base import ToolResult -from app.daytona.tool_base import SandboxToolsBase, Sandbox, ThreadMessage + # 最大文件大小(原图10MB,压缩后5MB) MAX_IMAGE_SIZE = 10 * 1024 * 1024 @@ -99,7 +101,7 @@ def compress_image(self, image_bytes: bytes, mime_type: str, file_path: str): output_mime = "image/jpeg" compressed_bytes = output.getvalue() return compressed_bytes, output_mime - except Exception as e: + except Exception: return image_bytes, mime_type async def execute( @@ -122,9 +124,7 @@ async def execute( try: file_info = self.sandbox.fs.get_file_info(full_path) if file_info.is_dir: - return self.fail_response( - f"路径 '{cleaned_path}' 是目录,不是图片文件。" - ) + return self.fail_response(f"路径 '{cleaned_path}' 是目录,不是图片文件。") except Exception: return self.fail_response(f"图片文件未找到: '{cleaned_path}'") if file_info.size > MAX_IMAGE_SIZE: diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 8ab9008ad..4d3ecf1be 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -1 +1 @@ -# Utility functions and constants for agent tools \ No newline at end of file +# Utility functions and constants for agent tools diff --git a/app/utils/files_utils.py b/app/utils/files_utils.py index fb3f5676c..d14ad552b 100644 --- a/app/utils/files_utils.py +++ b/app/utils/files_utils.py @@ -1,6 +1,6 @@ - import os + # Files to exclude from operations EXCLUDED_FILES = { ".DS_Store", @@ -15,13 +15,7 @@ } # Directories to exclude from operations -EXCLUDED_DIRS = { - "node_modules", - ".next", - "dist", - "build", - ".git" -} +EXCLUDED_DIRS = {"node_modules", ".next", "dist", "build", ".git"} # File extensions to exclude from operations EXCLUDED_EXT = { @@ -35,9 +29,10 @@ ".tiff", ".webp", ".db", - ".sql" + ".sql", } + def should_exclude_file(rel_path: str) -> bool: """Check if a file should be excluded based on path, name, or extension @@ -64,6 +59,7 @@ def should_exclude_file(rel_path: str) -> bool: return False + def clean_path(path: str, workspace_path: str = "/workspace") -> str: """Clean and normalize a path to be relative to the workspace @@ -75,17 +71,17 @@ def clean_path(path: str, workspace_path: str = "/workspace") -> str: The cleaned path, relative to the workspace """ # Remove any leading slash - path = path.lstrip('/') + path = path.lstrip("/") # Remove workspace prefix if present - if path.startswith(workspace_path.lstrip('/')): - path = path[len(workspace_path.lstrip('/')):] + if path.startswith(workspace_path.lstrip("/")): + path = path[len(workspace_path.lstrip("/")) :] # Remove workspace/ prefix if present - if path.startswith('workspace/'): + if path.startswith("workspace/"): path = path[9:] # Remove any remaining leading slash - path = path.lstrip('/') + path = path.lstrip("/") return path diff --git a/app/utils/logger.py b/app/utils/logger.py index 6b36fa41f..3c4f6458b 100644 --- a/app/utils/logger.py +++ b/app/utils/logger.py @@ -1,4 +1,8 @@ -import structlog, logging, os +import logging +import os + +import structlog + ENV_MODE = os.getenv("ENV_MODE", "LOCAL") diff --git a/protocol/a2a/app/README.md b/protocol/a2a/app/README.md index c5ee04a04..6b413dfc4 100644 --- a/protocol/a2a/app/README.md +++ b/protocol/a2a/app/README.md @@ -192,4 +192,3 @@ Response: ## Learn More - [A2A Protocol Documentation](https://google.github.io/A2A/#/documentation) - diff --git a/protocol/a2a/app/README_zh.md b/protocol/a2a/app/README_zh.md index afa6fac6f..f97645aa2 100644 --- a/protocol/a2a/app/README_zh.md +++ b/protocol/a2a/app/README_zh.md @@ -192,4 +192,3 @@ Response: ## Learn More - [A2A Protocol Documentation](https://google.github.io/A2A/#/documentation) - diff --git a/protocol/a2a/app/agent.py b/protocol/a2a/app/agent.py index 9559ceba9..64ba414f2 100644 --- a/protocol/a2a/app/agent.py +++ b/protocol/a2a/app/agent.py @@ -1,6 +1,7 @@ -import httpx -from typing import Any, Dict, AsyncIterable, Literal, List, ClassVar +from typing import Any, AsyncIterable, ClassVar, Dict, List, Literal + from pydantic import BaseModel + from app.agent.manus import Manus @@ -12,7 +13,6 @@ class ResponseFormat(BaseModel): class A2AManus(Manus): - async def invoke(self, query, sessionId) -> str: config = {"configurable": {"thread_id": sessionId}} response = await self.run(query) diff --git a/protocol/a2a/app/agent_executor.py b/protocol/a2a/app/agent_executor.py index bce0b1dc0..3b4426181 100644 --- a/protocol/a2a/app/agent_executor.py +++ b/protocol/a2a/app/agent_executor.py @@ -1,8 +1,8 @@ import logging +from typing import Awaitable, Callable from a2a.server.agent_execution import AgentExecutor, RequestContext -from a2a.server.events import Event, EventQueue -from a2a.server.tasks import TaskUpdater +from a2a.server.events import EventQueue from a2a.types import ( InvalidParamsError, Part, @@ -10,13 +10,11 @@ TextPart, UnsupportedOperationError, ) -from a2a.utils import ( - completed_task, - new_artifact, -) -from .agent import A2AManus +from a2a.utils import completed_task, new_artifact from a2a.utils.errors import ServerError -from typing import Callable, Awaitable + +from .agent import A2AManus + logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) diff --git a/protocol/a2a/app/main.py b/protocol/a2a/app/main.py index dd09f23d3..e69a12bb4 100644 --- a/protocol/a2a/app/main.py +++ b/protocol/a2a/app/main.py @@ -1,25 +1,22 @@ -import httpx import argparse +import asyncio +import logging +from typing import Optional +import httpx from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler -from a2a.server.tasks import InMemoryTaskStore, InMemoryPushNotifier -from a2a.types import ( - AgentCapabilities, - AgentCard, - AgentSkill, -) - -from .agent_executor import ManusExecutor +from a2a.server.tasks import InMemoryPushNotifier, InMemoryTaskStore +from a2a.types import AgentCapabilities, AgentCard, AgentSkill +from dotenv import load_dotenv -from .agent import A2AManus from app.tool.browser_use_tool import _BROWSER_DESCRIPTION from app.tool.str_replace_editor import _STR_REPLACE_EDITOR_DESCRIPTION from app.tool.terminate import _TERMINATE_DESCRIPTION -import logging -from dotenv import load_dotenv -import asyncio -from typing import Optional + +from .agent import A2AManus +from .agent_executor import ManusExecutor + load_dotenv() diff --git a/tests/daytona/test_computer_use_tool.py b/tests/daytona/test_computer_use_tool.py index 545dcb1f8..0b359e50a 100644 --- a/tests/daytona/test_computer_use_tool.py +++ b/tests/daytona/test_computer_use_tool.py @@ -1,11 +1,13 @@ -from app.tool.computer_use_tool import ComputerUseTool -from app.daytona.sandbox import create_sandbox,start_supervisord_session import asyncio +from app.daytona.sandbox import create_sandbox +from app.tool.computer_use_tool import ComputerUseTool + + async def main(): # 创建沙箱和工具 sandbox = create_sandbox(password="123456") - base_url=sandbox.get_preview_link(8000) + base_url = sandbox.get_preview_link(8000) print(f"Sandbox base URL: {base_url}") print(f"Sandbox ID: {sandbox.id}") @@ -18,5 +20,6 @@ async def main(): # 清理资源(可选) await computer_tool.cleanup() + if __name__ == "__main__": asyncio.run(main()) # 运行异步主函数 diff --git a/tests/daytona/test_daytona.py b/tests/daytona/test_daytona.py index ddacad45b..692f7409d 100644 --- a/tests/daytona/test_daytona.py +++ b/tests/daytona/test_daytona.py @@ -1,10 +1,5 @@ -from daytona import ( - CreateSandboxFromImageParams, - Daytona, - DaytonaConfig, - Image, - Resources, -) +from daytona import CreateSandboxFromImageParams, Daytona, DaytonaConfig, Resources + # Using environment variables (DAYTONA_API_KEY, DAYTONA_API_URL, DAYTONA_TARGET) # daytona = Daytona() diff --git a/tests/daytona/test_sb_browser_use.py b/tests/daytona/test_sb_browser_use.py index 3b210f71d..af289802a 100644 --- a/tests/daytona/test_sb_browser_use.py +++ b/tests/daytona/test_sb_browser_use.py @@ -1,11 +1,7 @@ import asyncio -import json -from daytona import Daytona, DaytonaConfig - -from app.daytona.sandbox import create_sandbox, start_supervisord_session +from app.daytona.sandbox import create_sandbox from app.tool.sb_browser_tool import SandboxBrowserTool -from app.utils.logger import logger async def main(): diff --git a/tests/daytona/test_sb_files_tool.py b/tests/daytona/test_sb_files_tool.py index ccf2ed247..d5076589c 100644 --- a/tests/daytona/test_sb_files_tool.py +++ b/tests/daytona/test_sb_files_tool.py @@ -1,7 +1,9 @@ -from app.tool.sb_files_tool import SandboxFilesTool -from app.daytona.sandbox import create_sandbox import asyncio +from app.daytona.sandbox import create_sandbox +from app.tool.sb_files_tool import SandboxFilesTool + + async def main(): # 创建沙箱和工具 sandbox = create_sandbox(password="123456") @@ -17,15 +19,18 @@ async def main(): sb_files_tool = SandboxFilesTool(sandbox) - # 执行创建文件操作 - result = await sb_files_tool.execute(action="create_file", file_path="src/a.txt", file_contents="aaaaa1111") + result = await sb_files_tool.execute( + action="create_file", file_path="src/a.txt", file_contents="aaaaa1111" + ) print(result) # 执行字符串替换操作 # result = await sb_files_tool.execute(action="str_replace", file_path="src/a.txt", old_str="aaaaa", new_str="bbbbb") # print(result) # 执行全文件重写操作 - result = await sb_files_tool.execute(action="full_file_rewrite", file_path="src/a.txt", file_contents="1234567") + result = await sb_files_tool.execute( + action="full_file_rewrite", file_path="src/a.txt", file_contents="1234567" + ) print(result) # 执行删除文件操作 # result = await sb_files_tool.execute(action="delete_file", file_path="src/a.txt") @@ -33,5 +38,6 @@ async def main(): # 清理资源(可选) # await sb_shell_tool.cleanup() + if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/tests/daytona/test_sb_shell_tools.py b/tests/daytona/test_sb_shell_tools.py index 25762a1d6..8c69f51c4 100644 --- a/tests/daytona/test_sb_shell_tools.py +++ b/tests/daytona/test_sb_shell_tools.py @@ -1,7 +1,9 @@ -from app.tool.sb_shell_tool import SandboxShellTool -from app.daytona.sandbox import create_sandbox import asyncio +from app.daytona.sandbox import create_sandbox +from app.tool.sb_shell_tool import SandboxShellTool + + async def main(): # 创建沙箱和工具 sandbox = create_sandbox(password="123456") @@ -16,7 +18,6 @@ async def main(): print(f"Website Link: {website_link}") sb_shell_tool = SandboxShellTool(sandbox) - # 执行截图操作 result = await sb_shell_tool.execute(action="execute_command", command="pwd") print(result) @@ -24,6 +25,7 @@ async def main(): # 清理资源(可选) # await sb_shell_tool.cleanup() + if __name__ == "__main__": print("123") - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/tests/daytona/test_sb_vision_tool.py b/tests/daytona/test_sb_vision_tool.py index 99151256d..c06a51cb3 100644 --- a/tests/daytona/test_sb_vision_tool.py +++ b/tests/daytona/test_sb_vision_tool.py @@ -1,8 +1,8 @@ -from app.tool.sb_shell_tool import SandboxShellTool -from app.tool.sb_vision_tool import SandboxVisionTool -from app.daytona.sandbox import create_sandbox import asyncio +from app.daytona.sandbox import create_sandbox +from app.tool.sb_shell_tool import SandboxShellTool +from app.tool.sb_vision_tool import SandboxVisionTool async def main(): @@ -21,14 +21,20 @@ async def main(): sb_vision_tool = SandboxVisionTool(sandbox) # 执行终端命令 - result = await sb_shell_tool.execute(action="execute_command", command="curl -O http://img.netbian.com/file/2025/0716/091412RIFD9.jpg") + result = await sb_shell_tool.execute( + action="execute_command", + command="curl -O http://img.netbian.com/file/2025/0716/091412RIFD9.jpg", + ) print(result) # 执行see_image操作 - result = await sb_vision_tool.execute(action="see_image", file_path="091412RIFD9.jpg") + result = await sb_vision_tool.execute( + action="see_image", file_path="091412RIFD9.jpg" + ) print(result) # 清理资源(可选) # await sb_shell_tool.cleanup() + if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) From 802e212ef28938c6424cf348b2f99fba3386bc85 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Sun, 27 Jul 2025 22:31:39 +0800 Subject: [PATCH 24/33] Apply pre-commit formatting fixes --- app/tool/__init__.py | 4 ++-- app/tool/crawl4ai.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/tool/__init__.py b/app/tool/__init__.py index df15a0aba..636e9b8da 100644 --- a/app/tool/__init__.py +++ b/app/tool/__init__.py @@ -1,13 +1,13 @@ from app.tool.base import BaseTool from app.tool.bash import Bash from app.tool.browser_use_tool import BrowserUseTool +from app.tool.crawl4ai import Crawl4aiTool from app.tool.create_chat_completion import CreateChatCompletion from app.tool.planning import PlanningTool from app.tool.str_replace_editor import StrReplaceEditor from app.tool.terminate import Terminate from app.tool.tool_collection import ToolCollection from app.tool.web_search import WebSearch -from app.tool.crawl4ai import Crawl4aiTool __all__ = [ @@ -20,5 +20,5 @@ "ToolCollection", "CreateChatCompletion", "PlanningTool", - "Crawl4aiTool" + "Crawl4aiTool", ] diff --git a/app/tool/crawl4ai.py b/app/tool/crawl4ai.py index ebd30c706..d0f913368 100644 --- a/app/tool/crawl4ai.py +++ b/app/tool/crawl4ai.py @@ -248,7 +248,7 @@ async def execute( return ToolResult(output="\n".join(output_lines)) - except ImportError as e: + except ImportError: error_msg = "Crawl4AI is not installed. Please install it with: pip install crawl4ai" logger.error(error_msg) return ToolResult(error=error_msg) From 9cb3bd16c463158e83b837ab1f8482581aaeaa00 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Wed, 30 Jul 2025 18:59:33 +0800 Subject: [PATCH 25/33] remove sb_*.py to tool/sandbox&modify requirements.txt --- app/agent/browser.py | 2 +- app/agent/sandbox_agent.py | 8 +- app/daytona/api.py | 468 ---------------------- app/daytona/sandbox.py | 4 +- app/tool/{ => sandbox}/sb_browser_tool.py | 0 app/tool/{ => sandbox}/sb_files_tool.py | 0 app/tool/{ => sandbox}/sb_shell_tool.py | 0 app/tool/{ => sandbox}/sb_vision_tool.py | 0 requirements.txt | 3 + tests/daytona/test_computer_use_tool.py | 25 -- tests/daytona/test_daytona.py | 79 ---- tests/daytona/test_sb_browser_use.py | 82 ---- tests/daytona/test_sb_files_tool.py | 43 -- tests/daytona/test_sb_shell_tools.py | 31 -- tests/daytona/test_sb_vision_tool.py | 40 -- 15 files changed, 10 insertions(+), 775 deletions(-) delete mode 100644 app/daytona/api.py rename app/tool/{ => sandbox}/sb_browser_tool.py (100%) rename app/tool/{ => sandbox}/sb_files_tool.py (100%) rename app/tool/{ => sandbox}/sb_shell_tool.py (100%) rename app/tool/{ => sandbox}/sb_vision_tool.py (100%) delete mode 100644 tests/daytona/test_computer_use_tool.py delete mode 100644 tests/daytona/test_daytona.py delete mode 100644 tests/daytona/test_sb_browser_use.py delete mode 100644 tests/daytona/test_sb_files_tool.py delete mode 100644 tests/daytona/test_sb_shell_tools.py delete mode 100644 tests/daytona/test_sb_vision_tool.py diff --git a/app/agent/browser.py b/app/agent/browser.py index 9d20827bf..3f25c4539 100644 --- a/app/agent/browser.py +++ b/app/agent/browser.py @@ -8,7 +8,7 @@ from app.prompt.browser import NEXT_STEP_PROMPT, SYSTEM_PROMPT from app.schema import Message, ToolChoice from app.tool import BrowserUseTool, Terminate, ToolCollection -from app.tool.sb_browser_tool import SandboxBrowserTool +from app.tool.sandbox.sb_browser_tool import SandboxBrowserTool # Avoid circular import if BrowserAgent needs BrowserContextHelper diff --git a/app/agent/sandbox_agent.py b/app/agent/sandbox_agent.py index a510f68b6..1390e4abf 100644 --- a/app/agent/sandbox_agent.py +++ b/app/agent/sandbox_agent.py @@ -13,10 +13,10 @@ from app.tool.ask_human import AskHuman from app.tool.browser_use_tool import BrowserUseTool from app.tool.mcp import MCPClients, MCPClientTool -from app.tool.sb_browser_tool import SandboxBrowserTool -from app.tool.sb_files_tool import SandboxFilesTool -from app.tool.sb_shell_tool import SandboxShellTool -from app.tool.sb_vision_tool import SandboxVisionTool +from app.tool.sandbox.sb_browser_tool import SandboxBrowserTool +from app.tool.sandbox.sb_files_tool import SandboxFilesTool +from app.tool.sandbox.sb_shell_tool import SandboxShellTool +from app.tool.sandbox.sb_vision_tool import SandboxVisionTool class SandboxManus(ToolCallAgent): diff --git a/app/daytona/api.py b/app/daytona/api.py deleted file mode 100644 index e1fdc16a9..000000000 --- a/app/daytona/api.py +++ /dev/null @@ -1,468 +0,0 @@ -import os -import urllib.parse -from typing import Optional - -from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile -from fastapi.responses import Response -from pydantic import BaseModel -from sandbox.sandbox import delete_sandbox, get_or_start_sandbox -from services.supabase import DBConnection -from utils.auth_utils import get_optional_user_id -from utils.logger import logger - - -# Initialize shared resources -router = APIRouter(tags=["sandbox"]) -db = None - - -def initialize(_db: DBConnection): - """Initialize the sandbox API with resources from the main API.""" - global db - db = _db - logger.info("Initialized sandbox API with database connection") - - -class FileInfo(BaseModel): - """Model for file information""" - - name: str - path: str - is_dir: bool - size: int - mod_time: str - permissions: Optional[str] = None - - -def normalize_path(path: str) -> str: - """ - Normalize a path to ensure proper UTF-8 encoding and handling. - - Args: - path: The file path, potentially containing URL-encoded characters - - Returns: - Normalized path with proper UTF-8 encoding - """ - try: - # First, ensure the path is properly URL-decoded - decoded_path = urllib.parse.unquote(path) - - # Handle Unicode escape sequences like \u0308 - try: - # Replace Python-style Unicode escapes (\u0308) with actual characters - # This handles cases where the Unicode escape sequence is part of the URL - import re - - unicode_pattern = re.compile(r"\\u([0-9a-fA-F]{4})") - - def replace_unicode(match): - hex_val = match.group(1) - return chr(int(hex_val, 16)) - - decoded_path = unicode_pattern.sub(replace_unicode, decoded_path) - except Exception as unicode_err: - logger.warning( - f"Error processing Unicode escapes in path '{path}': {str(unicode_err)}" - ) - - logger.debug(f"Normalized path from '{path}' to '{decoded_path}'") - return decoded_path - except Exception as e: - logger.error(f"Error normalizing path '{path}': {str(e)}") - return path # Return original path if decoding fails - - -async def verify_sandbox_access(client, sandbox_id: str, user_id: Optional[str] = None): - """ - Verify that a user has access to a specific sandbox based on account membership. - - Args: - client: The Supabase client - sandbox_id: The sandbox ID to check access for - user_id: The user ID to check permissions for. Can be None for public resource access. - - Returns: - dict: Project data containing sandbox information - - Raises: - HTTPException: If the user doesn't have access to the sandbox or sandbox doesn't exist - """ - # Find the project that owns this sandbox - project_result = ( - await client.table("projects") - .select("*") - .filter("sandbox->>id", "eq", sandbox_id) - .execute() - ) - - if not project_result.data or len(project_result.data) == 0: - raise HTTPException(status_code=404, detail="Sandbox not found") - - project_data = project_result.data[0] - - if project_data.get("is_public"): - return project_data - - # For private projects, we must have a user_id - if not user_id: - raise HTTPException( - status_code=401, detail="Authentication required for this resource" - ) - - account_id = project_data.get("account_id") - - # Verify account membership - if account_id: - account_user_result = ( - await client.schema("basejump") - .from_("account_user") - .select("account_role") - .eq("user_id", user_id) - .eq("account_id", account_id) - .execute() - ) - if account_user_result.data and len(account_user_result.data) > 0: - return project_data - - raise HTTPException(status_code=403, detail="Not authorized to access this sandbox") - - -async def get_sandbox_by_id_safely(client, sandbox_id: str): - """ - Safely retrieve a sandbox object by its ID, using the project that owns it. - - Args: - client: The Supabase client - sandbox_id: The sandbox ID to retrieve - - Returns: - Sandbox: The sandbox object - - Raises: - HTTPException: If the sandbox doesn't exist or can't be retrieved - """ - # Find the project that owns this sandbox - project_result = ( - await client.table("projects") - .select("project_id") - .filter("sandbox->>id", "eq", sandbox_id) - .execute() - ) - - if not project_result.data or len(project_result.data) == 0: - logger.error(f"No project found for sandbox ID: {sandbox_id}") - raise HTTPException( - status_code=404, - detail="Sandbox not found - no project owns this sandbox ID", - ) - - # project_id = project_result.data[0]['project_id'] - # logger.debug(f"Found project {project_id} for sandbox {sandbox_id}") - - try: - # Get the sandbox - sandbox = await get_or_start_sandbox(sandbox_id) - # Extract just the sandbox object from the tuple (sandbox, sandbox_id, sandbox_pass) - # sandbox = sandbox_tuple[0] - - return sandbox - except Exception as e: - logger.error(f"Error retrieving sandbox {sandbox_id}: {str(e)}") - raise HTTPException( - status_code=500, detail=f"Failed to retrieve sandbox: {str(e)}" - ) - - -@router.post("/sandboxes/{sandbox_id}/files") -async def create_file( - sandbox_id: str, - path: str = Form(...), - file: UploadFile = File(...), - request: Request = None, - user_id: Optional[str] = Depends(get_optional_user_id), -): - """Create a file in the sandbox using direct file upload""" - # Normalize the path to handle UTF-8 encoding correctly - path = normalize_path(path) - - logger.info( - f"Received file upload request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}" - ) - client = await db.client - - # Verify the user has access to this sandbox - await verify_sandbox_access(client, sandbox_id, user_id) - - try: - # Get sandbox using the safer method - sandbox = await get_sandbox_by_id_safely(client, sandbox_id) - - # Read file content directly from the uploaded file - content = await file.read() - - # Create file using raw binary content - sandbox.fs.upload_file(content, path) - logger.info(f"File created at {path} in sandbox {sandbox_id}") - - return {"status": "success", "created": True, "path": path} - except Exception as e: - logger.error(f"Error creating file in sandbox {sandbox_id}: {str(e)}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/sandboxes/{sandbox_id}/files") -async def list_files( - sandbox_id: str, - path: str, - request: Request = None, - user_id: Optional[str] = Depends(get_optional_user_id), -): - """List files and directories at the specified path""" - # Normalize the path to handle UTF-8 encoding correctly - path = normalize_path(path) - - logger.info( - f"Received list files request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}" - ) - client = await db.client - - # Verify the user has access to this sandbox - await verify_sandbox_access(client, sandbox_id, user_id) - - try: - # Get sandbox using the safer method - sandbox = await get_sandbox_by_id_safely(client, sandbox_id) - - # List files - files = sandbox.fs.list_files(path) - result = [] - - for file in files: - # Convert file information to our model - # Ensure forward slashes are used for paths, regardless of OS - full_path = ( - f"{path.rstrip('/')}/{file.name}" if path != "/" else f"/{file.name}" - ) - file_info = FileInfo( - name=file.name, - path=full_path, # Use the constructed path - is_dir=file.is_dir, - size=file.size, - mod_time=str(file.mod_time), - permissions=getattr(file, "permissions", None), - ) - result.append(file_info) - - logger.info(f"Successfully listed {len(result)} files in sandbox {sandbox_id}") - return {"files": [file.dict() for file in result]} - except Exception as e: - logger.error(f"Error listing files in sandbox {sandbox_id}: {str(e)}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/sandboxes/{sandbox_id}/files/content") -async def read_file( - sandbox_id: str, - path: str, - request: Request = None, - user_id: Optional[str] = Depends(get_optional_user_id), -): - """Read a file from the sandbox""" - # Normalize the path to handle UTF-8 encoding correctly - original_path = path - path = normalize_path(path) - - logger.info( - f"Received file read request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}" - ) - if original_path != path: - logger.info(f"Normalized path from '{original_path}' to '{path}'") - - client = await db.client - - # Verify the user has access to this sandbox - await verify_sandbox_access(client, sandbox_id, user_id) - - try: - # Get sandbox using the safer method - sandbox = await get_sandbox_by_id_safely(client, sandbox_id) - - # Read file directly - don't check existence first with a separate call - try: - content = sandbox.fs.download_file(path) - except Exception as download_err: - logger.error( - f"Error downloading file {path} from sandbox {sandbox_id}: {str(download_err)}" - ) - raise HTTPException( - status_code=404, detail=f"Failed to download file: {str(download_err)}" - ) - - # Return a Response object with the content directly - filename = os.path.basename(path) - logger.info(f"Successfully read file {filename} from sandbox {sandbox_id}") - - # Ensure proper encoding by explicitly using UTF-8 for the filename in Content-Disposition header - # This applies RFC 5987 encoding for the filename to support non-ASCII characters - encoded_filename = filename.encode("utf-8").decode("latin-1") - content_disposition = f"attachment; filename*=UTF-8''{encoded_filename}" - - return Response( - content=content, - media_type="application/octet-stream", - headers={"Content-Disposition": content_disposition}, - ) - except HTTPException: - # Re-raise HTTP exceptions without wrapping - raise - except Exception as e: - logger.error(f"Error reading file in sandbox {sandbox_id}: {str(e)}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.delete("/sandboxes/{sandbox_id}/files") -async def delete_file( - sandbox_id: str, - path: str, - request: Request = None, - user_id: Optional[str] = Depends(get_optional_user_id), -): - """Delete a file from the sandbox""" - # Normalize the path to handle UTF-8 encoding correctly - path = normalize_path(path) - - logger.info( - f"Received file delete request for sandbox {sandbox_id}, path: {path}, user_id: {user_id}" - ) - client = await db.client - - # Verify the user has access to this sandbox - await verify_sandbox_access(client, sandbox_id, user_id) - - try: - # Get sandbox using the safer method - sandbox = await get_sandbox_by_id_safely(client, sandbox_id) - - # Delete file - sandbox.fs.delete_file(path) - logger.info(f"File deleted at {path} in sandbox {sandbox_id}") - - return {"status": "success", "deleted": True, "path": path} - except Exception as e: - logger.error(f"Error deleting file in sandbox {sandbox_id}: {str(e)}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.delete("/sandboxes/{sandbox_id}") -async def delete_sandbox_route( - sandbox_id: str, - request: Request = None, - user_id: Optional[str] = Depends(get_optional_user_id), -): - """Delete an entire sandbox""" - logger.info( - f"Received sandbox delete request for sandbox {sandbox_id}, user_id: {user_id}" - ) - client = await db.client - - # Verify the user has access to this sandbox - await verify_sandbox_access(client, sandbox_id, user_id) - - try: - # Delete the sandbox using the sandbox module function - await delete_sandbox(sandbox_id) - - return {"status": "success", "deleted": True, "sandbox_id": sandbox_id} - except Exception as e: - logger.error(f"Error deleting sandbox {sandbox_id}: {str(e)}") - raise HTTPException(status_code=500, detail=str(e)) - - -# Should happen on server-side fully -@router.post("/project/{project_id}/sandbox/ensure-active") -async def ensure_project_sandbox_active( - project_id: str, - request: Request = None, - user_id: Optional[str] = Depends(get_optional_user_id), -): - """ - Ensure that a project's sandbox is active and running. - Checks the sandbox status and starts it if it's not running. - """ - logger.info( - f"Received ensure sandbox active request for project {project_id}, user_id: {user_id}" - ) - client = await db.client - - # Find the project and sandbox information - project_result = ( - await client.table("projects") - .select("*") - .eq("project_id", project_id) - .execute() - ) - - if not project_result.data or len(project_result.data) == 0: - logger.error(f"Project not found: {project_id}") - raise HTTPException(status_code=404, detail="Project not found") - - project_data = project_result.data[0] - - # For public projects, no authentication is needed - if not project_data.get("is_public"): - # For private projects, we must have a user_id - if not user_id: - logger.error(f"Authentication required for private project {project_id}") - raise HTTPException( - status_code=401, detail="Authentication required for this resource" - ) - - account_id = project_data.get("account_id") - - # Verify account membership - if account_id: - account_user_result = ( - await client.schema("basejump") - .from_("account_user") - .select("account_role") - .eq("user_id", user_id) - .eq("account_id", account_id) - .execute() - ) - if not (account_user_result.data and len(account_user_result.data) > 0): - logger.error( - f"User {user_id} not authorized to access project {project_id}" - ) - raise HTTPException( - status_code=403, detail="Not authorized to access this project" - ) - - try: - # Get sandbox ID from project data - sandbox_info = project_data.get("sandbox", {}) - if not sandbox_info.get("id"): - raise HTTPException( - status_code=404, detail="No sandbox found for this project" - ) - - sandbox_id = sandbox_info["id"] - - # Get or start the sandbox - logger.info(f"Ensuring sandbox is active for project {project_id}") - await get_or_start_sandbox(sandbox_id) - - logger.info( - f"Successfully ensured sandbox {sandbox_id} is active for project {project_id}" - ) - - return { - "status": "success", - "sandbox_id": sandbox_id, - "message": "Sandbox is active", - } - except Exception as e: - logger.error( - f"Error ensuring sandbox is active for project {project_id}: {str(e)}" - ) - raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/daytona/sandbox.py b/app/daytona/sandbox.py index 6901a75c2..ff866ce21 100644 --- a/app/daytona/sandbox.py +++ b/app/daytona/sandbox.py @@ -135,12 +135,12 @@ def create_sandbox(password: str, project_id: str = None): # Create the sandbox sandbox = daytona.create(params) - logger.debug(f"Sandbox created with ID: {sandbox.id}") + logger.info(f"Sandbox created with ID: {sandbox.id}") # Start supervisord in a session for new sandbox start_supervisord_session(sandbox) - logger.debug(f"Sandbox environment successfully initialized") + logger.info(f"Sandbox environment successfully initialized") return sandbox diff --git a/app/tool/sb_browser_tool.py b/app/tool/sandbox/sb_browser_tool.py similarity index 100% rename from app/tool/sb_browser_tool.py rename to app/tool/sandbox/sb_browser_tool.py diff --git a/app/tool/sb_files_tool.py b/app/tool/sandbox/sb_files_tool.py similarity index 100% rename from app/tool/sb_files_tool.py rename to app/tool/sandbox/sb_files_tool.py diff --git a/app/tool/sb_shell_tool.py b/app/tool/sandbox/sb_shell_tool.py similarity index 100% rename from app/tool/sb_shell_tool.py rename to app/tool/sandbox/sb_shell_tool.py diff --git a/app/tool/sb_vision_tool.py b/app/tool/sandbox/sb_vision_tool.py similarity index 100% rename from app/tool/sb_vision_tool.py rename to app/tool/sandbox/sb_vision_tool.py diff --git a/requirements.txt b/requirements.txt index aa7e6dc93..8e9e13625 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,3 +40,6 @@ crawl4ai~=0.6.3 huggingface-hub~=0.29.2 setuptools~=75.8.0 + +daytona==0.21.8 +structlog==25.4.0 diff --git a/tests/daytona/test_computer_use_tool.py b/tests/daytona/test_computer_use_tool.py deleted file mode 100644 index 0b359e50a..000000000 --- a/tests/daytona/test_computer_use_tool.py +++ /dev/null @@ -1,25 +0,0 @@ -import asyncio - -from app.daytona.sandbox import create_sandbox -from app.tool.computer_use_tool import ComputerUseTool - - -async def main(): - # 创建沙箱和工具 - sandbox = create_sandbox(password="123456") - base_url = sandbox.get_preview_link(8000) - print(f"Sandbox base URL: {base_url}") - - print(f"Sandbox ID: {sandbox.id}") - computer_tool = ComputerUseTool.create_with_sandbox(sandbox) - - # 执行截图操作 - result = await computer_tool.execute(action="screenshot") - print(result) - - # 清理资源(可选) - await computer_tool.cleanup() - - -if __name__ == "__main__": - asyncio.run(main()) # 运行异步主函数 diff --git a/tests/daytona/test_daytona.py b/tests/daytona/test_daytona.py deleted file mode 100644 index 692f7409d..000000000 --- a/tests/daytona/test_daytona.py +++ /dev/null @@ -1,79 +0,0 @@ -from daytona import CreateSandboxFromImageParams, Daytona, DaytonaConfig, Resources - - -# Using environment variables (DAYTONA_API_KEY, DAYTONA_API_URL, DAYTONA_TARGET) -# daytona = Daytona() -# Using explicit configuration -config = DaytonaConfig(api_key="", api_url="https://app.daytona.io/api", target="us") -daytona = Daytona(config) -params = CreateSandboxFromImageParams( - image="", - # image=Image.debian_slim("3.12"), - public=True, - labels=None, - env_vars={ - "CHROME_PERSISTENT_SESSION": "true", - "RESOLUTION": "1024x768x24", - "RESOLUTION_WIDTH": "1024", - "RESOLUTION_HEIGHT": "768", - "VNC_PASSWORD": "123456", - "ANONYMIZED_TELEMETRY": "false", - "CHROME_PATH": "", - "CHROME_USER_DATA": "", - "CHROME_DEBUGGING_PORT": "9222", - "CHROME_DEBUGGING_HOST": "localhost", - "CHROME_CDP": "", - }, - resources=Resources( - cpu=1, - memory=1, - disk=1, - ), - auto_stop_interval=15, - auto_archive_interval=24 * 60, -) -# Create the sandbox -sandbox = daytona.create(params) -print(f"Sandbox created with ID: {sandbox.id}") - - -# from daytona import Daytona - -# def main(): -# # Initialize the SDK (uses environment variables by default) -# daytona = Daytona(config) - -# # Create a new sandbox -# sandbox = daytona.create() - -# # Execute a command -# response = sandbox.process.exec("echo 'Hello, World!'") -# print(response.result) - -# if __name__ == "__main__": -# main() -# from daytona import Daytona, DaytonaConfig - -# Define the configuration - -# config = DaytonaConfig(api_key="") - -# # Initialize the Daytona client - -# daytona = Daytona(config) - -# # Create the Sandbox instance - -# sandbox = daytona.create() - -# # Run the code securely inside the Sandbox - -# response = sandbox.process.code_run('print("Hello World from code!")') -# if response.exit_code != 0: -# print(f"Error: {response.exit_code} {response.result}") -# else: -# print(response.result) - -# Clean up - -# sandbox.delete() diff --git a/tests/daytona/test_sb_browser_use.py b/tests/daytona/test_sb_browser_use.py deleted file mode 100644 index af289802a..000000000 --- a/tests/daytona/test_sb_browser_use.py +++ /dev/null @@ -1,82 +0,0 @@ -import asyncio - -from app.daytona.sandbox import create_sandbox -from app.tool.sb_browser_tool import SandboxBrowserTool - - -async def main(): - # 创建沙箱和工具 - sandbox = create_sandbox(password="123456") - # config = DaytonaConfig( - # api_key="", - # api_url="https://app.daytona.io/api", - # target="us" - # ) - # daytona = Daytona(config) - # sandbox = daytona.find_one("201415a9-28ad-4b6d-8756-13b1e34a70c3") - # if sandbox.state == "archived" or sandbox.state == "stopped": - # logger.info(f"Sandbox is in {sandbox.state} state. Starting...") - # try: - # daytona.start(sandbox) - # start_supervisord_session(sandbox) - # # Wait a moment for the sandbox to initialize - # # sleep(5) - # # Refresh sandbox state after starting - # # sandbox = daytona.get(sandbox.id) - # except Exception as e: - # logger.error(f"Error starting sandbox: {e}") - # raise e - - # sandbox.start() - vnc_link = sandbox.get_preview_link(6080) - website_link = sandbox.get_preview_link(8080) - computer_link = sandbox.get_preview_link(8000) - print(f"VNC Link: {vnc_link}") - print(f"Website Link: {website_link}") - print(f"Computer Link: {computer_link}") - # sandbox.start() - # base_url=sandbox.get_preview_link(8000) - # print(f"Sandbox base URL: {base_url}") - - # print(f"Sandbox ID: {sandbox.id}") - tool = SandboxBrowserTool(sandbox) - - # # 执行截图操作 - result, tooresult = await tool.execute( - action="navigate_to", url="https://www.github.com" - ) - print(result) - - # endpoint = "navigate_to" - # method = "POST" - # params = { - # "url": "https://www.google.com" - # } - # url = f"http://localhost:8003/api/automation/{endpoint}" - # curl_cmd = f"curl -s -X {method} '{url}' -H 'Content-Type: application/json'" - # json_data = json.dumps(params) - # curl_cmd += f" -d '{json_data}'" - # logger.debug(f"Executing curl command: {curl_cmd}") - # response = sandbox.process.exec(curl_cmd, timeout=30) - # print(f"Response: {response}") - - # response = sandbox.process.exec("ls -la") - # print(response.result) - # 清理资源(可选) - # await computer_tool.cleanup() - - -if __name__ == "__main__": - asyncio.run(main()) # 运行异步主函数 - # await computer_tool.cleanup() - -if __name__ == "__main__": - asyncio.run(main()) # 运行异步主函数 - # await computer_tool.cleanup() - -if __name__ == "__main__": - asyncio.run(main()) # 运行异步主函数 - # await computer_tool.cleanup() - -if __name__ == "__main__": - asyncio.run(main()) # 运行异步主函数 diff --git a/tests/daytona/test_sb_files_tool.py b/tests/daytona/test_sb_files_tool.py deleted file mode 100644 index d5076589c..000000000 --- a/tests/daytona/test_sb_files_tool.py +++ /dev/null @@ -1,43 +0,0 @@ -import asyncio - -from app.daytona.sandbox import create_sandbox -from app.tool.sb_files_tool import SandboxFilesTool - - -async def main(): - # 创建沙箱和工具 - sandbox = create_sandbox(password="123456") - # base_url=sandbox.get_preview_link(8000) - # print(f"Sandbox base URL: {base_url}") - - print(f"Sandbox ID: {sandbox.id}") - - vnc_link = sandbox.get_preview_link(6080) - website_link = sandbox.get_preview_link(8080) - print(f"VNC Link: {vnc_link}") - print(f"Website Link: {website_link}") - - sb_files_tool = SandboxFilesTool(sandbox) - - # 执行创建文件操作 - result = await sb_files_tool.execute( - action="create_file", file_path="src/a.txt", file_contents="aaaaa1111" - ) - print(result) - # 执行字符串替换操作 - # result = await sb_files_tool.execute(action="str_replace", file_path="src/a.txt", old_str="aaaaa", new_str="bbbbb") - # print(result) - # 执行全文件重写操作 - result = await sb_files_tool.execute( - action="full_file_rewrite", file_path="src/a.txt", file_contents="1234567" - ) - print(result) - # 执行删除文件操作 - # result = await sb_files_tool.execute(action="delete_file", file_path="src/a.txt") - # print(result) - # 清理资源(可选) - # await sb_shell_tool.cleanup() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/tests/daytona/test_sb_shell_tools.py b/tests/daytona/test_sb_shell_tools.py deleted file mode 100644 index 8c69f51c4..000000000 --- a/tests/daytona/test_sb_shell_tools.py +++ /dev/null @@ -1,31 +0,0 @@ -import asyncio - -from app.daytona.sandbox import create_sandbox -from app.tool.sb_shell_tool import SandboxShellTool - - -async def main(): - # 创建沙箱和工具 - sandbox = create_sandbox(password="123456") - # base_url=sandbox.get_preview_link(8000) - # print(f"Sandbox base URL: {base_url}") - - print(f"Sandbox ID: {sandbox.id}") - - vnc_link = sandbox.get_preview_link(6080) - website_link = sandbox.get_preview_link(8080) - print(f"VNC Link: {vnc_link}") - print(f"Website Link: {website_link}") - sb_shell_tool = SandboxShellTool(sandbox) - - # 执行截图操作 - result = await sb_shell_tool.execute(action="execute_command", command="pwd") - print(result) - - # 清理资源(可选) - # await sb_shell_tool.cleanup() - - -if __name__ == "__main__": - print("123") - asyncio.run(main()) diff --git a/tests/daytona/test_sb_vision_tool.py b/tests/daytona/test_sb_vision_tool.py deleted file mode 100644 index c06a51cb3..000000000 --- a/tests/daytona/test_sb_vision_tool.py +++ /dev/null @@ -1,40 +0,0 @@ -import asyncio - -from app.daytona.sandbox import create_sandbox -from app.tool.sb_shell_tool import SandboxShellTool -from app.tool.sb_vision_tool import SandboxVisionTool - - -async def main(): - # 创建沙箱和工具 - sandbox = create_sandbox(password="123456") - # base_url=sandbox.get_preview_link(8000) - # print(f"Sandbox base URL: {base_url}") - - print(f"Sandbox ID: {sandbox.id}") - - vnc_link = sandbox.get_preview_link(6080) - website_link = sandbox.get_preview_link(8080) - print(f"VNC Link: {vnc_link}") - print(f"Website Link: {website_link}") - sb_shell_tool = SandboxShellTool(sandbox) - sb_vision_tool = SandboxVisionTool(sandbox) - - # 执行终端命令 - result = await sb_shell_tool.execute( - action="execute_command", - command="curl -O http://img.netbian.com/file/2025/0716/091412RIFD9.jpg", - ) - print(result) - # 执行see_image操作 - result = await sb_vision_tool.execute( - action="see_image", file_path="091412RIFD9.jpg" - ) - print(result) - - # 清理资源(可选) - # await sb_shell_tool.cleanup() - - -if __name__ == "__main__": - asyncio.run(main()) From 23f2e85ae9a6c2098545c5dcd8547387d7d734b4 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Wed, 30 Jul 2025 23:36:44 +0800 Subject: [PATCH 26/33] modify requirements.txt --- requirements.txt | 3 --- 1 file changed, 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8e9e13625..aa7e6dc93 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,6 +40,3 @@ crawl4ai~=0.6.3 huggingface-hub~=0.29.2 setuptools~=75.8.0 - -daytona==0.21.8 -structlog==25.4.0 From 3e604acb93e41495370d93af5ed44d7e9c6c8319 Mon Sep 17 00:00:00 2001 From: Jiayi Zhang <84363704+didiforgithub@users.noreply.github.com> Date: Wed, 13 Aug 2025 10:38:39 +0800 Subject: [PATCH 27/33] Update Group Link. --- assets/community_group.jpg | Bin 170211 -> 0 bytes assets/community_group.png | Bin 0 -> 177676 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 assets/community_group.jpg create mode 100644 assets/community_group.png diff --git a/assets/community_group.jpg b/assets/community_group.jpg deleted file mode 100644 index 3998f0d433530cf03ad108b011e5c8353ee29993..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 170211 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

XpAE33!8R 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 jWihra#{Xk6hWR^5l?ND?o;X)-13pFuF#Vznw Date: Sat, 16 Aug 2025 17:46:02 +0800 Subject: [PATCH 28/33] modify sb_browser_use tool's result --- app/tool/sandbox/sb_browser_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/tool/sandbox/sb_browser_tool.py b/app/tool/sandbox/sb_browser_tool.py index 3725cbf64..16bb84450 100644 --- a/app/tool/sandbox/sb_browser_tool.py +++ b/app/tool/sandbox/sb_browser_tool.py @@ -258,7 +258,7 @@ async def _execute_browser_action( if field in result: success_response[field] = result[field] return ( - (result, self.success_response(success_response)) + self.success_response(success_response) if success_response["success"] else self.fail_response(success_response) ) From 20c7207bda3c9ea21723919c728b4be1f1265843 Mon Sep 17 00:00:00 2001 From: Jiayi Zhang <84363704+didiforgithub@users.noreply.github.com> Date: Thu, 4 Sep 2025 18:39:07 +0800 Subject: [PATCH 29/33] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 6dd1c8b3f..58d82ded4 100644 --- a/README.md +++ b/README.md @@ -174,8 +174,7 @@ Thanks to [PPIO](https://ppinfra.com/user/register?invited_by=OCPKCN&utm_source= ## Acknowledgement -Thanks to [anthropic-computer-use](https://github.com/anthropics/anthropic-quickstarts/tree/main/computer-use-demo) -and [browser-use](https://github.com/browser-use/browser-use) for providing basic support for this project! +Thanks to [anthropic-computer-use](https://github.com/anthropics/anthropic-quickstarts/tree/main/computer-use-demo), [browser-use](https://github.com/browser-use/browser-use) and [crawl4ai](https://github.com/unclecode/crawl4ai) for providing basic support for this project! Additionally, we are grateful to [AAAJ](https://github.com/metauto-ai/agent-as-a-judge), [MetaGPT](https://github.com/geekan/MetaGPT), [OpenHands](https://github.com/All-Hands-AI/OpenHands) and [SWE-agent](https://github.com/SWE-agent/SWE-agent). From c26bc9262ed655bc53a0df9e74a6fa57cb3fbcf0 Mon Sep 17 00:00:00 2001 From: GhostC <1276537536@qq.com> Date: Sun, 14 Sep 2025 18:15:01 +0800 Subject: [PATCH 30/33] put base64 in the currect position for sb_vision --- app/agent/sandbox_agent.py | 3 +-- app/daytona/sandbox.py | 3 +++ app/tool/sandbox/sb_browser_tool.py | 2 +- app/tool/sandbox/sb_vision_tool.py | 5 +++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/agent/sandbox_agent.py b/app/agent/sandbox_agent.py index 1390e4abf..58612d20f 100644 --- a/app/agent/sandbox_agent.py +++ b/app/agent/sandbox_agent.py @@ -11,7 +11,6 @@ from app.prompt.manus import NEXT_STEP_PROMPT, SYSTEM_PROMPT from app.tool import Terminate, ToolCollection from app.tool.ask_human import AskHuman -from app.tool.browser_use_tool import BrowserUseTool from app.tool.mcp import MCPClients, MCPClientTool from app.tool.sandbox.sb_browser_tool import SandboxBrowserTool from app.tool.sandbox.sb_files_tool import SandboxFilesTool @@ -205,7 +204,7 @@ async def think(self) -> bool: original_prompt = self.next_step_prompt recent_messages = self.memory.messages[-3:] if self.memory.messages else [] browser_in_use = any( - tc.function.name == BrowserUseTool().name + tc.function.name == SandboxBrowserTool().name for msg in recent_messages if msg.tool_calls for tc in msg.tool_calls diff --git a/app/daytona/sandbox.py b/app/daytona/sandbox.py index ff866ce21..8970b9cef 100644 --- a/app/daytona/sandbox.py +++ b/app/daytona/sandbox.py @@ -1,3 +1,5 @@ +import time + from daytona import ( CreateSandboxFromImageParams, Daytona, @@ -90,6 +92,7 @@ def start_supervisord_session(sandbox: Sandbox): var_async=True, ), ) + time.sleep(25) # Wait a bit to ensure supervisord starts properly logger.info(f"Supervisord started in session {session_id}") except Exception as e: logger.error(f"Error starting supervisord session: {str(e)}") diff --git a/app/tool/sandbox/sb_browser_tool.py b/app/tool/sandbox/sb_browser_tool.py index 16bb84450..b3a862edd 100644 --- a/app/tool/sandbox/sb_browser_tool.py +++ b/app/tool/sandbox/sb_browser_tool.py @@ -229,7 +229,7 @@ async def _execute_browser_action( f"Screenshot validation failed: {validation_message}" ) result["image_validation_error"] = validation_message - del result["screenshot_base64"] + del result["screenshot_base64"] # added_message = await self.thread_manager.add_message( # thread_id=self.thread_id, diff --git a/app/tool/sandbox/sb_vision_tool.py b/app/tool/sandbox/sb_vision_tool.py index 7559450b9..ffe847d48 100644 --- a/app/tool/sandbox/sb_vision_tool.py +++ b/app/tool/sandbox/sb_vision_tool.py @@ -170,8 +170,9 @@ async def execute( ) self.vision_message = message # return self.success_response(f"成功加载并压缩图片 '{cleaned_path}' (由 {file_info.size / 1024:.1f}KB 压缩到 {len(compressed_bytes) / 1024:.1f}KB)。") - return self.success_response( - f"成功加载并压缩图片 '{cleaned_path}',压缩后的内容为:{base64_image}" + return ToolResult( + output=f"成功加载并压缩图片 '{cleaned_path}'", + base64_image=base64_image, ) except Exception as e: return self.fail_response(f"see_image 执行异常: {str(e)}") From 872d5bb59e8d68c657c61c8bc88dc807700a91bf Mon Sep 17 00:00:00 2001 From: cnJasonZ Date: Fri, 7 Nov 2025 13:48:49 +0800 Subject: [PATCH 31/33] feat: add JiekouAI as new provider --- config/config.example-model-jiekouai.toml | 17 +++++++++++++++++ config/config.example.toml | 8 ++++++++ 2 files changed, 25 insertions(+) create mode 100644 config/config.example-model-jiekouai.toml diff --git a/config/config.example-model-jiekouai.toml b/config/config.example-model-jiekouai.toml new file mode 100644 index 000000000..ea40f27e9 --- /dev/null +++ b/config/config.example-model-jiekouai.toml @@ -0,0 +1,17 @@ +# Global LLM configuration +[llm] #Jiekou.AI: +api_type = 'jiekou' +model = "claude-sonnet-4-5-20250929" # The LLM model to use +base_url = "https://api.jiekou.ai/openai" # API endpoint URL +api_key = "your Jiekou.AI api key" # Your API key +max_tokens = 64000 # Maximum number of tokens in the response +temperature = 0.0 # Controls randomness + + +[llm.vision] #Jiekou.AI VISION: +api_type = 'jiekou' +model = "claude-sonnet-4-5-20250929" # The vision model to use +base_url = "https://api.jiekou.ai/openai" # API endpoint URL for vision model +api_key = "your Jiekou.AI api key" # Your API key for vision model +max_tokens = 64000 # Maximum number of tokens in the response +temperature = 0.0 # Controls randomness for vision model diff --git a/config/config.example.toml b/config/config.example.toml index 7693ee8df..2e99bf828 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -31,6 +31,14 @@ temperature = 0.0 # Controls randomness # max_tokens = 4096 # temperature = 0.0 +# [llm] #Jiekou.AI: +# api_type = 'jiekou' +# model = "claude-sonnet-4-5-20250929" # The LLM model to use +# base_url = "https://api.jiekou.ai/openai" # API endpoint URL +# api_key = "your Jiekou.AI api key" # Your API key +# max_tokens = 64000 # Maximum number of tokens in the response +# temperature = 0.0 # Controls randomness + # Optional configuration for specific LLM models [llm.vision] model = "claude-3-7-sonnet-20250219" # The vision model to use From 52a13f2a57d8c7f6737eefb02ccf569594d44273 Mon Sep 17 00:00:00 2001 From: Jiayi Zhang <84363704+didiforgithub@users.noreply.github.com> Date: Sun, 4 Jan 2026 11:11:56 +0800 Subject: [PATCH 32/33] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 58d82ded4..d4a84374f 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ OpenManus is built by contributors from MetaGPT. Huge thanks to this agent commu ## Cite ```bibtex @misc{openmanus2025, - author = {Xinbin Liang and Jinyu Xiang and Zhaoyang Yu and Jiayi Zhang and Sirui Hong and Sheng Fan and Xiao Tang}, + author = {Xinbin Liang and Jinyu Xiang and Zhaoyang Yu and Jiayi Zhang and Sirui Hong and Sheng Fan and Xiao Tang and Bang Liu and Yuyu Luo and Chenglin Wu}, title = {OpenManus: An open-source framework for building general AI agents}, year = {2025}, publisher = {Zenodo}, From f2edfc73dafe9d0411301abdb074a91ac56df499 Mon Sep 17 00:00:00 2001 From: cryptozone2030-hue Date: Mon, 30 Mar 2026 19:49:46 +0300 Subject: [PATCH 33/33] Update config.yml I want to get educational support to open services OpenManus --- .github/ISSUE_TEMPLATE/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 69766c1fd..d19a19228 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - - name: "Join the Community Group" + - name: "ali ali" about: Join the OpenManus community to discuss and get help from others url: https://github.com/FoundationAgents/OpenManus?tab=readme-ov-file#community-group