mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-22 01:24:52 +02:00
Multi-provider support (WIP - not fully tested): - Owned agent loop replacing claude_agent_sdk (agent_loop.py, mcp_client.py) - Provider adapters: Anthropic (native), OpenAI-compat (any endpoint), Gemini (native + schema cleaning) - 19 models across 9 providers (Anthropic, OpenAI, Google, xAI, Meta, DeepSeek, Mistral, Qwen, Cohere) - OpenRouter integration for 300+ models via single API key - Builtin tool reimplementations (Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch, AskUserQuestion) - Standalone MCP client manager (stdio/sse/http) - Frontend: grouped model dropdown, provider selection, dynamic context windows Analytics (tested): - PostHog integration as single analytics source - Tracks: app.opened, session.started/completed, tool.called, tool.approval_resolved, error.occurred - Rich session data: user messages, assistant messages, session titles, tools used, MCP servers, task categories - PostHog dashboard with 14 insights created via API - Usage stats in Settings (Usage tab) with pixel-art bars Settings (tested): - 4 tabs: General, Models, Usage, Commands - Model Providers tab with OpenRouter (recommended), Anthropic, OpenAI, Google key fields - "Get key" links for each provider - Usage tab with session/cost/tool stats + analytics opt-in toggle - analytics_opt_in defaults to true, installation_id auto-generated Merged haik/updates-v1 (tested): - Sub-agent spawning, chat branching, browser control improvements - Settings: auto_select_mode, expand_new_chats, auto_reveal_sub_agents, dev_mode Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
207 lines
7.2 KiB
Python
207 lines
7.2 KiB
Python
"""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 (compatible; SelfSwarmBot/1.0; +https://github.com/openswarm-ai/self-swarm)"
|
||
)
|
||
|
||
|
||
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
|
||
link_match = re.search(
|
||
r'<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)</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}"}]
|