mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 11:42:23 +02:00
301 lines
12 KiB
Python
301 lines
12 KiB
Python
"""Web tools: WebSearch and WebFetch."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
import re
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.agents.tools.base import BaseTool, ToolContext
|
|
from backend.apps.agents.tools.ssrf_guard import SSRFBlocked, safe_fetch
|
|
|
|
P_HTTP_TIMEOUT = 30
|
|
P_MAX_OUTPUT_BYTES = 250 * 1024 # ~250 KB covers ~95% of articles/wikis/docs.
|
|
P_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"
|
|
)
|
|
|
|
|
|
class DDGRateLimited(Exception):
|
|
"""DuckDuckGo answered with its throttle challenge (HTTP 202), not results.
|
|
|
|
Distinct from 'genuinely zero hits' so the caller can fail over to another
|
|
backend instead of reporting an empty search to the user. The throttle is
|
|
per-IP and burst-triggered; a quick retry on the same or the `lite` endpoint
|
|
does NOT clear it (both share the limiter), so the only cure is a different
|
|
backend or waiting it out."""
|
|
|
|
def anthropic_web_search_is_reliable(*, uses_direct_anthropic_api: bool,
|
|
is_pro: bool) -> bool:
|
|
"""Whether the CLI's built-in WebSearch is reliable enough to suppress the
|
|
DuckDuckGo fallback. The built-in tool fires an aux `claude-haiku` call, and
|
|
that call only authenticates when it reaches an ENTITLED Anthropic endpoint:
|
|
|
|
- `uses_direct_anthropic_api`: the session is pinned to a direct Anthropic
|
|
api-route model (base_url = api.anthropic.com with the user's own key),
|
|
so the haiku call hits Anthropic directly and works.
|
|
- `is_pro`: OpenSwarm Pro, entitled to the managed `anthropic` pool that
|
|
9Router's `anthropic/*` route resolves to.
|
|
|
|
A bare `anthropic_api_key` in settings is NOT sufficient: a SUBSCRIPTION-route
|
|
Claude model (e.g. `opus-4-8`, route=None) still sends the haiku call through
|
|
9Router to the managed pool, which 401s for non-Pro users ('Invalid bearer
|
|
token, reset after ~2m'). Only a `*-api` route model talks to Anthropic
|
|
directly. Everyone else keeps the free, always-working DDG path."""
|
|
return bool(uses_direct_anthropic_api or is_pro)
|
|
|
|
|
|
@typechecked
|
|
def should_register_web_mcp(
|
|
*,
|
|
model: str,
|
|
router_model_id: object,
|
|
api_type: Optional[str],
|
|
anthropic_api_key: Optional[str],
|
|
connection_mode: str,
|
|
) -> bool:
|
|
"""True when the agent loop must register the DDG-backed openswarm-web MCP because the
|
|
primary model has NO reliable native Anthropic web-search path. We prefer Anthropic's
|
|
hosted search (return False) whenever it's actually reachable, and cascade through our own
|
|
/api/web/search (Gemini -> OpenAI -> DuckDuckGo) otherwise. The three no-path cases:
|
|
a non-Claude primary, a custom-provider session (ANTHROPIC_BASE_URL points at 9Router with
|
|
no Claude connection), and a subscription-route Claude model on a non-Pro account (the
|
|
built-in WebSearch's aux haiku call 401s). Pro pool is deliberately NOT counted for a
|
|
non-Claude primary: spending it on WebSearch would drain the user's Claude turns."""
|
|
from backend.apps.agents.providers.registry import find_builtin_model as find_builtin_model
|
|
|
|
m = router_model_id if isinstance(router_model_id, str) else ""
|
|
primary_is_claude = m.startswith("cc/") or (
|
|
isinstance(router_model_id, str)
|
|
and not router_model_id.startswith(("cc/", "cx/", "gc/", "ag/", "gemini/"))
|
|
and api_type == "anthropic"
|
|
)
|
|
is_custom_session = api_type == "custom"
|
|
web_model_entry = find_builtin_model(model)
|
|
uses_direct_anthropic_api = (
|
|
web_model_entry is not None
|
|
and web_model_entry.get("route") == "api"
|
|
and web_model_entry.get("api") == "anthropic"
|
|
and bool(anthropic_api_key)
|
|
)
|
|
has_anthropic_path = (
|
|
not is_custom_session
|
|
and primary_is_claude
|
|
and anthropic_web_search_is_reliable(
|
|
uses_direct_anthropic_api=uses_direct_anthropic_api,
|
|
is_pro=(connection_mode in ("openswarm-pro", "free-trial")),
|
|
)
|
|
)
|
|
return not has_anthropic_path
|
|
|
|
|
|
def p_truncate(text: str, limit: int = P_MAX_OUTPUT_BYTES) -> str:
|
|
if len(text) > limit:
|
|
return text[:limit] + "\n... (output truncated)"
|
|
return text
|
|
|
|
|
|
def p_strip_html(raw_html: str) -> str:
|
|
"""Naive but effective HTML to plain-text conversion."""
|
|
text = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", raw_html, flags=re.DOTALL | re.IGNORECASE)
|
|
text = re.sub(r"<[^>]+>", " ", text)
|
|
text = html.unescape(text)
|
|
text = re.sub(r"[ \t]+", " ", text)
|
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
return text.strip()
|
|
|
|
|
|
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 DDGRateLimited:
|
|
return [{"type": "text", "text": (
|
|
"DuckDuckGo is rate-limiting this network right now (HTTP 202). "
|
|
"Wait a bit and retry, or use a different search source."
|
|
)}]
|
|
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=P_HTTP_TIMEOUT,
|
|
follow_redirects=True,
|
|
headers={"User-Agent": P_USER_AGENT},
|
|
) as client:
|
|
resp = await client.post(
|
|
"https://html.duckduckgo.com/html/",
|
|
data={"q": query},
|
|
)
|
|
# DDG serves its throttle challenge as 202 (a ~14KB no-results page), which is a 2xx so raise_for_status() sails right past it. Catch it explicitly so we report "rate-limited" instead of a bogus "no hits".
|
|
if resp.status_code == 202:
|
|
raise DDGRateLimited(query)
|
|
resp.raise_for_status()
|
|
|
|
body = resp.text
|
|
|
|
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
|
|
|
|
# Handle both class-before-href and href-before-class attribute orders.
|
|
link_match = re.search(
|
|
r'<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)</a>',
|
|
block,
|
|
flags=re.DOTALL,
|
|
)
|
|
if not link_match:
|
|
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))
|
|
|
|
# Drop sponsored rows: DDG ads point at its own y.js click-tracker (ad_domain/ad_provider) instead of a real uddg= redirect, so they'd otherwise show up as junk "duckduckgo.com/y.js?ad_..." results.
|
|
if "/y.js?" in raw_url or "ad_provider=" in raw_url or "ad_domain=" in raw_url:
|
|
continue
|
|
|
|
title = p_strip_html(link_match.group(2)).strip()
|
|
|
|
snippet_match = re.search(
|
|
r'<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>',
|
|
block,
|
|
flags=re.DOTALL,
|
|
)
|
|
snippet = p_strip_html(snippet_match.group(1)).strip() if snippet_match else ""
|
|
|
|
# DDG wraps URLs in a redirect; extract the real one.
|
|
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)
|
|
|
|
|
|
class WebFetchTool(BaseTool):
|
|
name = "WebFetch"
|
|
description = (
|
|
"Fetch the contents of a URL and return the extracted text. "
|
|
"HTML is stripped to plain text. Output capped at ~250 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:
|
|
resp = await safe_fetch(
|
|
url,
|
|
method="GET",
|
|
headers={"User-Agent": P_USER_AGENT},
|
|
timeout=P_HTTP_TIMEOUT,
|
|
)
|
|
resp.raise_for_status()
|
|
except SSRFBlocked as exc:
|
|
return [{"type": "text", "text": f"Refused to fetch {url}: {exc}"}]
|
|
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", "")
|
|
is_html = "html" in content_type or resp.text.strip().startswith("<!")
|
|
|
|
if is_html:
|
|
# Prefer trafilatura for main-content extraction; fall back to regex strip on apps/login walls/JS-heavy pages.
|
|
text: str | None = None
|
|
try:
|
|
import trafilatura # type: ignore
|
|
text = trafilatura.extract(
|
|
resp.text,
|
|
include_comments=False,
|
|
include_tables=True,
|
|
favor_precision=True,
|
|
)
|
|
except Exception:
|
|
text = None
|
|
if not text:
|
|
text = p_strip_html(resp.text)
|
|
else:
|
|
text = resp.text
|
|
|
|
text = p_truncate(text)
|
|
|
|
header = f"Contents of {url}:"
|
|
if prompt:
|
|
header += f"\n(Looking for: {prompt})"
|
|
|
|
return [{"type": "text", "text": f"{header}\n\n{text}"}]
|