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)[^>]*>.*?\1>", 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()