Files
openswarm/backend/apps/agents/tools/web.py
T
ciregenzandClaude Opus 4.6 b6f45e8412 [eric] multi-provider agent loop, PostHog analytics, settings overhaul
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>
2026-03-21 13:50:21 -07:00

207 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}"}]