mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-21 04:02:22 +02:00
212 lines
7.9 KiB
Python
212 lines
7.9 KiB
Python
"""Web tools: WebSearch and WebFetch."""
|
|
|
|
from __future__ import annotations
|
|
|
|
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.search_ddg import (
|
|
DDGRateLimited,
|
|
HTTP_TIMEOUT,
|
|
USER_AGENT,
|
|
strip_html,
|
|
)
|
|
from backend.apps.agents.tools.search_ddg import search_ddg as run_ddg_search
|
|
from backend.apps.agents.tools.ssrf_guard import SSRFBlocked, safe_fetch
|
|
|
|
P_MAX_OUTPUT_BYTES = 250 * 1024 # ~250 KB covers ~95% of articles/wikis/docs.
|
|
|
|
|
|
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
|
|
|
|
|
|
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:
|
|
return await run_ddg_search(query, num_results)
|
|
|
|
|
|
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": USER_AGENT},
|
|
timeout=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 = 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}"}]
|