"""Web search and fetch tools.""" from __future__ import annotations from html.parser import HTMLParser from typing import Any import httpx from opc.layer4_tools.output_budget import clip_text, persist_tool_result from opc.layer4_tools.registry import ToolDefinition class _ReadableHTMLParser(HTMLParser): def __init__(self) -> None: super().__init__() self._parts: list[str] = [] self._skip_depth = 0 def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: _ = attrs if tag in {"script", "style", "noscript"}: self._skip_depth += 1 if tag in {"p", "br", "div", "section", "article", "li", "tr", "h1", "h2", "h3", "h4"}: self._parts.append("\n") def handle_endtag(self, tag: str) -> None: if tag in {"script", "style", "noscript"} and self._skip_depth > 0: self._skip_depth -= 1 if tag in {"p", "div", "section", "article", "li", "tr"}: self._parts.append("\n") def handle_data(self, data: str) -> None: if self._skip_depth: return text = " ".join(str(data or "").split()) if text: self._parts.append(text + " ") def text(self) -> str: lines = [] for raw in "".join(self._parts).splitlines(): line = " ".join(raw.split()) if line: lines.append(line) return "\n".join(lines) def _html_to_text(value: str) -> str: parser = _ReadableHTMLParser() parser.feed(value) return parser.text() or value async def web_search(query: str, max_results: int = 5) -> dict[str, Any]: """Search the web using DuckDuckGo HTML scraping (no API key needed).""" try: async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.get( "https://html.duckduckgo.com/html/", params={"q": query}, headers={"User-Agent": "Mozilla/5.0 (compatible; OPC/1.0)"}, ) resp.raise_for_status() text = resp.text results: list[dict[str, str]] = [] import re links = re.findall(r'class="result__a"[^>]*href="([^"]*)"[^>]*>(.*?)', text) snippets = re.findall(r'class="result__snippet"[^>]*>(.*?)]+>', text, re.DOTALL) for i, (url, title) in enumerate(links[:max_results]): snippet = snippets[i].strip() if i < len(snippets) else "" snippet = re.sub(r"<[^>]+>", "", snippet).strip() title = re.sub(r"<[^>]+>", "", title).strip() results.append({"title": title, "url": url, "snippet": snippet}) return {"results": results, "query": query} except Exception as e: return {"error": str(e), "query": query} async def web_fetch( url: str, max_length: int = 20000, offset: int = 0, save_full: bool = True, task: Any | None = None, ) -> dict[str, Any]: """Fetch a URL and return its text content.""" try: async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client: resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0 (compatible; OPC/1.0)"}) resp.raise_for_status() content_type = resp.headers.get("content-type", "") if "text" in content_type or "json" in content_type or "xml" in content_type: text = _html_to_text(resp.text) if "html" in content_type else resp.text start = max(0, int(offset or 0)) limit = max(1, int(max_length or 20000)) sliced = text[start:] preview = clip_text(sliced, limit=limit, marker="web_fetch truncated") next_offset = start + preview.kept_chars if preview.truncated else None persisted = {} if save_full and (preview.truncated or start > 0): persisted = persist_tool_result( text, tool_name="web_fetch", task=task, extension="txt", ) return { "content": preview.text, "url": str(resp.url), "final_url": str(resp.url), "status": resp.status_code, "content_type": content_type, "total_chars": len(text), "offset": start, "max_length": limit, "truncated": preview.truncated, "omitted_chars": preview.omitted_chars, "next_offset": next_offset, "full_content_path": persisted.get("full_output_path", ""), "success": True, } else: return {"error": f"Unsupported content type: {content_type}", "url": str(resp.url)} except Exception as e: return {"error": str(e), "url": url} def create_web_tools() -> list[ToolDefinition]: return [ ToolDefinition( name="web_search", description="Search the web for information. Returns titles, URLs, and snippets.", parameters={ "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, "max_results": {"type": "integer", "description": "Max results", "default": 5}, }, "required": ["query"], }, func=web_search, category="search", ), ToolDefinition( name="web_fetch", description="Fetch a URL and return its text content.", parameters={ "type": "object", "properties": { "url": {"type": "string", "description": "URL to fetch"}, "max_length": {"type": "integer", "description": "Max content length", "default": 20000}, "offset": {"type": "integer", "description": "Character offset to start reading from", "default": 0}, "save_full": {"type": "boolean", "description": "Persist full fetched text when preview is truncated", "default": True}, }, "required": ["url"], }, func=web_fetch, category="search", self_bounded_output=True, max_result_chars=80_000, ), ]