diff --git a/GETTING_STARTED.md b/CONTRIBUTING.md similarity index 50% rename from GETTING_STARTED.md rename to CONTRIBUTING.md index 70227318..f41cd881 100644 --- a/GETTING_STARTED.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Getting Started +# Contributing -A step-by-step guide to get Open Swarm running locally — from clone to launch. +A guide to setting up Open Swarm for local development and contributing to the project. --- @@ -15,10 +15,8 @@ Make sure the following are installed on your machine before proceeding: | **Node.js** | 18+ | `node --version` | | **npm** | 9+ (ships with Node) | `npm --version` | -### Installing prerequisites -
-Node.js (via nvm) +Installing Node.js via nvm ```bash curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash @@ -34,54 +32,61 @@ nvm use 22 ## 1. Clone the repository ```bash -git clone https://github.com//self-swarm.git -cd self-swarm +git clone https://github.com/openswarm-ai/openswarm.git +cd openswarm ``` --- -## 2. Backend setup (Configure environment variables) +## 2. Configure environment variables -Copy the example environment file and fill in your values: +Copy the example environment file: ```bash cp backend/.env.example backend/.env ``` -Edit `backend/.env` with your values: +The Anthropic API key can be set in-app via the **Settings** page — no `.env` entry needed for that. -```env -# Backend server port -BACKEND_PORT=8324 +For other integrations, edit `backend/.env`: -# Google OAuth (optional — needed for Google Workspace tools) -GOOGLE_OAUTH_CLIENT_ID= -GOOGLE_OAUTH_CLIENT_SECRET= -``` +| Variable | Purpose | +|----------|---------| +| `BACKEND_PORT` | Backend server port (default: `8324`) | +| `GOOGLE_OAUTH_CLIENT_ID` | Google Workspace integration (Gmail, Calendar, Drive) | +| `GOOGLE_OAUTH_CLIENT_SECRET` | Google Workspace integration | +| `APPLE_ID` | macOS code signing & notarization (release builds only) | +| `APPLE_APP_SPECIFIC_PASSWORD` | macOS notarization (release builds only) | +| `APPLE_TEAM_ID` | macOS code signing (release builds only) | +| `GH_TOKEN` | GitHub Releases publishing (release builds only) | --- ## 3. Run the application -You have two options: use the provided **run scripts** (recommended) or start each service manually. - -### Option A: Run scripts (recommended) - -These scripts handle virtual environments, dependency installation, and startup automatically. - -**Backend** (starts FastAPI server): +### Option A: All-in-one (recommended) ```bash -./backend/run/dev.sh +bash run/local.sh ``` -**Frontend** (in a separate terminal): +This starts the backend (port 8324), frontend (port 3000), and Electron shell together. The script handles virtual environments and dependency installation automatically. + +### Option B: Run services individually + +**Backend** (in one terminal): ```bash -./frontend/run/dev.sh +bash backend/run.sh # API at http://localhost:8324 — docs at /docs ``` -### Option B: Manual startup +**Frontend** (in another terminal): + +```bash +bash frontend/run.sh # App at http://localhost:3000 +``` + +### Option C: Manual startup **Terminal 1 — Backend server:** @@ -166,47 +171,74 @@ Your agents can now use Google Calendar, Gmail, Drive, etc. through MCP tools. ## Project structure ``` -self-swarm/ -├── backend/ -│ ├── apps/ # FastAPI route modules -│ │ ├── agents/ # Agent lifecycle, WebSocket, worktree management -│ │ ├── templates/ # Prompt template CRUD -│ │ ├── skills/ # Skills CRUD (synced to ~/.claude/skills/) -│ │ ├── tools_lib/ # Tool definitions CRUD -│ │ ├── modes/ # Agent mode configurations -│ │ ├── settings/ # App settings (API keys, preferences) -│ │ ├── outputs/ # Output management -│ │ └── dashboards/ # Dashboard layout persistence -│ ├── config/ # FastAPI app configuration -│ ├── data/ # Persistent JSON file storage -│ ├── run/ # Shell scripts for starting the backend -│ ├── main.py # FastAPI entrypoint -│ ├── requirements.txt # Python dependencies -│ └── .env # Environment variables (not committed) -├── frontend/ -│ ├── src/ -│ │ ├── app/ -│ │ │ ├── components/ # AppShell, CommandPicker, modals -│ │ │ └── pages/ # Dashboard, AgentChat, Templates, Skills, Tools, etc. -│ │ └── shared/ -│ │ ├── state/ # Redux slices -│ │ ├── ws/ # WebSocket manager -│ │ └── hooks/ # Custom React hooks -│ ├── public/ # Static assets -│ ├── webpack.config.js # Webpack bundler config -│ └── package.json # Node dependencies -├── debugger/ # Optional debugging tool -└── README.md +backend/ + apps/ + agents/ Agent lifecycle, streaming, worktree management + dashboards/ Dashboard CRUD and layout persistence + dashboard_layout/ Card positions and spatial canvas state + templates/ Prompt template CRUD + skills/ Skills CRUD (synced to ~/.claude/skills/) + tools_lib/ MCP tool configuration and discovery + modes/ Agent mode definitions + outputs/ Views/outputs, vibe coding, Python executor + settings/ App settings and file browser + health/ Health check endpoint + mcp_registry/ MCP server registry proxy + skill_registry/ Anthropic skills marketplace proxy + config/ FastAPI app configuration + data/ Persistent JSON file storage + +frontend/ + src/ + app/ + components/ AppShell, Layout, shared UI + pages/ + Dashboard/ Spatial canvas with agent/view/browser cards + AgentChat/ Streaming chat, HITL approvals, branching, diff viewer + Templates/ Template library with structured input fields + Skills/ Skills library, skill builder, registry browser + Tools/ Tool config, MCP discovery, OAuth, registry browser + Modes/ Mode definitions with system prompts + Views/ Output artifacts, code editor, vibe coding + Commands/ Keyboard shortcuts reference + Settings/ App configuration + shared/ + state/ Redux slices (agents, dashboards, templates, skills, tools, modes, etc.) + ws/ WebSocket manager + hooks/ Custom hooks + styles/ Theme tokens, global styles + +electron/ + main.js Electron main process, auto-updater, Python env management + scripts/ Build and notarization scripts + +run/ + utils/ + build-app.sh Desktop app packaging (electron-builder) + build-python-env.sh Standalone Python 3.13 environment bundler + local.sh Start backend, frontend, and Electron shell + publish.sh Build and deploy to Firebase Hosting ``` --- +## Contribution workflow + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/your-feature`) +3. Make your changes +4. Submit a pull request + +Please open an issue first for larger changes so we can discuss the approach. + +--- + ## Troubleshooting ### Backend won't start — `ModuleNotFoundError` Make sure you're running from the **project root** (not from `backend/`): ```bash -cd self-swarm +cd openswarm python -m uvicorn backend.main:app --host 0.0.0.0 --port 8324 --reload ``` diff --git a/README.md b/README.md index 3018550c..63e9ae49 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

License - Getting Started + Contributing Platform GitHub Stars PRs Welcome @@ -67,7 +67,7 @@ Running agents in a terminal works fine for one task. But when you're juggling f **Dark & Light Themes** — Full theme support with design tokens. -**Keyboard Shortcuts** — Navigate between agents, approve/deny requests, and switch pages without touching a mouse. +**Keyboard Shortcuts** — Navigate between agents, approve/deny requests, and switch pages. Press `?` in-app to see all shortcuts.
@@ -91,12 +91,7 @@ bash run/local.sh This starts the backend (port 8324), frontend (port 3000), and Electron shell together. Once running, set your Anthropic API key in the in-app Settings page. -To run services individually: - -```bash -bash backend/run.sh # API at http://localhost:8324 — docs at /docs -bash frontend/run.sh # App at http://localhost:3000 -``` +See the **[Contributing Guide](CONTRIBUTING.md)** for detailed setup options, environment configuration, Google Workspace integration, and troubleshooting.
@@ -121,93 +116,6 @@ Electron Shell (desktop wrapper, auto-updater)
-## Configuration - -The Anthropic API key is configured in-app via the **Settings** page — no environment variable needed for normal usage. - -For advanced configuration, copy `backend/.env.example` to `backend/.env`: - -| Variable | Purpose | -|----------|---------| -| `BACKEND_PORT` | Backend server port (default: `8324`) | -| `GOOGLE_OAUTH_CLIENT_ID` | Google Workspace integration (Gmail, Calendar, Drive) | -| `GOOGLE_OAUTH_CLIENT_SECRET` | Google Workspace integration | -| `APPLE_ID` | macOS code signing & notarization (release builds only) | -| `APPLE_APP_SPECIFIC_PASSWORD` | macOS notarization (release builds only) | -| `APPLE_TEAM_ID` | macOS code signing (release builds only) | -| `GH_TOKEN` | GitHub Releases publishing (release builds only) | - -
- -## Keyboard Shortcuts - -| Key | Action | -|-----|--------| -| `D` | Go to Dashboard | -| `T` | Go to Templates | -| `1` – `9` | Open agent by position | -| `Shift+A` | Approve all pending requests | -| `Shift+D` | Deny all pending requests | -| `?` | Show shortcuts help | - -Type `/` in the chat input to invoke prompt templates and skills as slash commands. - -
- -## Project Structure - -``` -backend/ - apps/ - agents/ Agent lifecycle, streaming, worktree management - dashboards/ Dashboard CRUD and layout persistence - dashboard_layout/ Card positions and spatial canvas state - templates/ Prompt template CRUD - skills/ Skills CRUD (synced to ~/.claude/skills/) - tools_lib/ MCP tool configuration and discovery - modes/ Agent mode definitions - outputs/ Views/outputs, vibe coding, Python executor - settings/ App settings and file browser - health/ Health check endpoint - mcp_registry/ MCP server registry proxy - skill_registry/ Anthropic skills marketplace proxy - config/ FastAPI app configuration - data/ Persistent JSON file storage - -frontend/ - src/ - app/ - components/ AppShell, Layout, shared UI - pages/ - Dashboard/ Spatial canvas with agent/view/browser cards - AgentChat/ Streaming chat, HITL approvals, branching, diff viewer - Templates/ Template library with structured input fields - Skills/ Skills library, skill builder, registry browser - Tools/ Tool config, MCP discovery, OAuth, registry browser - Modes/ Mode definitions with system prompts - Views/ Output artifacts, code editor, vibe coding - Commands/ Keyboard shortcuts reference - Settings/ App configuration - shared/ - state/ Redux slices (agents, dashboards, templates, skills, tools, modes, etc.) - ws/ WebSocket manager - hooks/ Custom hooks - styles/ Theme tokens, global styles - -electron/ - main.js Electron main process, auto-updater, Python env management - scripts/ Build and notarization scripts - -run/ - utils/ - build-app.sh Desktop app packaging (electron-builder) - build-python-env.sh Standalone Python 3.13 environment bundler - local.sh Start backend, frontend, and Electron shell - publish.sh Build and deploy to Firebase Hosting -``` - -
- ## Tech Stack **Frontend** — React 18, TypeScript, Redux Toolkit, Material UI v7, CodeMirror 6, Framer Motion, React Router v7, Webpack 5 @@ -222,14 +130,7 @@ run/ ## Contributing -Contributions are welcome. To get started: - -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/your-feature`) -3. Make your changes -4. Submit a pull request - -Please open an issue first for larger changes so we can discuss the approach. +Contributions are welcome — see **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full development setup, project structure, and contribution workflow.
diff --git a/backend/apps/agents/mcp_client.py b/backend/apps/agents/mcp_client.py deleted file mode 100644 index 1ad69aa4..00000000 --- a/backend/apps/agents/mcp_client.py +++ /dev/null @@ -1,346 +0,0 @@ -"""Standalone MCP client manager for agent sessions. - -Replaces claude_agent_sdk's internal MCP server management. -One MCPClientManager instance per agent session — manages connections -to stdio/http/sse MCP servers, discovers tools, and routes tool calls. -""" - -from __future__ import annotations - -import asyncio -import json -import logging -from contextlib import AsyncExitStack -from dataclasses import dataclass, field -from typing import Any - -from backend.apps.agents.providers.base import ToolSchema -from backend.apps.common.mcp_utils import parse_sse_json as _parse_sse_json - -logger = logging.getLogger(__name__) - - -@dataclass -class MCPConnection: - """A live connection to an MCP server.""" - server_name: str - session: Any # mcp.ClientSession - tools: list[ToolSchema] = field(default_factory=list) - - -class MCPClientManager: - """Manages connections to MCP servers for a single agent session.""" - - def __init__(self): - self._connections: dict[str, MCPConnection] = {} - self._exit_stack = AsyncExitStack() - self._started = False - - async def __aenter__(self): - await self._exit_stack.__aenter__() - self._started = True - return self - - async def __aexit__(self, *exc): - await self.disconnect_all() - try: - await self._exit_stack.__aexit__(*exc) - except (BaseExceptionGroup, ExceptionGroup, Exception) as e: - # MCP subprocess cleanup errors are non-fatal - logger.warning(f"MCP cleanup error (non-fatal): {e}") - self._started = False - - async def connect(self, server_name: str, config: dict, timeout: float = 30.0) -> list[ToolSchema]: - """Connect to an MCP server and return its available tools. - - The tools are returned with names prefixed as mcp____. - """ - transport = config.get("type", "stdio") - try: - if transport == "stdio": - coro = self._connect_stdio(server_name, config) - elif transport == "sse": - coro = self._connect_sse(server_name, config) - elif transport == "http": - coro = self._connect_http(server_name, config) - else: - logger.warning(f"Unsupported MCP transport: {transport} for {server_name}") - return [] - - conn = await asyncio.wait_for(coro, timeout=timeout) - self._connections[server_name] = conn - logger.info(f"MCP connected: {server_name} ({len(conn.tools)} tools)") - return conn.tools - - except asyncio.TimeoutError: - logger.warning(f"MCP server {server_name} connection timed out after {timeout}s") - return [] - except Exception as e: - logger.warning(f"Failed to connect MCP server {server_name}: {e}") - return [] - - async def _connect_stdio(self, server_name: str, config: dict) -> MCPConnection: - """Connect to a stdio MCP server (spawns a subprocess).""" - from mcp import ClientSession - from mcp.client.stdio import stdio_client, StdioServerParameters - - command = config.get("command", "") - args = config.get("args", []) - env = config.get("env") - - params = StdioServerParameters( - command=command, - args=args, - env=env, - ) - - transport = await self._exit_stack.enter_async_context( - stdio_client(params) - ) - read_stream, write_stream = transport - session = await self._exit_stack.enter_async_context( - ClientSession(read_stream, write_stream) - ) - await session.initialize() - - result = await session.list_tools() - tools = [ - ToolSchema( - name=f"mcp__{server_name}__{t.name}", - description=t.description or "", - input_schema=t.inputSchema if hasattr(t, "inputSchema") else (t.input_schema if hasattr(t, "input_schema") else {}), - ) - for t in result.tools - ] - - return MCPConnection(server_name=server_name, session=session, tools=tools) - - async def _connect_sse(self, server_name: str, config: dict) -> MCPConnection: - """Connect to an SSE MCP server.""" - from mcp import ClientSession - from mcp.client.sse import sse_client - - url = config.get("url", "") - headers = config.get("headers") - - transport = await self._exit_stack.enter_async_context( - sse_client(url=url, headers=headers, timeout=30, sse_read_timeout=300) - ) - read_stream, write_stream = transport - session = await self._exit_stack.enter_async_context( - ClientSession(read_stream, write_stream) - ) - await session.initialize() - - result = await session.list_tools() - tools = [ - ToolSchema( - name=f"mcp__{server_name}__{t.name}", - description=t.description or "", - input_schema=t.inputSchema if hasattr(t, "inputSchema") else (t.input_schema if hasattr(t, "input_schema") else {}), - ) - for t in result.tools - ] - - return MCPConnection(server_name=server_name, session=session, tools=tools) - - async def _connect_http(self, server_name: str, config: dict) -> MCPConnection: - """Connect to a Streamable HTTP MCP server. - - Falls back to SSE if streamable HTTP fails. - """ - url = config.get("url", "") - headers = config.get("headers") - - # Try streamable HTTP first, fall back to SSE - try: - return await self._connect_http_streamable(server_name, url, headers) - except Exception as e: - logger.info(f"Streamable HTTP failed for {server_name}, trying SSE: {e}") - return await self._connect_sse(server_name, config) - - async def _connect_http_streamable( - self, server_name: str, url: str, headers: dict | None, - ) -> MCPConnection: - """Connect via Streamable HTTP (JSON-RPC POST).""" - import httpx - from mcp import ClientSession - - # Use httpx for streamable HTTP — keep client alive in the exit stack - client = await self._exit_stack.enter_async_context( - httpx.AsyncClient(timeout=30.0) - ) - - h = { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - **(headers or {}), - } - - # Initialize - init_resp = await client.post(url, headers=h, json={ - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": {"name": "self-swarm", "version": "0.1.0"}, - }, - }) - if init_resp.status_code not in (200, 201): - raise ConnectionError(f"MCP initialize failed: {init_resp.status_code}") - - session_id = init_resp.headers.get("mcp-session-id", "") - if session_id: - h["mcp-session-id"] = session_id - - # Notify initialized - await client.post(url, headers=h, json={ - "jsonrpc": "2.0", "method": "notifications/initialized", - }) - - # List tools - list_resp = await client.post(url, headers=h, json={ - "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}, - }) - if list_resp.status_code not in (200, 201): - raise ConnectionError(f"MCP tools/list failed: {list_resp.status_code}") - - ct = list_resp.headers.get("content-type", "") - if "text/event-stream" in ct: - data = self._parse_sse_json(list_resp.text) - else: - data = list_resp.json() - - if not data: - raise ConnectionError("Empty response from MCP server") - - tools_list = data.get("result", {}).get("tools", []) - tools = [ - ToolSchema( - name=f"mcp__{server_name}__{t.get('name', '')}", - description=t.get("description", ""), - input_schema=t.get("inputSchema", t.get("input_schema", {})), - ) - for t in tools_list - ] - - # Store the HTTP client info for call_tool - conn = MCPConnection(server_name=server_name, session=None, tools=tools) - conn._http_client = client # type: ignore[attr-defined] - conn._http_url = url # type: ignore[attr-defined] - conn._http_headers = h # type: ignore[attr-defined] - conn._next_id = 3 # type: ignore[attr-defined] - return conn - - _parse_sse_json = staticmethod(_parse_sse_json) - - async def call_tool( - self, server_name: str, tool_name: str, arguments: dict, - ) -> list[dict]: - """Call a tool on a specific MCP server. - - Args: - server_name: The MCP server name (e.g. "google-workspace") - tool_name: The bare tool name (without mcp__prefix) - arguments: Tool input arguments - - Returns: - List of content blocks: [{"type": "text", "text": "..."}] - """ - conn = self._connections.get(server_name) - if not conn: - return [{"type": "text", "text": f"MCP server {server_name} not connected"}] - - try: - if conn.session is not None: - # stdio or SSE — use MCP ClientSession - result = await conn.session.call_tool(tool_name, arguments) - return self._format_mcp_result(result) - elif hasattr(conn, "_http_client"): - # Streamable HTTP — use JSON-RPC - return await self._call_tool_http(conn, tool_name, arguments) - else: - return [{"type": "text", "text": f"No session for MCP server {server_name}"}] - - except Exception as e: - logger.warning(f"MCP tool call failed: {server_name}/{tool_name}: {e}") - return [{"type": "text", "text": f"Error calling {tool_name}: {e}"}] - - async def _call_tool_http( - self, conn: MCPConnection, tool_name: str, arguments: dict, - ) -> list[dict]: - """Call a tool via Streamable HTTP.""" - client = conn._http_client # type: ignore[attr-defined] - url = conn._http_url # type: ignore[attr-defined] - headers = conn._http_headers # type: ignore[attr-defined] - req_id = conn._next_id # type: ignore[attr-defined] - conn._next_id = req_id + 1 # type: ignore[attr-defined] - - resp = await client.post(url, headers=headers, json={ - "jsonrpc": "2.0", - "id": req_id, - "method": "tools/call", - "params": {"name": tool_name, "arguments": arguments}, - }, timeout=300.0) - - ct = resp.headers.get("content-type", "") - if "text/event-stream" in ct: - data = self._parse_sse_json(resp.text) - else: - data = resp.json() - - if not data: - return [{"type": "text", "text": "Empty response from MCP server"}] - - if "error" in data: - return [{"type": "text", "text": f"MCP error: {data['error']}"}] - - result = data.get("result", {}) - content = result.get("content", []) - return content if content else [{"type": "text", "text": json.dumps(result)}] - - @staticmethod - def _format_mcp_result(result: Any) -> list[dict]: - """Convert an MCP CallToolResult to content blocks.""" - if hasattr(result, "content"): - blocks = [] - for item in result.content: - if hasattr(item, "text"): - blocks.append({"type": "text", "text": item.text}) - elif hasattr(item, "data"): - blocks.append({ - "type": "image", - "source": { - "type": "base64", - "media_type": getattr(item, "mimeType", "image/png"), - "data": item.data, - }, - }) - else: - blocks.append({"type": "text", "text": str(item)}) - return blocks if blocks else [{"type": "text", "text": "Done."}] - - return [{"type": "text", "text": str(result)}] - - def get_all_tool_schemas(self) -> list[ToolSchema]: - """Return tool schemas from all connected MCP servers.""" - schemas = [] - for conn in self._connections.values(): - schemas.extend(conn.tools) - return schemas - - def parse_mcp_tool_name(self, full_name: str) -> tuple[str, str] | None: - """Parse mcp____ into (server_name, tool_name). - - Returns None if the name doesn't match the MCP naming convention. - """ - import re - m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", full_name) - if m: - return m.group(1), m.group(2) - return None - - async def disconnect_all(self): - """Disconnect all MCP servers. Called on session end.""" - self._connections.clear() - # The AsyncExitStack handles actual cleanup of transports/sessions diff --git a/backend/apps/agents/providers/__init__.py b/backend/apps/agents/providers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/apps/agents/providers/anthropic.py b/backend/apps/agents/providers/anthropic.py deleted file mode 100644 index bd347ead..00000000 --- a/backend/apps/agents/providers/anthropic.py +++ /dev/null @@ -1,260 +0,0 @@ -"""Anthropic provider adapter using the native Anthropic SDK.""" - -from __future__ import annotations - -import json -import logging -from typing import Any, AsyncIterator - -import anthropic - -from backend.apps.agents.providers.base import ( - BaseProvider, ContentBlock, ModelResponse, ProviderMessage, - StreamEvent, ToolCall, ToolSchema, -) - -logger = logging.getLogger(__name__) - -MODEL_MAP = { - "sonnet": "claude-sonnet-4-6", - "opus": "claude-opus-4-6", - "haiku": "claude-haiku-4-5", -} - - -class AnthropicProvider(BaseProvider): - """Provider adapter for Anthropic's Messages API.""" - - def __init__( - self, - api_key: str | None = None, - auth_token: str | None = None, - base_url: str | None = None, - ): - kwargs: dict[str, Any] = {} - if auth_token: - kwargs["auth_token"] = auth_token - elif api_key: - kwargs["api_key"] = api_key - if base_url: - kwargs["base_url"] = base_url - self.client = anthropic.AsyncAnthropic(**kwargs) - - def get_model_id(self, short_name: str) -> str: - return MODEL_MAP.get(short_name, short_name) - - def clean_tool_schema(self, schema: ToolSchema) -> dict: - return { - "name": schema.name, - "description": schema.description, - "input_schema": schema.input_schema, - } - - def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict: - return { - "type": "tool_result", - "tool_use_id": tool_use_id, - "content": content, - } - - def format_user_message(self, content: Any) -> ProviderMessage: - return ProviderMessage(role="user", content=content) - - def format_assistant_message(self, response: ModelResponse) -> ProviderMessage: - blocks = [] - for block in response.content: - if block.type == "text": - blocks.append({"type": "text", "text": block.text}) - elif block.type == "tool_use" and block.tool_call: - blocks.append({ - "type": "tool_use", - "id": block.tool_call.id, - "name": block.tool_call.name, - "input": block.tool_call.input, - }) - return ProviderMessage(role="assistant", content=blocks) - - def _build_messages(self, messages: list[ProviderMessage]) -> list[dict]: - """Convert ProviderMessages to Anthropic API format.""" - result = [] - for msg in messages: - if msg.role == "tool_result": - # Tool results: content is a list of tool_result dicts - if isinstance(msg.content, list): - result.append({"role": "user", "content": msg.content}) - else: - result.append({"role": "user", "content": [msg.content]}) - elif msg.role == "assistant": - result.append({"role": "assistant", "content": msg.content}) - elif msg.role == "user": - result.append({"role": "user", "content": msg.content}) - return result - - async def create_message( - self, - model: str, - system: str | None, - messages: list[ProviderMessage], - tools: list[ToolSchema], - max_tokens: int = 8192, - ) -> ModelResponse: - kwargs: dict[str, Any] = { - "model": self.get_model_id(model), - "max_tokens": max_tokens, - "messages": self._build_messages(messages), - } - if system: - kwargs["system"] = system - if tools: - kwargs["tools"] = [self.clean_tool_schema(t) for t in tools] - - resp = await self.client.messages.create(**kwargs) - - content = [] - for block in resp.content: - if block.type == "text": - content.append(ContentBlock(type="text", text=block.text)) - elif block.type == "tool_use": - content.append(ContentBlock( - type="tool_use", - tool_call=ToolCall( - id=block.id, - name=block.name, - input=block.input, - ), - )) - - return ModelResponse( - content=content, - stop_reason="tool_use" if resp.stop_reason == "tool_use" else "end_turn", - usage={ - "input_tokens": resp.usage.input_tokens, - "output_tokens": resp.usage.output_tokens, - }, - ) - - async def stream_message( - self, - model: str, - system: str | None, - messages: list[ProviderMessage], - tools: list[ToolSchema], - max_tokens: int = 8192, - ) -> AsyncIterator[StreamEvent]: - kwargs: dict[str, Any] = { - "model": self.get_model_id(model), - "max_tokens": max_tokens, - "messages": self._build_messages(messages), - } - if system: - kwargs["system"] = system - if tools: - kwargs["tools"] = [self.clean_tool_schema(t) for t in tools] - - # Use create() with stream=True for raw SSE events - kwargs["stream"] = True - raw_stream = await self.client.messages.create(**kwargs) - - current_block_type: dict[int, str] = {} - current_tool_name: dict[int, str] = {} - current_tool_id: dict[int, str] = {} - current_text: dict[int, str] = {} - current_json: dict[int, str] = {} - - async for event in raw_stream: - event_type = getattr(event, "type", "") - - if event_type == "content_block_start": - index = event.index - block = event.content_block - block_type = block.type - current_block_type[index] = block_type - - if block_type == "text": - current_text[index] = "" - yield StreamEvent( - type="content_block_start", - index=index, - block_type="text", - ) - elif block_type == "tool_use": - current_tool_name[index] = block.name - current_tool_id[index] = block.id - current_json[index] = "" - yield StreamEvent( - type="content_block_start", - index=index, - block_type="tool_use", - tool_name=block.name, - tool_id=block.id, - ) - - elif event_type == "content_block_delta": - index = event.index - delta = event.delta - delta_type = delta.type - - if delta_type == "text_delta": - current_text.setdefault(index, "") - current_text[index] += delta.text - yield StreamEvent( - type="content_block_delta", - index=index, - delta_type="text_delta", - text=delta.text, - ) - elif delta_type == "input_json_delta": - current_json.setdefault(index, "") - current_json[index] += delta.partial_json - yield StreamEvent( - type="content_block_delta", - index=index, - delta_type="input_json_delta", - text=delta.partial_json, - ) - - elif event_type == "content_block_stop": - yield StreamEvent(type="content_block_stop", index=event.index) - - elif event_type == "message_delta": - # Extract output token usage from the final delta - usage_data = {} - delta_usage = getattr(event, "usage", None) - if delta_usage: - output_tokens = getattr(delta_usage, "output_tokens", 0) - if output_tokens: - usage_data["output_tokens"] = output_tokens - if usage_data: - yield StreamEvent(type="usage", usage=usage_data) - - elif event_type == "message_start": - # Extract input token usage from the message start - msg = getattr(event, "message", None) - if msg: - msg_usage = getattr(msg, "usage", None) - if msg_usage: - usage_data = {} - input_tokens = getattr(msg_usage, "input_tokens", 0) - output_tokens = getattr(msg_usage, "output_tokens", 0) - if input_tokens: - usage_data["input_tokens"] = input_tokens - if output_tokens: - usage_data["output_tokens"] = output_tokens - if usage_data: - yield StreamEvent(type="usage", usage=usage_data) - - yield StreamEvent(type="message_stop") - - async def stream_and_collect( - self, - model: str, - system: str | None, - messages: list[ProviderMessage], - tools: list[ToolSchema], - max_tokens: int = 8192, - ) -> tuple[AsyncIterator[StreamEvent], ModelResponse]: - """Helper: stream events and also return the full collected response. - - Not used directly — the AgentLoop handles collection. - """ - raise NotImplementedError("Use stream_message() directly; AgentLoop collects.") diff --git a/backend/apps/agents/providers/base.py b/backend/apps/agents/providers/base.py deleted file mode 100644 index ef2f746e..00000000 --- a/backend/apps/agents/providers/base.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Provider-agnostic base classes for multi-model support. - -All provider adapters (Anthropic, OpenAI, Gemini, OpenAI-compatible) -implement BaseProvider, translating their native APIs into these -common data structures. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from typing import Any, AsyncIterator - - -@dataclass -class ToolSchema: - """Provider-agnostic tool definition.""" - name: str - description: str - input_schema: dict[str, Any] - - -@dataclass -class ToolCall: - """A tool invocation requested by the model.""" - id: str - name: str - input: dict[str, Any] - - -@dataclass -class ContentBlock: - """A block of content from the model response.""" - type: str # "text" | "tool_use" - text: str = "" - tool_call: ToolCall | None = None - - -@dataclass -class ModelResponse: - """Complete (non-streaming) response from a provider.""" - content: list[ContentBlock] - stop_reason: str # "end_turn" | "tool_use" | "max_tokens" - usage: dict[str, int] = field(default_factory=dict) - - -@dataclass -class StreamEvent: - """A single streaming event, normalized across providers. - - The event types match what the frontend already expects via WebSocket: - content_block_start, content_block_delta, content_block_stop, message_stop. - """ - type: str - index: int = 0 - block_type: str = "" # "text" | "tool_use" - delta_type: str = "" # "text_delta" | "input_json_delta" - text: str = "" - tool_name: str = "" - tool_id: str = "" - usage: dict[str, int] = field(default_factory=dict) - - -@dataclass -class ProviderMessage: - """Provider-agnostic message for conversation history. - - Each provider adapter converts these to/from its native format. - """ - role: str # "user" | "assistant" | "tool_result" - content: Any # str, list[dict], or provider-specific content - - -class BaseProvider(ABC): - """Abstract base for LLM provider adapters.""" - - @abstractmethod - async def stream_message( - self, - model: str, - system: str | None, - messages: list[ProviderMessage], - tools: list[ToolSchema], - max_tokens: int = 8192, - ) -> AsyncIterator[StreamEvent]: - """Stream a model response, yielding normalized StreamEvents.""" - ... - - @abstractmethod - async def create_message( - self, - model: str, - system: str | None, - messages: list[ProviderMessage], - tools: list[ToolSchema], - max_tokens: int = 8192, - ) -> ModelResponse: - """Non-streaming message creation.""" - ... - - @abstractmethod - def format_tool_result( - self, - tool_use_id: str, - content: list[dict], - ) -> dict: - """Format a tool result in this provider's expected message format.""" - ... - - @abstractmethod - def format_user_message(self, content: Any) -> ProviderMessage: - """Wrap user content (str or multimodal blocks) into a ProviderMessage.""" - ... - - @abstractmethod - def format_assistant_message(self, response: ModelResponse) -> ProviderMessage: - """Convert a ModelResponse into a ProviderMessage for conversation history.""" - ... - - @abstractmethod - def get_model_id(self, short_name: str) -> str: - """Resolve a short model name to the full API model ID.""" - ... - - def clean_tool_schema(self, schema: ToolSchema) -> dict: - """Convert a ToolSchema to the provider's native tool format. - - Default: Anthropic-style format. Override for providers that need - different formats or schema cleaning (e.g. Gemini). - """ - return { - "name": schema.name, - "description": schema.description, - "input_schema": schema.input_schema, - } diff --git a/backend/apps/agents/providers/openai_compat.py b/backend/apps/agents/providers/openai_compat.py deleted file mode 100644 index bdc4a3b6..00000000 --- a/backend/apps/agents/providers/openai_compat.py +++ /dev/null @@ -1,330 +0,0 @@ -"""OpenAI-compatible provider adapter. - -Works with ANY endpoint that speaks the OpenAI Chat Completions API: -OpenAI, OpenRouter, Together, Groq, Fireworks, Mistral, Ollama, vLLM, etc. -""" - -from __future__ import annotations - -import json -import logging -from typing import Any, AsyncIterator -from uuid import uuid4 - -from openai import AsyncOpenAI - -from backend.apps.agents.providers.base import ( - BaseProvider, ContentBlock, ModelResponse, ProviderMessage, - StreamEvent, ToolCall, ToolSchema, -) - -logger = logging.getLogger(__name__) - - -class OpenAICompatProvider(BaseProvider): - """Provider adapter for any OpenAI-compatible API endpoint.""" - - def __init__( - self, - api_key: str = "", - base_url: str | None = None, - ): - kwargs: dict[str, Any] = {} - # Always set api_key — use "none" as placeholder if empty (some endpoints don't need real keys) - kwargs["api_key"] = api_key if api_key else "none" - if base_url: - kwargs["base_url"] = base_url - self.client = AsyncOpenAI(**kwargs) - - def get_model_id(self, short_name: str) -> str: - # Pass through — user selects exact model ID - return short_name - - def clean_tool_schema(self, schema: ToolSchema) -> dict: - """Convert to OpenAI function calling format.""" - return { - "type": "function", - "function": { - "name": schema.name, - "description": schema.description, - "parameters": schema.input_schema, - }, - } - - def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict: - """Format tool result as OpenAI expects.""" - # OpenAI wants a single string for tool results - text_parts = [] - for block in content: - if block.get("type") == "text": - text_parts.append(block.get("text", "")) - elif block.get("type") == "image": - text_parts.append("[image]") - else: - text_parts.append(json.dumps(block)) - return { - "role": "tool", - "tool_call_id": tool_use_id, - "content": "\n".join(text_parts) if text_parts else "Done.", - } - - def format_user_message(self, content: Any) -> ProviderMessage: - """Convert user content to OpenAI format.""" - if isinstance(content, str): - return ProviderMessage(role="user", content=content) - # Multimodal content (text + images) - if isinstance(content, list): - parts = [] - for block in content: - if isinstance(block, dict): - if block.get("type") == "text": - parts.append({"type": "text", "text": block["text"]}) - elif block.get("type") == "image": - source = block.get("source", {}) - media_type = source.get("media_type", "image/png") - data = source.get("data", "") - parts.append({ - "type": "image_url", - "image_url": {"url": f"data:{media_type};base64,{data}"}, - }) - elif isinstance(block, str): - parts.append({"type": "text", "text": block}) - return ProviderMessage(role="user", content=parts) - return ProviderMessage(role="user", content=str(content)) - - def format_assistant_message(self, response: ModelResponse) -> ProviderMessage: - """Convert ModelResponse to OpenAI assistant message format.""" - text_parts = [] - tool_calls = [] - for block in response.content: - if block.type == "text": - text_parts.append(block.text) - elif block.type == "tool_use" and block.tool_call: - tool_calls.append({ - "id": block.tool_call.id, - "type": "function", - "function": { - "name": block.tool_call.name, - "arguments": json.dumps(block.tool_call.input), - }, - }) - msg: dict[str, Any] = {"role": "assistant"} - if text_parts: - msg["content"] = "\n".join(text_parts) - else: - msg["content"] = None - if tool_calls: - msg["tool_calls"] = tool_calls - return ProviderMessage(role="assistant", content=msg) - - def _build_messages( - self, - system: str | None, - messages: list[ProviderMessage], - ) -> list[dict]: - """Convert ProviderMessages to OpenAI API format.""" - result = [] - if system: - result.append({"role": "system", "content": system}) - - for msg in messages: - if msg.role == "assistant": - # Assistant messages are already in OpenAI format from format_assistant_message - if isinstance(msg.content, dict) and "role" in msg.content: - result.append(msg.content) - else: - # Raw content blocks from provider-agnostic format - text_parts = [] - tool_calls = [] - if isinstance(msg.content, list): - for block in msg.content: - if isinstance(block, dict): - if block.get("type") == "text": - text_parts.append(block["text"]) - elif block.get("type") == "tool_use": - tool_calls.append({ - "id": block.get("id", uuid4().hex), - "type": "function", - "function": { - "name": block.get("name", ""), - "arguments": json.dumps(block.get("input", {})), - }, - }) - api_msg: dict[str, Any] = { - "role": "assistant", - "content": "\n".join(text_parts) if text_parts else None, - } - if tool_calls: - api_msg["tool_calls"] = tool_calls - result.append(api_msg) - - elif msg.role == "tool_result": - # Tool results: content is a list of tool result dicts - if isinstance(msg.content, list): - for tr in msg.content: - if isinstance(tr, dict) and "tool_call_id" in tr: - result.append(tr) - elif isinstance(msg.content, dict) and "tool_call_id" in msg.content: - result.append(msg.content) - - elif msg.role == "user": - result.append({"role": "user", "content": msg.content}) - - return result - - async def create_message( - self, - model: str, - system: str | None, - messages: list[ProviderMessage], - tools: list[ToolSchema], - max_tokens: int = 8192, - ) -> ModelResponse: - kwargs: dict[str, Any] = { - "model": self.get_model_id(model), - "max_tokens": max_tokens, - "messages": self._build_messages(system, messages), - } - if tools: - kwargs["tools"] = [self.clean_tool_schema(t) for t in tools] - - resp = await self.client.chat.completions.create(**kwargs) - choice = resp.choices[0] - message = choice.message - - content: list[ContentBlock] = [] - if message.content: - content.append(ContentBlock(type="text", text=message.content)) - - if message.tool_calls: - for tc in message.tool_calls: - try: - args = json.loads(tc.function.arguments) - except json.JSONDecodeError: - args = {} - content.append(ContentBlock( - type="tool_use", - tool_call=ToolCall( - id=tc.id, - name=tc.function.name, - input=args, - ), - )) - - stop = "end_turn" - if choice.finish_reason == "tool_calls": - stop = "tool_use" - elif message.tool_calls: - stop = "tool_use" - - usage_dict = {} - if resp.usage: - usage_dict = { - "input_tokens": resp.usage.prompt_tokens, - "output_tokens": resp.usage.completion_tokens, - } - - return ModelResponse(content=content, stop_reason=stop, usage=usage_dict) - - async def stream_message( - self, - model: str, - system: str | None, - messages: list[ProviderMessage], - tools: list[ToolSchema], - max_tokens: int = 8192, - ) -> AsyncIterator[StreamEvent]: - kwargs: dict[str, Any] = { - "model": self.get_model_id(model), - "max_tokens": max_tokens, - "messages": self._build_messages(system, messages), - "stream": True, - "stream_options": {"include_usage": True}, - } - if tools: - kwargs["tools"] = [self.clean_tool_schema(t) for t in tools] - - stream = await self.client.chat.completions.create(**kwargs) - - # Track streaming state to emit normalized events - text_started = False - text_index = 0 - tool_indices: dict[int, dict] = {} # openai tool_call index -> {name, id, json_buf} - next_block_index = 0 - - async for chunk in stream: - if not chunk.choices: - # Usage-only chunk at the end - if chunk.usage: - yield StreamEvent(type="usage", usage={ - "input_tokens": chunk.usage.prompt_tokens or 0, - "output_tokens": chunk.usage.completion_tokens or 0, - }) - continue - - delta = chunk.choices[0].delta - finish_reason = chunk.choices[0].finish_reason - - # Text content - if delta.content is not None: - if not text_started: - text_started = True - text_index = next_block_index - next_block_index += 1 - yield StreamEvent( - type="content_block_start", - index=text_index, - block_type="text", - ) - yield StreamEvent( - type="content_block_delta", - index=text_index, - delta_type="text_delta", - text=delta.content, - ) - - # Tool calls - if delta.tool_calls: - for tc_delta in delta.tool_calls: - tc_idx = tc_delta.index - if tc_idx not in tool_indices: - # New tool call starting - if text_started: - yield StreamEvent(type="content_block_stop", index=text_index) - text_started = False - - block_idx = next_block_index - next_block_index += 1 - tool_indices[tc_idx] = { - "block_index": block_idx, - "id": tc_delta.id or uuid4().hex, - "name": tc_delta.function.name if tc_delta.function else "", - "json_buf": "", - } - yield StreamEvent( - type="content_block_start", - index=block_idx, - block_type="tool_use", - tool_name=tool_indices[tc_idx]["name"], - tool_id=tool_indices[tc_idx]["id"], - ) - - info = tool_indices[tc_idx] - if tc_delta.function and tc_delta.function.name: - info["name"] = tc_delta.function.name - if tc_delta.function and tc_delta.function.arguments: - info["json_buf"] += tc_delta.function.arguments - yield StreamEvent( - type="content_block_delta", - index=info["block_index"], - delta_type="input_json_delta", - text=tc_delta.function.arguments, - ) - - # Finish - if finish_reason is not None: - if text_started: - yield StreamEvent(type="content_block_stop", index=text_index) - for info in tool_indices.values(): - yield StreamEvent(type="content_block_stop", index=info["block_index"]) - yield StreamEvent(type="message_stop") diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py deleted file mode 100644 index 09e38010..00000000 --- a/backend/apps/agents/providers/registry.py +++ /dev/null @@ -1,277 +0,0 @@ -"""Provider factory and model registry. - -Two-tier system: -1. Built-in providers (Anthropic, OpenAI, Gemini) with curated model lists -2. User-configured custom providers (any OpenAI-compatible endpoint) - - Includes built-in OpenRouter integration for 300+ models -""" - -from __future__ import annotations - -import logging -from typing import Any, TYPE_CHECKING - -from backend.apps.agents.providers.base import BaseProvider -from backend.apps.common.model_registry import ( - get_builtin_models_by_provider, - get_context_window as _registry_get_context_window, - calculate_cost as _registry_calculate_cost, -) - -if TYPE_CHECKING: - from backend.apps.settings.models import AppSettings - -logger = logging.getLogger(__name__) - -BUILTIN_MODELS = get_builtin_models_by_provider() - -# --------------------------------------------------------------------------- -# OpenRouter: built-in integration for 300+ models -# --------------------------------------------------------------------------- - -OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" - -_9router_cache: dict = {"available": None, "checked_at": 0} - - -def _is_9router_available() -> bool: - """Check if 9Router is running on localhost:20128. Caches for 30 seconds.""" - import time as _time - now = _time.time() - if _9router_cache["available"] is not None and now - _9router_cache["checked_at"] < 30: - return _9router_cache["available"] - try: - import httpx - r = httpx.get("http://localhost:20128/v1/models", timeout=2.0) - available = r.status_code == 200 - except Exception: - available = False - _9router_cache["available"] = available - _9router_cache["checked_at"] = now - return available - - -# --------------------------------------------------------------------------- -# Provider factory -# --------------------------------------------------------------------------- - -def create_provider( - provider_name: str, - settings: AppSettings, - provider_config: dict | None = None, -) -> BaseProvider: - """Create a provider adapter. - - Routes based on the 'api' field in BUILTIN_MODELS: - - "anthropic" → native Anthropic SDK - - "openai" → native OpenAI SDK (direct API) - - "gemini" → native Google GenAI SDK - - "openrouter" → OpenAI-compat via openrouter.ai (Meta, Mistral, DeepSeek, Qwen, xAI, etc.) - Custom providers use OpenAI-compat with user's base_url. - """ - api_type = _get_api_type(provider_name) - - # Check for 9Router first - if provider_name in ("9Router", "9router"): - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1") - - # Check for GitHub Copilot - if provider_name in ("GitHub Copilot", "copilot"): - from backend.apps.agents.providers.copilot import CopilotProvider - copilot_token = getattr(settings, "copilot_token", None) - if not copilot_token: - raise ValueError("GitHub Copilot not connected. Sign in via Settings → Models.") - # Auto-refresh if expired - import time as _time - expires = getattr(settings, "copilot_token_expires", None) - if expires and _time.time() > expires - 120: - github_token = getattr(settings, "copilot_github_token", None) - if github_token: - import asyncio - from backend.apps.agents.copilot_auth import exchange_for_copilot_token - try: - loop = asyncio.get_event_loop() - result = loop.run_until_complete(exchange_for_copilot_token(github_token)) - copilot_token = result["token"] - settings.copilot_token = copilot_token - settings.copilot_token_expires = result["expires_at"] - from backend.apps.settings.settings import _save_settings - _save_settings(settings) - except Exception as e: - logger.warning(f"Copilot token refresh failed: {e}") - return CopilotProvider(copilot_token=copilot_token) - - if api_type == "anthropic": - from backend.apps.agents.providers.anthropic import AnthropicProvider - if getattr(settings, "connection_mode", "own_key") == "managed": - return AnthropicProvider( - auth_token=getattr(settings, "openswarm_auth_token", None), - base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.ai", - ) - # Priority: API key → 9Router subscription - if settings.anthropic_api_key: - return AnthropicProvider(api_key=settings.anthropic_api_key) - # No API key — try 9Router as fallback - if _is_9router_available(): - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - provider = OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1") - # Override get_model_id to map our short names to 9Router's cc/ prefixed IDs - _original_get_model = provider.get_model_id - _9r_model_map = { - "sonnet": "cc/claude-sonnet-4-6", - "opus": "cc/claude-opus-4-6", - "haiku": "cc/claude-haiku-4-5-20251001", - } - provider.get_model_id = lambda name: _9r_model_map.get(name, f"cc/{name}" if not name.startswith("cc/") else name) - return provider - raise ValueError("Anthropic API key not configured. Set it in Settings, or connect 9Router.") - - if api_type == "openai": - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - if settings.openai_api_key: - return OpenAICompatProvider(api_key=settings.openai_api_key, base_url="https://api.openai.com/v1") - # No API key — try 9Router as fallback - if _is_9router_available(): - return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1") - raise ValueError("OpenAI API key not configured. Set it in Settings, or connect 9Router.") - - if api_type == "gemini": - from backend.apps.agents.providers.gemini import GeminiProvider - if settings.google_api_key: - return GeminiProvider(api_key=settings.google_api_key) - # No API key — try 9Router as fallback - if _is_9router_available(): - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1") - raise ValueError("Google API key not configured. Set it in Settings, or connect 9Router.") - - if api_type == "openrouter": - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - openrouter_key = getattr(settings, "openrouter_api_key", None) - if openrouter_key: - return OpenAICompatProvider(api_key=openrouter_key, base_url=OPENROUTER_BASE_URL) - # No OpenRouter key — try 9Router as fallback - if _is_9router_available(): - return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1") - raise ValueError(f"OpenRouter API key not configured for {provider_name}. Set it in Settings, or connect a subscription.") - - # Custom provider — look up in settings.custom_providers - if provider_config: - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - return OpenAICompatProvider( - api_key=provider_config.get("api_key", ""), - base_url=provider_config.get("base_url", ""), - ) - - for cp in getattr(settings, "custom_providers", []): - if cp.name == provider_name: - from backend.apps.agents.providers.openai_compat import OpenAICompatProvider - return OpenAICompatProvider( - api_key=cp.api_key, - base_url=cp.base_url, - ) - - raise ValueError(f"Unknown provider: {provider_name}") - - -def _get_api_type(provider_name: str) -> str: - """Get the API type for a provider from BUILTIN_MODELS. - - Accepts both display names ('Anthropic') and lowercase API names ('anthropic'). - """ - # Direct lookup first (display name like 'Anthropic', 'OpenAI', etc.) - models = BUILTIN_MODELS.get(provider_name, []) - if models: - return models[0].get("api", "openrouter") - - # Lowercase API name mapping - _API_NAME_MAP = { - "anthropic": "anthropic", - "openai": "openai", - "gemini": "gemini", - "google": "gemini", - "openrouter": "openrouter", - } - if provider_name.lower() in _API_NAME_MAP: - return _API_NAME_MAP[provider_name.lower()] - - # Case-insensitive lookup into BUILTIN_MODELS - lower = provider_name.lower() - for key, models in BUILTIN_MODELS.items(): - if key.lower() == lower: - return models[0].get("api", "openrouter") - - return "openrouter" - - -def _has_credentials(provider_name: str, settings: AppSettings) -> bool: - """Check if a provider has credentials configured.""" - api_type = _get_api_type(provider_name) - - if api_type == "anthropic": - if getattr(settings, "connection_mode", "own_key") == "managed": - return bool(getattr(settings, "openswarm_auth_token", None)) - return bool(settings.anthropic_api_key) - if api_type == "openai": - return bool(settings.openai_api_key) - if api_type == "gemini": - return bool(getattr(settings, "google_api_key", None)) - if api_type == "openrouter": - return bool(getattr(settings, "openrouter_api_key", None)) - return False - - -def get_available_models(settings: AppSettings) -> dict[str, list[dict]]: - """Return all models — always show everything, mark which have keys configured. - - Like Cursor: show all models upfront, prompt for key when user tries to use one. - Returns: {"provider_name": [{"value": ..., "label": ..., "context_window": ..., "configured": bool}, ...]} - """ - result: dict[str, list[dict]] = {} - - # Built-in providers — always show all - for provider_name, models in BUILTIN_MODELS.items(): - configured = _has_credentials(provider_name, settings) - result[provider_name] = [ - {**m, "configured": configured} - for m in models - ] - - # Custom providers - for cp in getattr(settings, "custom_providers", []): - if cp.models: - result[cp.name] = [ - { - "value": m.get("value", m.get("id", "")), - "label": m.get("label", m.get("value", m.get("id", ""))), - "context_window": m.get("context_window", 128_000), - "configured": True, - } - for m in cp.models - ] - - return result - - -def get_context_window(provider: str, model: str, settings: AppSettings | None = None) -> int: - """Look up context window for any model.""" - result = _registry_get_context_window(model) - if result != 128_000: - return result - - if settings: - for cp in getattr(settings, "custom_providers", []): - for m in cp.models: - if m.get("value") == model or m.get("id") == model: - return m.get("context_window", 128_000) - - return 128_000 - - -def calculate_cost( - provider: str, model: str, - input_tokens: int, output_tokens: int, -) -> float: - """Calculate cost in USD from token counts.""" - return _registry_calculate_cost(provider, model, input_tokens, output_tokens) diff --git a/backend/apps/agents/tools/__init__.py b/backend/apps/agents/tools/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/apps/agents/tools/base.py b/backend/apps/agents/tools/base.py deleted file mode 100644 index 6190a170..00000000 --- a/backend/apps/agents/tools/base.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Base classes for builtin tool implementations.""" - -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Any - - -@dataclass -class ToolContext: - """Runtime context passed to every tool execution.""" - cwd: str - session_id: str - - -class BaseTool(ABC): - """Abstract base for all builtin tools. - - Subclasses must set ``name`` and ``description`` as class attributes and - implement ``get_schema`` (JSON Schema for tool input) and ``execute``. - """ - - name: str - description: str - - @abstractmethod - def get_schema(self) -> dict: - """Return JSON Schema for this tool's input parameters.""" - ... - - @abstractmethod - async def execute(self, input_data: dict, context: ToolContext) -> list[dict]: - """Execute the tool. - - Returns a list of content blocks, e.g. - ``[{"type": "text", "text": "..."}]``. - """ - ... - - def to_tool_schema(self): - """Convert to the provider-agnostic ``ToolSchema`` used everywhere.""" - from backend.apps.agents.providers.base import ToolSchema - - return ToolSchema( - name=self.name, - description=self.description, - input_schema=self.get_schema(), - ) diff --git a/backend/apps/agents/tools/filesystem.py b/backend/apps/agents/tools/filesystem.py deleted file mode 100644 index 5ea3c253..00000000 --- a/backend/apps/agents/tools/filesystem.py +++ /dev/null @@ -1,476 +0,0 @@ -"""Filesystem tools: Read, Write, Edit, Glob, Grep.""" - -from __future__ import annotations - -import asyncio -import base64 -import mimetypes -import os -import re -from pathlib import Path -from typing import Any - -from backend.apps.agents.tools.base import BaseTool, ToolContext - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"} -_MAX_OUTPUT_BYTES = 50 * 1024 # ~50 KB cap for grep output - - -def _resolve(file_path: str, cwd: str) -> Path: - """Resolve *file_path* against *cwd* when it is relative.""" - p = Path(file_path) - if not p.is_absolute(): - p = Path(cwd) / p - return p.resolve() - - -def _text_block(text: str) -> list[dict]: - return [{"type": "text", "text": text}] - - -# ─────────────────────────────────────────────────────────────────────────── -# ReadTool -# ─────────────────────────────────────────────────────────────────────────── - - -class ReadTool(BaseTool): - name = "Read" - description = ( - "Read a file from the filesystem. Returns lines with line numbers " - "(cat -n style). For image files returns base64 content. Supports " - "offset and limit parameters for reading portions of large files." - ) - - def get_schema(self) -> dict: - return { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Absolute or relative path to the file to read.", - }, - "offset": { - "type": "integer", - "description": "1-based line number to start reading from.", - }, - "limit": { - "type": "integer", - "description": "Maximum number of lines to return (default 2000).", - }, - }, - "required": ["file_path"], - "additionalProperties": False, - } - - async def execute(self, input_data: dict, context: ToolContext) -> list[dict]: - file_path = _resolve(input_data["file_path"], context.cwd) - - if not file_path.exists(): - return _text_block(f"Error: file not found: {file_path}") - - if not file_path.is_file(): - return _text_block(f"Error: not a regular file: {file_path}") - - # Binary / image files → base64 - ext = file_path.suffix.lower() - if ext in _IMAGE_EXTENSIONS: - try: - raw = file_path.read_bytes() - b64 = base64.b64encode(raw).decode("ascii") - media = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" - return [ - { - "type": "image", - "source": { - "type": "base64", - "media_type": media, - "data": b64, - }, - } - ] - except Exception as exc: - return _text_block(f"Error reading image {file_path}: {exc}") - - # Text files - offset = max(input_data.get("offset", 1), 1) - limit = input_data.get("limit", 2000) - if limit <= 0: - limit = 2000 - - try: - with open(file_path, "r", errors="replace") as fh: - lines: list[str] = [] - for lineno, line in enumerate(fh, start=1): - if lineno < offset: - continue - if len(lines) >= limit: - break - # cat -n style: right-justified line number + tab + content - lines.append(f"{lineno:>6}\t{line.rstrip()}") - if not lines: - return _text_block(f"(file is empty or offset beyond end of file: {file_path})") - return _text_block("\n".join(lines)) - except Exception as exc: - return _text_block(f"Error reading {file_path}: {exc}") - - -# ─────────────────────────────────────────────────────────────────────────── -# WriteTool -# ─────────────────────────────────────────────────────────────────────────── - - -class WriteTool(BaseTool): - name = "Write" - description = ( - "Write content to a file. Creates parent directories if they do not " - "exist. Overwrites the file if it already exists." - ) - - def get_schema(self) -> dict: - return { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Absolute or relative path to the file to write.", - }, - "content": { - "type": "string", - "description": "The full content to write to the file.", - }, - }, - "required": ["file_path", "content"], - "additionalProperties": False, - } - - async def execute(self, input_data: dict, context: ToolContext) -> list[dict]: - file_path = _resolve(input_data["file_path"], context.cwd) - content: str = input_data["content"] - - try: - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(content, encoding="utf-8") - return _text_block(f"Successfully wrote {len(content)} bytes to {file_path}") - except Exception as exc: - return _text_block(f"Error writing {file_path}: {exc}") - - -# ─────────────────────────────────────────────────────────────────────────── -# EditTool -# ─────────────────────────────────────────────────────────────────────────── - - -class EditTool(BaseTool): - name = "Edit" - description = ( - "Perform exact string replacements in a file. By default the " - "old_string must appear exactly once (not unique → error). Pass " - "replace_all=true to replace every occurrence." - ) - - def get_schema(self) -> dict: - return { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Absolute or relative path to the file to edit.", - }, - "old_string": { - "type": "string", - "description": "The exact text to find in the file.", - }, - "new_string": { - "type": "string", - "description": "The text to replace old_string with.", - }, - "replace_all": { - "type": "boolean", - "description": "If true, replace all occurrences. Default false.", - "default": False, - }, - }, - "required": ["file_path", "old_string", "new_string"], - "additionalProperties": False, - } - - async def execute(self, input_data: dict, context: ToolContext) -> list[dict]: - file_path = _resolve(input_data["file_path"], context.cwd) - old_string: str = input_data["old_string"] - new_string: str = input_data["new_string"] - replace_all: bool = input_data.get("replace_all", False) - - if not file_path.exists(): - return _text_block(f"Error: file not found: {file_path}") - if not file_path.is_file(): - return _text_block(f"Error: not a regular file: {file_path}") - - try: - content = file_path.read_text(encoding="utf-8") - except Exception as exc: - return _text_block(f"Error reading {file_path}: {exc}") - - count = content.count(old_string) - if count == 0: - return _text_block( - f"Error: old_string not found in {file_path}. " - "Make sure the string matches exactly, including whitespace and indentation." - ) - - if not replace_all and count > 1: - return _text_block( - f"Error: old_string appears {count} times in {file_path}. " - "Provide more surrounding context to make the match unique, " - "or set replace_all=true to replace every occurrence." - ) - - if replace_all: - new_content = content.replace(old_string, new_string) - else: - # Replace only the first (and only) occurrence - new_content = content.replace(old_string, new_string, 1) - - try: - file_path.write_text(new_content, encoding="utf-8") - except Exception as exc: - return _text_block(f"Error writing {file_path}: {exc}") - - replacements = count if replace_all else 1 - return _text_block( - f"Successfully edited {file_path} ({replacements} replacement{'s' if replacements != 1 else ''})." - ) - - -# ─────────────────────────────────────────────────────────────────────────── -# GlobTool -# ─────────────────────────────────────────────────────────────────────────── - - -class GlobTool(BaseTool): - name = "Glob" - description = ( - "Fast file pattern matching. Supports glob patterns like '**/*.py'. " - "Returns matching file paths sorted by modification time (newest first)." - ) - - def get_schema(self) -> dict: - return { - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Glob pattern to match files (e.g. '**/*.py', 'src/**/*.ts').", - }, - "path": { - "type": "string", - "description": "Directory to search in. Defaults to the working directory.", - }, - }, - "required": ["pattern"], - "additionalProperties": False, - } - - async def execute(self, input_data: dict, context: ToolContext) -> list[dict]: - pattern: str = input_data["pattern"] - base = Path(input_data.get("path") or context.cwd) - - if not base.is_dir(): - return _text_block(f"Error: directory not found: {base}") - - try: - matches: list[Path] = [] - for p in base.glob(pattern): - if p.is_file(): - matches.append(p) - if len(matches) >= 500: - break - - # Sort by modification time, newest first - matches.sort(key=lambda p: p.stat().st_mtime, reverse=True) - - if not matches: - return _text_block(f"No files matched pattern '{pattern}' in {base}") - - result = "\n".join(str(p) for p in matches) - return _text_block(result) - except Exception as exc: - return _text_block(f"Error during glob '{pattern}' in {base}: {exc}") - - -# ─────────────────────────────────────────────────────────────────────────── -# GrepTool -# ─────────────────────────────────────────────────────────────────────────── - - -class GrepTool(BaseTool): - name = "Grep" - description = ( - "Search file contents using regular expressions. Uses ripgrep (rg) " - "when available, otherwise falls back to Python's re module. " - "Supports output modes: files_with_matches, content, count." - ) - - def get_schema(self) -> dict: - return { - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Regular expression pattern to search for.", - }, - "path": { - "type": "string", - "description": "File or directory to search in. Defaults to the working directory.", - }, - "glob": { - "type": "string", - "description": "Glob pattern to filter files (e.g. '*.py', '*.{ts,tsx}').", - }, - "output_mode": { - "type": "string", - "enum": ["files_with_matches", "content", "count"], - "description": "Output mode. Default: files_with_matches.", - "default": "files_with_matches", - }, - }, - "required": ["pattern"], - "additionalProperties": False, - } - - async def execute(self, input_data: dict, context: ToolContext) -> list[dict]: - pattern: str = input_data["pattern"] - search_path: str = input_data.get("path") or context.cwd - file_glob: str | None = input_data.get("glob") - output_mode: str = input_data.get("output_mode", "files_with_matches") - - # Try ripgrep first - try: - result = await self._run_rg(pattern, search_path, file_glob, output_mode) - if result is not None: - return result - except FileNotFoundError: - pass # rg not installed, fall through to Python fallback - - # Python fallback - return await self._python_grep(pattern, search_path, file_glob, output_mode) - - async def _run_rg( - self, - pattern: str, - search_path: str, - file_glob: str | None, - output_mode: str, - ) -> list[dict] | None: - """Run ripgrep and return results, or None if rg is not available.""" - cmd = ["rg", "--no-heading", "--color=never"] - - if output_mode == "files_with_matches": - cmd.append("--files-with-matches") - elif output_mode == "count": - cmd.append("--count") - else: - cmd.extend(["--line-number"]) - - if file_glob: - cmd.extend(["--glob", file_glob]) - - cmd.append(pattern) - cmd.append(search_path) - - try: - proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) - except FileNotFoundError: - raise # re-raise so caller knows rg is missing - except asyncio.TimeoutError: - return _text_block("Error: grep timed out after 30 seconds.") - except Exception as exc: - return _text_block(f"Error running ripgrep: {exc}") - - output = stdout.decode("utf-8", errors="replace") - - if proc.returncode not in (0, 1): - err = stderr.decode("utf-8", errors="replace").strip() - if err: - return _text_block(f"Grep error: {err}") - - if not output.strip(): - return _text_block(f"No matches found for pattern '{pattern}'.") - - # Truncate if too large - if len(output) > _MAX_OUTPUT_BYTES: - output = output[:_MAX_OUTPUT_BYTES] + "\n... (output truncated)" - - return _text_block(output.rstrip()) - - async def _python_grep( - self, - pattern: str, - search_path: str, - file_glob: str | None, - output_mode: str, - ) -> list[dict]: - """Pure-Python grep fallback using the re module.""" - try: - regex = re.compile(pattern) - except re.error as exc: - return _text_block(f"Invalid regex pattern: {exc}") - - base = Path(search_path) - if base.is_file(): - files = [base] - elif base.is_dir(): - glob_pat = file_glob or "**/*" - files = [p for p in base.glob(glob_pat) if p.is_file()] - else: - return _text_block(f"Error: path not found: {search_path}") - - lines_out: list[str] = [] - total_bytes = 0 - truncated = False - - for fp in sorted(files): - try: - text = fp.read_text(encoding="utf-8", errors="replace") - except Exception: - continue - - file_matches: list[tuple[int, str]] = [] - for lineno, line in enumerate(text.splitlines(), start=1): - if regex.search(line): - file_matches.append((lineno, line)) - - if not file_matches: - continue - - if output_mode == "files_with_matches": - entry = str(fp) - elif output_mode == "count": - entry = f"{fp}:{len(file_matches)}" - else: - parts = [f"{fp}:{ln}:{txt}" for ln, txt in file_matches] - entry = "\n".join(parts) - - total_bytes += len(entry) - if total_bytes > _MAX_OUTPUT_BYTES: - truncated = True - break - - lines_out.append(entry) - - if not lines_out: - return _text_block(f"No matches found for pattern '{pattern}'.") - - result = "\n".join(lines_out) - if truncated: - result += "\n... (output truncated)" - - return _text_block(result) diff --git a/backend/apps/agents/tools/registry.py b/backend/apps/agents/tools/registry.py deleted file mode 100644 index 91cd6b5b..00000000 --- a/backend/apps/agents/tools/registry.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Central tool registry. - -Importing this module automatically registers all builtin tools. -""" - -from __future__ import annotations - -from backend.apps.agents.tools.base import BaseTool -from backend.apps.agents.providers.base import ToolSchema - -_TOOLS: dict[str, BaseTool] = {} - - -def register_tool(tool: BaseTool) -> None: - """Register a tool instance by its name.""" - _TOOLS[tool.name] = tool - - -def get_tool(name: str) -> BaseTool | None: - """Look up a registered tool by name. Returns None if not found.""" - return _TOOLS.get(name) - - -def get_all_tools() -> list[BaseTool]: - """Return all registered tool instances.""" - return list(_TOOLS.values()) - - -def get_all_tool_schemas() -> list[ToolSchema]: - """Return provider-agnostic ToolSchema for every registered tool.""" - return [t.to_tool_schema() for t in _TOOLS.values()] - - -def init_tools() -> None: - """Import and register all builtin tools.""" - from backend.apps.agents.tools.filesystem import ( - ReadTool, - WriteTool, - EditTool, - GlobTool, - GrepTool, - ) - from backend.apps.agents.tools.system import BashTool, AskUserQuestionTool - from backend.apps.agents.tools.web import WebSearchTool, WebFetchTool - - for tool_cls in [ - ReadTool, - WriteTool, - EditTool, - GlobTool, - GrepTool, - BashTool, - AskUserQuestionTool, - WebSearchTool, - WebFetchTool, - ]: - register_tool(tool_cls()) - - -# Auto-register on import -init_tools() diff --git a/backend/apps/agents/tools/system.py b/backend/apps/agents/tools/system.py deleted file mode 100644 index 3a394328..00000000 --- a/backend/apps/agents/tools/system.py +++ /dev/null @@ -1,125 +0,0 @@ -"""System tools: Bash and AskUserQuestion.""" - -from __future__ import annotations - -import asyncio - -from backend.apps.agents.tools.base import BaseTool, ToolContext - -_MAX_OUTPUT_BYTES = 100 * 1024 # ~100 KB cap - - -class BashTool(BaseTool): - name = "Bash" - description = ( - "Execute a shell command and return its output. The command runs in " - "the session's working directory. Supports an optional timeout " - "(default 120 000 ms). Stdout and stderr are captured and returned." - ) - - def get_schema(self) -> dict: - return { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The shell command to execute.", - }, - "timeout": { - "type": "integer", - "description": "Timeout in milliseconds (default 120000, max 600000).", - "default": 120000, - }, - "description": { - "type": "string", - "description": "Optional human-readable description of what this command does.", - }, - }, - "required": ["command"], - "additionalProperties": False, - } - - async def execute(self, input_data: dict, context: ToolContext) -> list[dict]: - command: str = input_data["command"] - timeout_ms: int = min(input_data.get("timeout", 120000), 600000) - timeout_s: float = timeout_ms / 1000.0 - - try: - proc = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=context.cwd, - ) - except Exception as exc: - return [{"type": "text", "text": f"Error starting command: {exc}"}] - - try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s) - except asyncio.TimeoutError: - # Attempt to kill the process - try: - proc.kill() - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5) - except Exception: - stdout, stderr = b"", b"" - - partial = self._decode(stdout, stderr) - msg = ( - f"Command timed out after {timeout_ms}ms.\n" - f"Partial output:\n{partial}" - ) - return [{"type": "text", "text": self._truncate(msg)}] - except Exception as exc: - return [{"type": "text", "text": f"Error executing command: {exc}"}] - - output = self._decode(stdout, stderr) - - if proc.returncode != 0: - output = f"Exit code: {proc.returncode}\n{output}" - - if not output.strip(): - output = f"(command completed with exit code {proc.returncode})" - - return [{"type": "text", "text": self._truncate(output)}] - - @staticmethod - def _decode(stdout: bytes, stderr: bytes) -> str: - parts: list[str] = [] - if stdout: - parts.append(stdout.decode("utf-8", errors="replace")) - if stderr: - parts.append(stderr.decode("utf-8", errors="replace")) - return "\n".join(parts) - - @staticmethod - def _truncate(text: str) -> str: - if len(text) > _MAX_OUTPUT_BYTES: - return text[:_MAX_OUTPUT_BYTES] + "\n... (output truncated)" - return text - - -class AskUserQuestionTool(BaseTool): - name = "AskUserQuestion" - description = ( - "Ask the user a clarifying question. The actual blocking/HITL " - "interaction is handled by the agent loop's hitl_handler; this tool " - "simply surfaces the question text." - ) - - def get_schema(self) -> dict: - return { - "type": "object", - "properties": { - "question": { - "type": "string", - "description": "The question to ask the user.", - }, - }, - "required": ["question"], - "additionalProperties": False, - } - - async def execute(self, input_data: dict, context: ToolContext) -> list[dict]: - question: str = input_data.get("question", "") - return [{"type": "text", "text": question}] diff --git a/backend/apps/agents/tools/web.py b/backend/apps/agents/tools/web.py deleted file mode 100644 index d1eec3b0..00000000 --- a/backend/apps/agents/tools/web.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Web tools: WebSearch and WebFetch.""" - -from __future__ import annotations - -import html -import re -from typing import Any - -import httpx - -from backend.apps.agents.tools.base import BaseTool, ToolContext - -_MAX_OUTPUT_BYTES = 100 * 1024 # ~100 KB -_HTTP_TIMEOUT = 30 # seconds -_USER_AGENT = ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" -) - - -def _truncate(text: str, limit: int = _MAX_OUTPUT_BYTES) -> str: - if len(text) > limit: - return text[:limit] + "\n... (output truncated)" - return text - - -def _strip_html(raw_html: str) -> str: - """Naive but effective HTML → plain-text conversion.""" - # Remove script/style blocks - text = re.sub(r"<(script|style)[^>]*>.*?", "", raw_html, flags=re.DOTALL | re.IGNORECASE) - # Remove HTML tags - text = re.sub(r"<[^>]+>", " ", text) - # Decode HTML entities - text = html.unescape(text) - # Collapse whitespace - text = re.sub(r"[ \t]+", " ", text) - text = re.sub(r"\n{3,}", "\n\n", text) - return text.strip() - - -# ─────────────────────────────────────────────────────────────────────────── -# WebSearchTool -# ─────────────────────────────────────────────────────────────────────────── - - -class WebSearchTool(BaseTool): - name = "WebSearch" - description = ( - "Search the web using DuckDuckGo and return titles, URLs, and " - "snippets for the top results." - ) - - def get_schema(self) -> dict: - return { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "The search query.", - }, - "num_results": { - "type": "integer", - "description": "Maximum number of results to return (default 5).", - "default": 5, - }, - }, - "required": ["query"], - "additionalProperties": False, - } - - async def execute(self, input_data: dict, context: ToolContext) -> list[dict]: - query: str = input_data["query"] - num_results: int = input_data.get("num_results", 5) - - try: - results = await self._search_ddg(query, num_results) - if not results: - return [{"type": "text", "text": f"No search results found for: {query}"}] - return [{"type": "text", "text": results}] - except Exception as exc: - return [{"type": "text", "text": f"Web search error: {exc}"}] - - @staticmethod - async def _search_ddg(query: str, num_results: int) -> str: - """Query DuckDuckGo HTML endpoint and parse results.""" - async with httpx.AsyncClient( - timeout=_HTTP_TIMEOUT, - follow_redirects=True, - headers={"User-Agent": _USER_AGENT}, - ) as client: - resp = await client.post( - "https://html.duckduckgo.com/html/", - data={"q": query}, - ) - resp.raise_for_status() - - body = resp.text - - # Parse result blocks – DuckDuckGo wraps each result in - #

...
- result_blocks = re.findall( - r']*class="[^"]*result[^"]*"[^>]*>(.*?)\s*(?=]*class="[^"]*result|$)', - body, - flags=re.DOTALL, - ) - - entries: list[str] = [] - for block in result_blocks: - if len(entries) >= num_results: - break - - # Title + URL — handle both class-before-href and href-before-class - link_match = re.search( - r']*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)', - block, - flags=re.DOTALL, - ) - if not link_match: - # Try reversed attribute order - link_match = re.search( - r']*href="([^"]*)"[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)', - block, - flags=re.DOTALL, - ) - if not link_match: - continue - - raw_url = html.unescape(link_match.group(1)) - title = _strip_html(link_match.group(2)).strip() - - # Snippet - snippet_match = re.search( - r']*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)', - block, - flags=re.DOTALL, - ) - snippet = _strip_html(snippet_match.group(1)).strip() if snippet_match else "" - - # DuckDuckGo wraps URLs through a redirect; try to extract the real URL - real_url_match = re.search(r"uddg=([^&]+)", raw_url) - if real_url_match: - from urllib.parse import unquote - url = unquote(real_url_match.group(1)) - else: - url = raw_url - - entry = f"[{len(entries) + 1}] {title}\n {url}" - if snippet: - entry += f"\n {snippet}" - entries.append(entry) - - return "\n\n".join(entries) - - -# ─────────────────────────────────────────────────────────────────────────── -# WebFetchTool -# ─────────────────────────────────────────────────────────────────────────── - - -class WebFetchTool(BaseTool): - name = "WebFetch" - description = ( - "Fetch the contents of a URL and return the extracted text. " - "HTML is stripped to plain text. Output is truncated to ~100 KB." - ) - - def get_schema(self) -> dict: - return { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "The URL to fetch.", - }, - "prompt": { - "type": "string", - "description": "Optional prompt/context describing what information to look for.", - }, - }, - "required": ["url"], - "additionalProperties": False, - } - - async def execute(self, input_data: dict, context: ToolContext) -> list[dict]: - url: str = input_data["url"] - prompt: str | None = input_data.get("prompt") - - try: - async with httpx.AsyncClient( - timeout=_HTTP_TIMEOUT, - follow_redirects=True, - headers={"User-Agent": _USER_AGENT}, - ) as client: - resp = await client.get(url) - resp.raise_for_status() - except httpx.HTTPStatusError as exc: - return [{"type": "text", "text": f"HTTP error {exc.response.status_code} fetching {url}"}] - except Exception as exc: - return [{"type": "text", "text": f"Error fetching {url}: {exc}"}] - - content_type = resp.headers.get("content-type", "") - - if "html" in content_type or resp.text.strip().startswith(" str: @@ -63,49 +61,3 @@ def resolve_model_id(short_name: str) -> str: return m.model_id if m else short_name -def get_cost_rates(provider: str, model: str) -> tuple[float, float] | None: - """Return ``(input_cost_per_1m, output_cost_per_1m)`` or ``None``.""" - m = _BY_VALUE.get(model) - if m and m.provider.lower() == provider.lower(): - return (m.input_cost_per_1m, m.output_cost_per_1m) - for md in ALL_MODELS: - if md.value == model and md.provider.lower() == provider.lower(): - return (md.input_cost_per_1m, md.output_cost_per_1m) - return None - - -def calculate_cost( - provider: str, model: str, input_tokens: int, output_tokens: int, -) -> float: - """Calculate cost in USD from token counts.""" - rates = get_cost_rates(provider, model) - if not rates: - return 0.0 - input_rate, output_rate = rates - return (input_tokens * input_rate + output_tokens * output_rate) / 1_000_000 - - -def get_context_window(model: str) -> int: - """Look up context window for a model by its short value name.""" - m = _BY_VALUE.get(model) or _BY_MODEL_ID.get(model) - return m.context_window if m else 128_000 - - -def get_builtin_models_by_provider() -> dict[str, list[dict[str, Any]]]: - """Return built-in models grouped by provider, matching the legacy format. - - Only includes the curated built-in models (Anthropic) — not - OpenRouter-backed models which are exposed through custom providers. - """ - result: dict[str, list[dict[str, Any]]] = {} - for m in ALL_MODELS: - if m.api == "openrouter": - continue - result.setdefault(m.provider, []).append({ - "value": m.value, - "label": m.label, - "context_window": m.context_window, - "model_id": m.model_id, - "api": m.api, - }) - return result diff --git a/backend/apps/dashboard_layout/__init__.py b/backend/apps/dashboard_layout/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/apps/dashboard_layout/dashboard_layout.py b/backend/apps/dashboard_layout/dashboard_layout.py deleted file mode 100644 index 5d16b81b..00000000 --- a/backend/apps/dashboard_layout/dashboard_layout.py +++ /dev/null @@ -1,58 +0,0 @@ -import json -import os -import logging -from contextlib import asynccontextmanager -from backend.config.Apps import SubApp -from backend.apps.dashboard_layout.models import DashboardLayout, DashboardLayoutUpdate - -logger = logging.getLogger(__name__) - -from backend.config.paths import DASHBOARD_LAYOUT_DIR as DATA_DIR - -LAYOUT_FILE = os.path.join(DATA_DIR, "layout.json") - - -@asynccontextmanager -async def dashboard_layout_lifespan(): - os.makedirs(DATA_DIR, exist_ok=True) - yield - - -dashboard_layout = SubApp("dashboard_layout", dashboard_layout_lifespan) - - -def _default_layout() -> DashboardLayout: - return DashboardLayout(cards={}) - - -def _load() -> DashboardLayout: - if not os.path.exists(LAYOUT_FILE): - return _default_layout() - try: - with open(LAYOUT_FILE) as f: - data = json.load(f) - if "columns" in data and "cards" not in data: - logger.info("Detected old column-based layout format, resetting to empty canvas") - return _default_layout() - return DashboardLayout(**data) - except Exception: - logger.exception("Failed to load dashboard layout, returning default") - return _default_layout() - - -def _save(layout: DashboardLayout): - with open(LAYOUT_FILE, "w") as f: - json.dump(layout.model_dump(), f, indent=2) - - -@dashboard_layout.router.get("") -async def get_layout(): - layout = _load() - return layout.model_dump() - - -@dashboard_layout.router.put("") -async def update_layout(body: DashboardLayoutUpdate): - layout = DashboardLayout(cards=body.cards, view_cards=body.view_cards) - _save(layout) - return layout.model_dump() diff --git a/backend/apps/dashboard_layout/models.py b/backend/apps/dashboard_layout/models.py deleted file mode 100644 index 65193747..00000000 --- a/backend/apps/dashboard_layout/models.py +++ /dev/null @@ -1,27 +0,0 @@ -from pydantic import BaseModel, Field - - -class CardPosition(BaseModel): - session_id: str - x: float = 0 - y: float = 0 - width: float = 420 - height: float = 280 - - -class ViewCardPosition(BaseModel): - output_id: str - x: float = 0 - y: float = 0 - width: float = 480 - height: float = 360 - - -class DashboardLayout(BaseModel): - cards: dict[str, CardPosition] = Field(default_factory=dict) - view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict) - - -class DashboardLayoutUpdate(BaseModel): - cards: dict[str, CardPosition] - view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict) diff --git a/backend/apps/health/health.py b/backend/apps/health/health.py index 1ffbddfd..5e7bcfac 100644 --- a/backend/apps/health/health.py +++ b/backend/apps/health/health.py @@ -2,14 +2,14 @@ from backend.config.Apps import SubApp from contextlib import asynccontextmanager from fastapi.responses import PlainTextResponse from typeguard import typechecked -import debug +# import debug from fastapi import status, HTTPException @asynccontextmanager async def health_lifespan(): - debug("START") + # debug("START") yield - debug("END") + # debug("END") health = SubApp("health", health_lifespan) @@ -20,7 +20,7 @@ health = SubApp("health", health_lifespan) @health.router.get("/check") @typechecked async def check() -> PlainTextResponse: - debug("Health check successful") + # debug("Health check successful") # Use PlainTextResponse instead of JSONResponse for AWS ALB compatibility # ALB health checks can be sensitive to JSON responses and Content-Length headers return PlainTextResponse( diff --git a/backend/config/Apps.py b/backend/config/Apps.py index d8f0608c..c2c41367 100644 --- a/backend/config/Apps.py +++ b/backend/config/Apps.py @@ -1,7 +1,7 @@ import os from fastapi import FastAPI, APIRouter -import debug +# import debug from uuid import uuid4 from typing import List from contextlib import asynccontextmanager @@ -11,26 +11,26 @@ from typing import Callable class SubApp: def __init__(self, name:str, lifespan:Callable): - debug("START", name) + # debug("START", name) self.id = uuid4() self.name = name self.prefix = f"/api/{name}" self.lifespan = lifespan self.router = APIRouter() - debug("END") + # debug("END") def __str__(self): return f"SubApp(name={self.name}, prefix={self.prefix}, id={self.id})" class MainApp: def __init__(self, sub_apps: List[SubApp]): - debug("START") + # debug("START") @asynccontextmanager async def lifespan(app: FastAPI): async with AsyncExitStack() as stack: for sub_app in sub_apps: - debug(sub_app.name) + # debug(sub_app.name) await stack.enter_async_context(sub_app.lifespan()) _port = os.environ.get("OPENSWARM_PORT", "8324") print(f"\nCheck out the API docs at: http://127.0.0.1:{_port}/docs\n") @@ -45,4 +45,4 @@ class MainApp: prefix=sub_app.prefix, tags=[sub_app.name] ) - debug("END") \ No newline at end of file + # debug("END") \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 909d7685..54900937 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,12 +3,9 @@ claude-agent-sdk jsonschema fastapi[standard] pydantic==2.10.5 -langchain-core==0.3.51 -langchain-openai==0.3.12 pytest==8.3.4 pytest-asyncio==0.25.2 typeguard==4.4.2 -python-dotenv==1.1.1 Pillow posthog httpx>=0.27.0 \ No newline at end of file diff --git a/backend/run.sh b/backend/run.sh index f79a734c..e4485111 100755 --- a/backend/run.sh +++ b/backend/run.sh @@ -59,18 +59,6 @@ source "$VENV_DIR/bin/activate" # --- Upgrade pip if outdated --- pip3 install --upgrade pip --quiet -# --- Install custom debugger module if not already installed --- -DEBUGGER_DIR_ABSPATH="$PROJECT_ROOT_ABSPATH/debugger" -if ! pip3 show debug > /dev/null 2>&1; then - echo "Installing debugger module..." - cd "$DEBUGGER_DIR_ABSPATH" - pip3 install -e . - if [[ $? -ne 0 ]]; then - echo "Failed to install debugger module." - exit 1 - fi -fi - # --- Install Python dependencies --- echo "Installing dependencies..." cd "$BACKEND_DIR_ABSPATH" diff --git a/electron/main.js b/electron/main.js index fc30398d..7512b888 100644 --- a/electron/main.js +++ b/electron/main.js @@ -161,8 +161,7 @@ async function startBackend() { process.resourcesPath, 'python-env', 'lib', 'python3.13', 'site-packages' ); - const debuggerDir = getResourcePath('debugger'); - env.PYTHONPATH = [projectRoot, debuggerDir, pythonEnvSitePackages].join(':'); + env.PYTHONPATH = [projectRoot, pythonEnvSitePackages].join(':'); } console.log(`Starting backend: ${pythonPath} on port ${backendPort}`); diff --git a/electron/package.json b/electron/package.json index dad514fc..90708f80 100644 --- a/electron/package.json +++ b/electron/package.json @@ -73,13 +73,6 @@ "**/*" ] }, - { - "from": "build-staging/debugger", - "to": "debugger", - "filter": [ - "**/*" - ] - }, { "from": "python-env", "to": "python-env", diff --git a/frontend/package.json b/frontend/package.json index 6fdc10a3..9e088072 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,6 @@ "@mui/icons-material": "^7.3.9", "@mui/material": "^7.3.9", "@reduxjs/toolkit": "^2.8.2", - "@types/react-syntax-highlighter": "^15.5.13", "codemirror": "^6.0.2", "framer-motion": "^12.35.2", "html-to-image": "^1.11.13", @@ -29,7 +28,6 @@ "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", - "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1" }, "devDependencies": { diff --git a/frontend/src/app/components/AnalyticsOptIn.tsx b/frontend/src/app/components/AnalyticsOptIn.tsx deleted file mode 100644 index b96701dd..00000000 --- a/frontend/src/app/components/AnalyticsOptIn.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import Paper from '@mui/material/Paper'; -import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { updateSettings } from '@/shared/state/settingsSlice'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; - -const AnalyticsOptIn: React.FC = () => { - const c = useClaudeTokens(); - const dispatch = useAppDispatch(); - const settings = useAppSelector((s) => s.settings.data); - const loaded = useAppSelector((s) => s.settings.loaded); - - if (!loaded || settings.analytics_opt_in !== null) return null; - - const handleChoice = (optIn: boolean) => { - dispatch(updateSettings({ ...settings, analytics_opt_in: optIn })); - }; - - return ( - - - - Help improve OpenSwarm - - - Share anonymous usage statistics like session counts, feature usage, and model preferences. - No conversations, file paths, or personal information — ever. - You can change this anytime in Settings. - - - - - - - - ); -}; - -export default AnalyticsOptIn; diff --git a/frontend/src/shared/dashboardClipboard.ts b/frontend/src/shared/dashboardClipboard.ts index a3b4af88..a31f4bb4 100644 --- a/frontend/src/shared/dashboardClipboard.ts +++ b/frontend/src/shared/dashboardClipboard.ts @@ -13,22 +13,15 @@ export interface ClipboardCard { } let clipboardCards: ClipboardCard[] = []; -let clipboardTimestamp = 0; export function setClipboardCards(cards: ClipboardCard[]): void { clipboardCards = cards; - clipboardTimestamp = Date.now(); } export function getClipboardCards(): ClipboardCard[] { return clipboardCards; } -export function getClipboardTimestamp(): number { - return clipboardTimestamp; -} - export function clearClipboard(): void { clipboardCards = []; - clipboardTimestamp = 0; } diff --git a/frontend/src/shared/modals/UnderConstruction/UnderConstruction.module.scss b/frontend/src/shared/modals/UnderConstruction/UnderConstruction.module.scss deleted file mode 100644 index 3e660d5c..00000000 --- a/frontend/src/shared/modals/UnderConstruction/UnderConstruction.module.scss +++ /dev/null @@ -1,28 +0,0 @@ -.under_construction_overlay { - width: 100%; - height: 100%; - background: transparent; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - // z-index: 1000; - text-align: center; - font-family: Arial, sans-serif; - color: white; - // box-sizing: border-box; -} - -.under_construction_overlay img { - width: 100px; - height: 100px; -} - -.under_construction_overlay h2 { - font-size: 24px; - margin-top: 20px; -} - -.under_construction_overlay p { - font-size: 18px; -} \ No newline at end of file diff --git a/frontend/src/shared/modals/UnderConstruction/UnderConstruction.tsx b/frontend/src/shared/modals/UnderConstruction/UnderConstruction.tsx deleted file mode 100644 index 0c1b746f..00000000 --- a/frontend/src/shared/modals/UnderConstruction/UnderConstruction.tsx +++ /dev/null @@ -1,15 +0,0 @@ - -import React from 'react'; -import styles from './UnderConstruction.module.scss'; // CSS for styling the overlay - -const UnderConstruction = () => { - return ( -
- Console Icon -

Under Construction

-

This feature is coming soon!

-
- ); -}; - -export { UnderConstruction }; \ No newline at end of file diff --git a/frontend/src/shared/state/analyticsSlice.ts b/frontend/src/shared/state/analyticsSlice.ts deleted file mode 100644 index 1ea364e0..00000000 --- a/frontend/src/shared/state/analyticsSlice.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; -import { API_BASE } from '@/shared/config'; - -const ANALYTICS_API = `${API_BASE}/analytics`; - -export interface UsageSummary { - total_sessions: number; - total_cost_usd: number; - total_messages: number; - total_tool_calls: number; - avg_duration_seconds: number; - avg_cost_per_session: number; - completion_rate: number; - models_used: Record; - providers_used: Record; - top_tools: Record; - status_breakdown: Record; - // 9Router enrichment - total_prompt_tokens: number; - total_completion_tokens: number; - cost_by_model: Record; - cost_by_provider: Record; - cost_source: ' 9router' | 'sdk' | 'none'; - nine_router_available: boolean; - total_requests: number; -} - -export interface CostBreakdown { - available: boolean; - period: string; - total_cost: number; - total_requests: number; - total_prompt_tokens: number; - total_completion_tokens: number; - by_model: Record; - by_provider: Record; -} - -interface AnalyticsState { - summary: UsageSummary | null; - costBreakdown: CostBreakdown | null; - loading: boolean; -} - -const initialState: AnalyticsState = { - summary: null, - costBreakdown: null, - loading: false, -}; - -export const fetchAnalyticsSummary = createAsyncThunk('analytics/fetchSummary', async () => { - const res = await fetch(`${ANALYTICS_API}/usage-summary`); - return (await res.json()) as UsageSummary; -}); - -export const fetchCostBreakdown = createAsyncThunk( - 'analytics/fetchCostBreakdown', - async (period: string = '7d') => { - const res = await fetch(`${ANALYTICS_API}/cost-breakdown?period=${period}`); - return (await res.json()) as CostBreakdown; - }, -); - -const analyticsSlice = createSlice({ - name: 'analytics', - initialState, - reducers: {}, - extraReducers: (builder) => { - builder - .addCase(fetchAnalyticsSummary.pending, (state) => { state.loading = true; }) - .addCase(fetchAnalyticsSummary.fulfilled, (state, action) => { - state.loading = false; - state.summary = action.payload; - }) - .addCase(fetchAnalyticsSummary.rejected, (state) => { state.loading = false; }) - .addCase(fetchCostBreakdown.fulfilled, (state, action) => { - state.costBreakdown = action.payload; - }); - }, -}); - -export default analyticsSlice.reducer; diff --git a/frontend/src/shared/state/outputsSlice.ts b/frontend/src/shared/state/outputsSlice.ts index 1ab4bf80..15b61981 100644 --- a/frontend/src/shared/state/outputsSlice.ts +++ b/frontend/src/shared/state/outputsSlice.ts @@ -47,16 +47,6 @@ export function buildServeUrl( return `${SERVE_BASE}/${outputId}/serve/index.html?_d=${encodeURIComponent(encoded)}`; } -export function buildWorkspaceServeUrl( - workspaceId: string, - inputData: Record = {}, - backendResult: Record | null = null, -): string { - const dataPayload = JSON.stringify({ i: inputData, r: backendResult }); - const encoded = btoa(unescape(encodeURIComponent(dataPayload))); - return `${SERVE_BASE}/workspace/${workspaceId}/serve/index.html?_d=${encodeURIComponent(encoded)}`; -} - export interface OutputExecuteResult { output_id: string; output_name: string; diff --git a/frontend/src/shared/state/store.ts b/frontend/src/shared/state/store.ts index 259cca24..a28f5190 100644 --- a/frontend/src/shared/state/store.ts +++ b/frontend/src/shared/state/store.ts @@ -12,7 +12,6 @@ import outputsReducer from './outputsSlice'; import dashboardLayoutReducer from './dashboardLayoutSlice'; import dashboardsReducer from './dashboardsSlice'; import updateReducer from './updateSlice'; -import analyticsReducer from './analyticsSlice'; import modelsReducer from './modelsSlice'; export const store = configureStore({ @@ -30,7 +29,6 @@ export const store = configureStore({ dashboardLayout: dashboardLayoutReducer, dashboards: dashboardsReducer, update: updateReducer, - analytics: analyticsReducer, models: modelsReducer, }, }); diff --git a/frontend/src/shared/state/tempStateSlice.ts b/frontend/src/shared/state/tempStateSlice.ts index c8294ed0..ef841e49 100644 --- a/frontend/src/shared/state/tempStateSlice.ts +++ b/frontend/src/shared/state/tempStateSlice.ts @@ -1,39 +1,25 @@ -// store/tempStateSlice.ts import { createSlice, PayloadAction } from '@reduxjs/toolkit'; interface TempState { - temp_state: string | null; pendingBrowserUrl: string | null; pendingFocusAgentId: string | null; - lastDashboardId: string | null; } const initialState: TempState = { - temp_state: null, pendingBrowserUrl: null, pendingFocusAgentId: null, - lastDashboardId: null, }; const tempStateSlice = createSlice({ name: 'tempState', initialState, reducers: { - setTempState(state, action: PayloadAction) { - state.temp_state = action.payload; - }, - resetTempState(state) { - state.temp_state = null; - }, setPendingBrowserUrl(state, action: PayloadAction) { state.pendingBrowserUrl = action.payload; }, clearPendingBrowserUrl(state) { state.pendingBrowserUrl = null; }, - setLastDashboardId(state, action: PayloadAction) { - state.lastDashboardId = action.payload; - }, setPendingFocusAgentId(state, action: PayloadAction) { state.pendingFocusAgentId = action.payload; }, @@ -44,11 +30,8 @@ const tempStateSlice = createSlice({ }); export const { - setTempState, - resetTempState, setPendingBrowserUrl, clearPendingBrowserUrl, - setLastDashboardId, setPendingFocusAgentId, clearPendingFocusAgentId, } = tempStateSlice.actions; diff --git a/frontend/src/shared/state/templatesSlice.ts b/frontend/src/shared/state/templatesSlice.ts index d4033149..cf788273 100644 --- a/frontend/src/shared/state/templatesSlice.ts +++ b/frontend/src/shared/state/templatesSlice.ts @@ -73,19 +73,6 @@ export const deleteTemplate = createAsyncThunk('templates/delete', async (id: st return id; }); -export const renderTemplate = createAsyncThunk( - 'templates/render', - async ({ templateId, values }: { templateId: string; values: Record }) => { - const res = await fetch(`${TEMPLATES_API}/render`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ template_id: templateId, values }), - }); - const data = await res.json(); - return data.rendered as string; - } -); - const templatesSlice = createSlice({ name: 'templates', initialState, diff --git a/frontend/src/shared/state/updateSlice.ts b/frontend/src/shared/state/updateSlice.ts index a1244132..c0d6b736 100644 --- a/frontend/src/shared/state/updateSlice.ts +++ b/frontend/src/shared/state/updateSlice.ts @@ -57,11 +57,6 @@ const updateSlice = createSlice({ state.status = 'error'; state.error = action.payload; }, - resetUpdateStatus(state) { - state.status = 'idle'; - state.error = null; - state.downloadPercent = 0; - }, }, }); @@ -73,7 +68,6 @@ export const { setDownloading, setUpdateDownloaded, setUpdateError, - resetUpdateStatus, } = updateSlice.actions; export default updateSlice.reducer; diff --git a/frontend/src/shared/styles/color.module.scss b/frontend/src/shared/styles/color.module.scss deleted file mode 100644 index 439d43cb..00000000 --- a/frontend/src/shared/styles/color.module.scss +++ /dev/null @@ -1,128 +0,0 @@ -@use '@/shared/styles/utils.module.scss' as utils; - -$text-map: ( - 'light-1': #F0F0F0, - 'light-2': #D9D9D9, - 'light-3': #BDBDBD, - 'light-4': #999999, -); -@function text($mode: 'light-1') { - @return utils.get-style( - $function-map: $text-map, - $mode: $mode - ); -} - -$background-color-map: ( - 'dark-1': #232323, - 'dark-2': #151515, - 'dark-3': #0A0A0A, -); -@function background-color($mode: 'dark-1') { - @return utils.get-style( - $function-map: $background-color-map, - $mode: $mode - ); -} - -@mixin gradient-1() { - $mask-color: rgba(0, 0, 0, 0.623); - $gradient-color: rgba(62, 139, 241, 0.20); - $background-color: #151515; - background: - linear-gradient(0deg, $mask-color 0%, $mask-color 100%), - radial-gradient(102.16% 48.94% at 49.58% 47.09%, rgba(6, 14, 25, 0.00) 0%, rgba(62, 139, 241, 0.20) 100%), - #151515; -} - -$glass-map: ( - 'default': ( - border-radius: 10px, - border: 1px solid rgba(255, 255, 255, 0.075), - background: rgba(38, 38, 38, 0.184), - background-blend-mode: luminosity, - backdrop-filter: blur(50px), - ), - 'light-05': ( - border-radius: 10px, - border: 1px solid rgba(255, 255, 255, 0.095), - background: rgba(160, 160, 160, 0.048), - background-blend-mode: luminosity, - backdrop-filter: blur(50px), - ), - 'light-075': ( - border-radius: 10px, - border: 1px solid rgba(255, 255, 255, 0.095), - background: rgba(160, 160, 160, 0.075), - background-blend-mode: luminosity, - backdrop-filter: blur(50px), - ), - 'light-1': ( - border-radius: 10px, - border: 1px solid rgba(255, 255, 255, 0.095), - background: rgba(160, 160, 160, 0.154), - background-blend-mode: luminosity, - backdrop-filter: blur(50px), - ), - 'light-2': ( - border-radius: 10px, - border: 1px solid rgba(255, 255, 255, 0.178), - background: rgba(160, 160, 160, 0.46), - background-blend-mode: luminosity, - backdrop-filter: blur(50px), - ), - 'light-3': ( - border-radius: 10px, - border: 1px solid rgba(255, 255, 255, 0.178), - background: rgba(0, 0, 0, 0.247), - background-blend-mode: luminosity, - backdrop-filter: blur(50px), - ), -); -@mixin glass($mode: 'default') { - @include utils.apply-style-map( - $mixin-map: $glass-map, - $mode: $mode - ); -} - -$glow-map: ( - 'default': ( - border: 1px solid #0099ff71, - box-shadow: 0 0 24px #0099ff71, - ), - 'dark-1': ( - border: 1px solid #3e8cf13e, - box-shadow: 0 0 24px #3e8cf12b, - ), - 'light-1': ( - border: 1px solid #ab19ff47, - box-shadow: 0 0 44px #c259ff8a, - ), - 'source-1': ( - border: 1px solid #ff000071, - box-shadow: 0 0 44px #ff000071, - ), -); -@mixin glow($mode: 'default') { - @include utils.apply-style-map( - $mixin-map: $glow-map, - $mode: $mode - ); -} - - -$accent-map: ( - 'blue-1': #3E8BF1, - 'blue-2': #3e8cf1c7, - 'blue-3': #3e8cf15b, - 'blue-grey-1': #77a7e5c7, - 'red-1': #F56868, - 'red-2': #f568681a, -); -@function accent($mode: 'blue-1') { - @return utils.get-style( - $function-map: $accent-map, - $mode: $mode - ); -} diff --git a/frontend/src/shared/styles/getStyleValue.tsx b/frontend/src/shared/styles/getStyleValue.tsx deleted file mode 100644 index 7f6162fd..00000000 --- a/frontend/src/shared/styles/getStyleValue.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export const getStyleValue = (className: string, property: string, defaultValue: string = "none"): string => { - if (typeof document !== 'undefined') { - const element = document.createElement("div"); - element.setAttribute("class", className); - document.body.appendChild(element); - const style = window.getComputedStyle(element); - const value = style.getPropertyValue(property); - document.body.removeChild(element); - return value || defaultValue; - } - return defaultValue; // Return default value if not in a browser environment -}; \ No newline at end of file diff --git a/frontend/src/shared/styles/layout.module.scss b/frontend/src/shared/styles/layout.module.scss deleted file mode 100644 index eb5d9958..00000000 --- a/frontend/src/shared/styles/layout.module.scss +++ /dev/null @@ -1,62 +0,0 @@ -@use '@/shared/styles/utils.module.scss' as utils; - -$flex-map: ( - 'vert': ( - flex-direction: column, - ), - 'horz': ( - flex-direction: row, - ), -); -@mixin flex($direction: 'vert') { - display: flex; - width: 100%; - height: 100%; - justify-content: center; - align-items: center; - gap: 0; - padding: 0; - margin: 0; - box-sizing: border-box; - @include utils.apply-style-map( - $mixin-map: $flex-map, - $mode: $direction - ); -} - -$flex-hug-map: ( - 'default': ( - width: fit-content, - height: fit-content, - ), - 'full-width': ( - width: 100%, - ), - 'full-height': ( - height: 100%, - ), -); -@mixin flex-hug($mode: 'default') { - @include flex('horz'); - @include utils.apply-style-map( - $mixin-map: $flex-hug-map, - $mode: $mode - ); -} - - -$scroll-map: ( - 'hidden': ( - "&::-webkit-scrollbar": ( - display: none - ), - -ms-overflow-style: none, /* IE and Edge */ - scrollbar-width: none, /* Firefox */ - ), -); -@mixin scroll-bar($mode: 'hidden') { - @include utils.apply-style-map( - $mixin-map: $scroll-map, - $mode: $mode - ); -} \ No newline at end of file diff --git a/frontend/src/shared/styles/text.module.scss b/frontend/src/shared/styles/text.module.scss deleted file mode 100644 index 8e218db1..00000000 --- a/frontend/src/shared/styles/text.module.scss +++ /dev/null @@ -1,63 +0,0 @@ -@use '@/shared/styles/color.module.scss' as g-color; -@use '@/shared/styles/utils.module.scss' as utils; - - -$font-map: ( - 'default': 'Inter', - 'secondary': 'Times New Roman' -); -@function font($mode: 'default') { - @return utils.get-style( - $function-map: $font-map, - $mode: $mode - ); -} - -$size-map: ( - 'small': 12px, - 'small-medium': 14px, - 'medium': 16px, - 'large': 20px, - 'title': 30px, -); -@function size($mode: 'default') { - @return utils.get-style( - $function-map: $size-map, - $mode: $mode - ); -} - -$weight-map: ( - 'small': 400, - 'medium': 500, - 'large': 600, - 'title': 700, -); -@function weight($mode: 'default') { - @return utils.get-style( - $function-map: $weight-map, - $mode: $mode - ); -} - -$text-map: ( - 'default': ( - font-family: 'Inter', - font-size: 16px, - font-weight: 400, - color: g-color.text('light-1'), - ), - 'title': ( - font-family: 'Inter', - font-size: 40px, - font-weight: 700, - color: g-color.accent('blue-1'), - line-height: 100%, - ) -); -@mixin text($mode: 'default') { - @include utils.apply-style-map( - $mixin-map: $text-map, - $mode: $mode - ); -} \ No newline at end of file diff --git a/frontend/src/shared/styles/utils.module.scss b/frontend/src/shared/styles/utils.module.scss deleted file mode 100644 index 9401ca4a..00000000 --- a/frontend/src/shared/styles/utils.module.scss +++ /dev/null @@ -1,63 +0,0 @@ -@use "sass:map"; -@use "sass:meta"; - -// NOTE: Example map input: -// $text-map: ( -// 'default': ( -// font-family: 'Inter', -// font-size: 16px, -// font-weight: 400, -// ), -// 'secondary': (∂ -// font-family: 'Times New Roman', -// font-size: 16px, -// font-weight: 400, -// ) -// ); -@function construct-styles($mixin-map, $mode) { - $styles: map.get($mixin-map, $mode); - @if $styles == null { - $available-modes: map.keys($styles); - @error "Invalid style mode: '#{$mode}' -> Available modes are: #{$available-modes}."; - } - @return $styles; -} -@mixin apply-style-map($mixin-map, $mode) { - $styles: construct-styles($mixin-map, $mode); - @each $property, $value in $styles { - @if meta.type-of($value) == map { - // This is a nested selector - #{$property} { - @each $nested-property, $nested-value in $value { - #{$nested-property}: $nested-value; - } - } - } @else { - // This is a normal property-value pair - #{$property}: $value; - } - } -} - - -// NOTE: Example map input: -// $font-map: ( -// 'default': 'Inter', -// 'secondary': 'Times New Roman', -// ) -// ); -@function construct-style($function-map, $mode) { - // Check if the mode exists in the map - $style-value: map.get($function-map, $mode); - @if $style-value == null { - $available-modes: map.keys($function-map); - @error "Invalid style mode: '#{$mode}' -> Available modes are: #{$available-modes}."; - } - - // Return the style value - @return $style-value; -} -@function get-style($function-map, $mode) { - $style: construct-style($function-map, $mode); - @return $style; -} diff --git a/frontend/src/types/css-modules.d.ts b/frontend/src/types/css-modules.d.ts deleted file mode 100644 index 091e8bab..00000000 --- a/frontend/src/types/css-modules.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -declare module '*.module.css' { - const classes: { [key: string]: string }; - export default classes; - } - - declare module '*.module.scss' { - const classes: { [key: string]: string }; - export default classes; - } - - declare module '*.module.sass' { - const classes: { [key: string]: string }; - export default classes; - } \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index cd524f2d..31de931e 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -13,15 +13,10 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, - "plugins": [ - { - "name": "next" - } - ], "paths": { "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] } \ No newline at end of file diff --git a/run/utils/build-app.sh b/run/utils/build-app.sh index c58cb61c..1d1ad06a 100755 --- a/run/utils/build-app.sh +++ b/run/utils/build-app.sh @@ -4,8 +4,8 @@ set -euo pipefail # Master build script for the OpenSwarm desktop app. # # Usage: -# bash scripts/build-app.sh Local dev build (unsigned) -# bash scripts/build-app.sh --publish Production build (signed, notarized, published to GitHub Releases) +# bash run/utils/build-app.sh Local dev build (unsigned) +# bash run/utils/build-app.sh --publish Production build (signed, notarized, published to GitHub Releases) SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" @@ -67,7 +67,7 @@ fi echo "" # Step 1: Build frontend -echo "[1/4] Building frontend..." +echo "[1/5] Building frontend..." cd "$PROJECT_ROOT/frontend" npm install npm run build @@ -80,7 +80,7 @@ echo "Frontend build complete." echo "" # Step 2: Build Python environment -echo "[2/4] Building Python environment..." +echo "[2/5] Building Python environment..." bash "$SCRIPT_DIR/build-python-env.sh" if [[ ! -d "$PROJECT_ROOT/electron/python-env" ]]; then @@ -123,12 +123,6 @@ rsync -a \ --exclude='*.pyc' --exclude='.venv' \ "$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/" -rsync -a \ - --exclude='__pycache__' --exclude='**/__pycache__' \ - --exclude='*.pyc' --exclude='.venv' --exclude='**/.venv' \ - --exclude='**/node_modules' \ - "$PROJECT_ROOT/debugger/" "$STAGING_DIR/debugger/" - rsync -a "$PROJECT_ROOT/frontend/dist/" "$STAGING_DIR/frontend/" # 9Router — copy the pre-built standalone directory diff --git a/run/utils/build-python-env.sh b/run/utils/build-python-env.sh index af321c58..0712e703 100755 --- a/run/utils/build-python-env.sh +++ b/run/utils/build-python-env.sh @@ -84,10 +84,6 @@ echo "Installing backend dependencies..." "$PYTHON_BIN" -m pip install --upgrade pip "$PYTHON_BIN" -m pip install -r "$PROJECT_ROOT/backend/requirements.txt" -# Install the debugger module -echo "Installing debugger module..." -"$PYTHON_BIN" -m pip install -e "$PROJECT_ROOT/debugger" - # Verify claude-agent-sdk and its bundled binary echo "Verifying claude-agent-sdk..." "$PYTHON_BIN" -c "import claude_agent_sdk; print(f'claude-agent-sdk installed')"