[Haik]: ckpt, updated readmes

This commit is contained in:
haikdc
2026-03-30 05:39:24 -07:00
parent 42c4ceac80
commit 46affaa5e9
45 changed files with 112 additions and 3266 deletions
+92 -60
View File
@@ -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
<details>
<summary><strong>Node.js (via nvm)</strong></summary>
<summary><strong>Installing Node.js via nvm</strong></summary>
```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/<your-org>/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
```
+4 -103
View File
@@ -14,7 +14,7 @@
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
<a href="GETTING_STARTED.md"><img src="https://img.shields.io/badge/📖_Getting_Started-guide-orange.svg" alt="Getting Started"></a>
<a href="CONTRIBUTING.md"><img src="https://img.shields.io/badge/📖_Contributing-guide-orange.svg" alt="Contributing"></a>
<a href="#"><img src="https://img.shields.io/badge/platform-macOS-lightgrey.svg" alt="Platform"></a>
<a href="https://github.com/openswarm-ai/openswarm/stargazers"><img src="https://img.shields.io/github/stars/openswarm-ai/openswarm?style=social" alt="GitHub Stars"></a>
<a href="https://github.com/openswarm-ai/openswarm/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome"></a>
@@ -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.
<br>
@@ -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.
<br>
@@ -121,93 +116,6 @@ Electron Shell (desktop wrapper, auto-updater)
<br>
## 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) |
<br>
## 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.
<br>
## 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
```
<br>
## 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.
<br>
-346
View File
@@ -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__<server_name>__<tool_name>.
"""
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__<server>__<tool> 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
-260
View File
@@ -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.")
-135
View File
@@ -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,
}
@@ -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")
-277
View File
@@ -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)
-47
View File
@@ -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(),
)
-476
View File
@@ -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)
-61
View File
@@ -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()
-125
View File
@@ -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}]
-214
View File
@@ -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)[^>]*>.*?</\1>", "", 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
# <div class="result ..."> ... </div>
result_blocks = re.findall(
r'<div[^>]*class="[^"]*result[^"]*"[^>]*>(.*?)</div>\s*(?=<div[^>]*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'<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)</a>',
block,
flags=re.DOTALL,
)
if not link_match:
# Try reversed attribute order
link_match = re.search(
r'<a[^>]*href="([^"]*)"[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)</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'<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>',
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("<!"):
text = _strip_html(resp.text)
else:
text = resp.text
text = _truncate(text)
header = f"Contents of {url}:"
if prompt:
header += f"\n(Looking for: {prompt})"
return [{"type": "text", "text": f"{header}\n\n{text}"}]
-37
View File
@@ -1,37 +0,0 @@
from pydantic import BaseModel
from typing import Optional
class AnalyticsEvent(BaseModel):
id: Optional[int] = None
timestamp: str
event_type: str
properties: dict
session_id: Optional[str] = None
dashboard_id: Optional[str] = None
class UsageSummary(BaseModel):
total_sessions: int = 0
total_cost_usd: float = 0.0
total_messages: int = 0
total_tool_calls: int = 0
avg_session_duration_seconds: float = 0.0
session_completion_rate: float = 0.0
approval_rate: float = 0.0
models_used: dict[str, int] = {}
modes_used: dict[str, int] = {}
top_tools: list[list] = []
class TimeSeriesPoint(BaseModel):
date: str
value: float
class ExportPayload(BaseModel):
export_version: str = "1.0"
exported_at: str = ""
app_version: str = "unknown"
period: dict = {}
summary: dict = {}
-48
View File
@@ -7,7 +7,6 @@ Other modules should import from here instead of maintaining their own
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
@@ -51,7 +50,6 @@ ALL_MODELS: list[ModelDef] = [
# fmt: on
_BY_VALUE: dict[str, ModelDef] = {m.value: m for m in ALL_MODELS}
_BY_MODEL_ID: dict[str, ModelDef] = {m.model_id: m for m in ALL_MODELS}
def resolve_model_id(short_name: str) -> 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
@@ -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()
-27
View File
@@ -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)
+4 -4
View File
@@ -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(
+6 -6
View File
@@ -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")
# debug("END")
-3
View File
@@ -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
-12
View File
@@ -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"
+1 -2
View File
@@ -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}`);
-7
View File
@@ -73,13 +73,6 @@
"**/*"
]
},
{
"from": "build-staging/debugger",
"to": "debugger",
"filter": [
"**/*"
]
},
{
"from": "python-env",
"to": "python-env",
-2
View File
@@ -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": {
@@ -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 (
<Box
sx={{
position: 'fixed',
bottom: 24,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1400,
maxWidth: 480,
width: '90%',
}}
>
<Paper
elevation={0}
sx={{
p: 2.5,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: 3,
boxShadow: c.shadow.lg,
}}
>
<Typography sx={{ color: c.text.primary, fontSize: '0.9rem', fontWeight: 600, mb: 0.5 }}>
Help improve OpenSwarm
</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.8rem', lineHeight: 1.5, mb: 2 }}>
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.
</Typography>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
onClick={() => handleChoice(false)}
sx={{
color: c.text.muted,
textTransform: 'none',
fontSize: '0.82rem',
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
}}
>
No thanks
</Button>
<Button
variant="contained"
onClick={() => handleChoice(true)}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.82rem',
borderRadius: 1.5,
px: 2,
}}
>
Share anonymous data
</Button>
</Box>
</Paper>
</Box>
);
};
export default AnalyticsOptIn;
@@ -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;
}
@@ -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;
}
@@ -1,15 +0,0 @@
import React from 'react';
import styles from './UnderConstruction.module.scss'; // CSS for styling the overlay
const UnderConstruction = () => {
return (
<div className={styles.under_construction_overlay}>
<img src="/hammer-icon.png" alt="Console Icon" />
<h2>Under Construction</h2>
<p>This feature is coming soon!</p>
</div>
);
};
export { UnderConstruction };
@@ -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<string, number>;
providers_used: Record<string, number>;
top_tools: Record<string, number>;
status_breakdown: Record<string, number>;
// 9Router enrichment
total_prompt_tokens: number;
total_completion_tokens: number;
cost_by_model: Record<string, { cost: number; requests: number; prompt_tokens: number; completion_tokens: number }>;
cost_by_provider: Record<string, { cost: number; requests: number }>;
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<string, any>;
by_provider: Record<string, any>;
}
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;
-10
View File
@@ -47,16 +47,6 @@ export function buildServeUrl(
return `${SERVE_BASE}/${outputId}/serve/index.html?_d=${encodeURIComponent(encoded)}`;
}
export function buildWorkspaceServeUrl(
workspaceId: string,
inputData: Record<string, any> = {},
backendResult: Record<string, any> | 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;
-2
View File
@@ -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,
},
});
@@ -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<string | null>) {
state.temp_state = action.payload;
},
resetTempState(state) {
state.temp_state = null;
},
setPendingBrowserUrl(state, action: PayloadAction<string>) {
state.pendingBrowserUrl = action.payload;
},
clearPendingBrowserUrl(state) {
state.pendingBrowserUrl = null;
},
setLastDashboardId(state, action: PayloadAction<string>) {
state.lastDashboardId = action.payload;
},
setPendingFocusAgentId(state, action: PayloadAction<string>) {
state.pendingFocusAgentId = action.payload;
},
@@ -44,11 +30,8 @@ const tempStateSlice = createSlice({
});
export const {
setTempState,
resetTempState,
setPendingBrowserUrl,
clearPendingBrowserUrl,
setLastDashboardId,
setPendingFocusAgentId,
clearPendingFocusAgentId,
} = tempStateSlice.actions;
@@ -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<string, any> }) => {
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,
-6
View File
@@ -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;
@@ -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
);
}
@@ -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
};
@@ -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
);
}
@@ -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
);
}
@@ -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;
}
-14
View File
@@ -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;
}
+1 -6
View File
@@ -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"]
}
+4 -10
View File
@@ -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
-4
View File
@@ -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')"