from __future__ import annotations import html import os import re import urllib.parse import httpx from oplog import log_event from ssrfguard import BlockedAddress, assert_public_url # Provider-agnostic web tools for the research agent: web_search + web_fetch. # Search uses Tavily when TAVILY_API_KEY is set, else keyless DuckDuckGo HTML. # Fetch is httpx + a stdlib HTML->text strip (no heavy deps). Under MOCK_MODE/ # WEB_TOOLS_MOCK both return canned data so the agent loop tests offline. MOCK = (os.environ.get("MOCK_MODE", "0").strip() in ("1", "true", "yes") or os.environ.get("WEB_TOOLS_MOCK", "0").strip() in ("1", "true", "yes")) _UA = {"User-Agent": "Mozilla/5.0 (compatible; loom-research/1.0)"} _FETCH_MAX = int(os.environ.get("WEB_FETCH_MAX_CHARS", "24000")) TOOLS_PROTOCOL = ( "\n\n## Research tools\n" "You can search and read the web. To use a tool, output EXACTLY one line and then STOP:\n" " ACTION: web_search {\"query\": \"\"}\n" " ACTION: web_fetch {\"url\": \"\"}\n" "After each action you will receive an `OBSERVATION:` with the result. Use tools to gather " "evidence, then write your FINAL answer with no ACTION line. Cite sources as [title](url). " "Be efficient: only search when it genuinely helps." ) _ACTION_RE = re.compile(r"ACTION:\s*(web_search|web_fetch)\s*(\{.*?\})", re.IGNORECASE | re.DOTALL) def parse_action(text: str): import json m = _ACTION_RE.search(text or "") if not m: return None tool = m.group(1).lower() try: args = json.loads(m.group(2)) except ValueError: return None if not isinstance(args, dict): return None return tool, args async def web_search(query: str, *, http_client: httpx.AsyncClient, max_results: int = 5) -> str: query = (query or "").strip() if not query: return "web_search error: empty query" if MOCK: return (f"1. Mock Result A - https://example.com/a\n Canned snippet about '{query}'.\n" f"2. Mock Result B - https://example.org/b\n Second canned snippet.") try: results = await _tavily(query, http_client, max_results) if _tavily_key() \ else await _ddg(query, http_client, max_results) except Exception as e: # noqa: BLE001 log_event("web_search_error", error=repr(e)[:160]) return f"web_search error: {type(e).__name__}" if not results: return "web_search: no results" lines = [] for i, r in enumerate(results[:max_results], 1): lines.append(f"{i}. {r['title']} - {r['url']}\n {r['snippet'][:300]}") return "\n".join(lines) async def web_fetch(url: str, *, http_client: httpx.AsyncClient, max_chars: int = _FETCH_MAX) -> str: url = (url or "").strip() if not url.startswith(("http://", "https://")): return "web_fetch error: url must start with http(s)://" if MOCK: return f"[mock page text for {url}] Lorem ipsum content used for offline testing." try: # SSRF guard: validate the host resolves to a PUBLIC address, and follow any # redirects MANUALLY (re-validating each hop) so a 302 can't slip to an internal # / cloud-metadata host. follow_redirects is off for exactly this reason. current = url for _hop in range(4): assert_public_url(current) r = await http_client.get(current, headers=_UA, timeout=12.0, follow_redirects=False) if r.status_code in (301, 302, 303, 307, 308): loc = r.headers.get("location") if not loc: return "web_fetch error: redirect without location" current = str(httpx.URL(r.url).join(loc)) continue if r.status_code >= 400: return f"web_fetch error: HTTP {r.status_code}" text = _html_to_text(r.text) return text[:max_chars] if text else "web_fetch: page had no extractable text" return "web_fetch error: too many redirects" except BlockedAddress as e: log_event("web_fetch_blocked", url=url[:120], reason=str(e)[:160]) return "web_fetch error: blocked address (non-public host)" except Exception as e: # noqa: BLE001 log_event("web_fetch_error", url=url[:120], error=repr(e)[:160]) return f"web_fetch error: {type(e).__name__}" def _tavily_key() -> str: return os.environ.get("TAVILY_API_KEY", "").strip() async def _tavily(query: str, client: httpx.AsyncClient, n: int) -> list[dict]: r = await client.post("https://api.tavily.com/search", timeout=20.0, json={ "api_key": _tavily_key(), "query": query, "max_results": n, "search_depth": "basic", "include_answer": False, }) r.raise_for_status() data = r.json() out = [] for item in (data.get("results") or []): out.append({"title": item.get("title", "")[:160], "url": item.get("url", ""), "snippet": (item.get("content") or "")[:400]}) return out _DDG_A = re.compile(r']*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)', re.DOTALL) _DDG_SNIP = re.compile(r'class="result__snippet"[^>]*>(.*?)', re.DOTALL) async def _ddg(query: str, client: httpx.AsyncClient, n: int) -> list[dict]: r = await client.get("https://html.duckduckgo.com/html/", headers=_UA, timeout=15.0, params={"q": query}, follow_redirects=True) r.raise_for_status() hrefs = _DDG_A.findall(r.text) snips = _DDG_SNIP.findall(r.text) out = [] for i, (href, title) in enumerate(hrefs[:n]): real = href m = re.search(r"uddg=([^&]+)", href) if m: real = urllib.parse.unquote(m.group(1)) snippet = _strip_tags(snips[i]) if i < len(snips) else "" out.append({"title": _strip_tags(title)[:160], "url": real, "snippet": snippet[:400]}) return out _SCRIPT_STYLE = re.compile(r"<(script|style|noscript)[^>]*>.*?", re.DOTALL | re.IGNORECASE) _TAG = re.compile(r"<[^>]+>") _WS = re.compile(r"\n\s*\n\s*\n+") def _strip_tags(s: str) -> str: return html.unescape(_TAG.sub("", s or "")).strip() def _html_to_text(page: str) -> str: page = _SCRIPT_STYLE.sub(" ", page or "") page = re.sub(r"<(br|/p|/div|/li|/h[1-6])\s*/?>", "\n", page, flags=re.IGNORECASE) text = html.unescape(_TAG.sub(" ", page)) text = re.sub(r"[ \t]+", " ", text) return _WS.sub("\n\n", text).strip()