"""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'