|
@@ -0,0 +1,526 @@
|
|
|
|
|
+"""Agentic Validator 的受控公开网页搜索与读取适配层。
|
|
|
|
|
+
|
|
|
|
|
+本模块只提供两个只读能力:搜索公开页面和读取已授权 URL。它不复用
|
|
|
|
|
+业务 Browser,不持有 Cookie,也不提供点击、登录、脚本执行或文件下载。
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import asyncio
|
|
|
|
|
+from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
|
|
|
+from dataclasses import dataclass, field
|
|
|
|
|
+from datetime import datetime, timezone
|
|
|
|
|
+from hashlib import sha256
|
|
|
|
|
+from html.parser import HTMLParser
|
|
|
|
|
+import ipaddress
|
|
|
|
|
+import json
|
|
|
|
|
+import math
|
|
|
|
|
+import os
|
|
|
|
|
+import socket
|
|
|
|
|
+from typing import Any, Protocol
|
|
|
|
|
+from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
|
|
|
+
|
|
|
|
|
+import httpx
|
|
|
|
|
+
|
|
|
|
|
+from cyber_agent.tools.registry import ToolRegistry
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+MAX_SEARCH_RESULTS = 5
|
|
|
|
|
+MAX_REDIRECTS = 3
|
|
|
|
|
+MAX_RESPONSE_BYTES = 512 * 1024
|
|
|
|
|
+MAX_PAGE_CHARS = 16_000
|
|
|
|
|
+MAX_TRACE_PAGE_CHARS = 50_000
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class ValidatorWebError(RuntimeError):
|
|
|
|
|
+ """Validator 网页工具的可呈现错误。"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class ProviderUnavailable(ValidatorWebError):
|
|
|
|
|
+ """搜索 Provider未配置、无密钥或暂不可用。"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class UnsafeUrlError(ValidatorWebError):
|
|
|
|
|
+ """URL、DNS或重定向越过公开网络只读边界。"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class ValidatorWebSearchProvider(Protocol):
|
|
|
|
|
+ """搜索服务的可注入端口;实现只返回普通网页候选。"""
|
|
|
|
|
+
|
|
|
|
|
+ async def search(
|
|
|
|
|
+ self,
|
|
|
|
|
+ query: str,
|
|
|
|
|
+ max_results: int,
|
|
|
|
|
+ ) -> "ValidatorSearchResponse | Sequence[Mapping[str, Any]]":
|
|
|
|
|
+ ...
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True)
|
|
|
|
|
+class ValidatorSearchResponse:
|
|
|
|
|
+ """搜索适配器返回的结果和可选 Provider 成本。"""
|
|
|
|
|
+
|
|
|
|
|
+ results: Sequence[Mapping[str, Any]]
|
|
|
|
|
+ cost_usd: float = 0.0
|
|
|
|
|
+
|
|
|
|
|
+ def __post_init__(self) -> None:
|
|
|
|
|
+ if (
|
|
|
|
|
+ isinstance(self.cost_usd, bool)
|
|
|
|
|
+ or not isinstance(self.cost_usd, (int, float))
|
|
|
|
|
+ or not math.isfinite(float(self.cost_usd))
|
|
|
|
|
+ or self.cost_usd < 0
|
|
|
|
|
+ ):
|
|
|
|
|
+ raise ValueError("Validator search cost must be non-negative")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class SerperWebSearchProvider:
|
|
|
|
|
+ """Serper Google Search API 的轻量适配器。"""
|
|
|
|
|
+
|
|
|
|
|
+ endpoint = "https://google.serper.dev/search"
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(
|
|
|
|
|
+ self,
|
|
|
|
|
+ api_key: str | None = None,
|
|
|
|
|
+ *,
|
|
|
|
|
+ timeout_seconds: float = 15.0,
|
|
|
|
|
+ cost_per_search_usd: float = 0.0,
|
|
|
|
|
+ client_factory: Callable[[], httpx.AsyncClient] | None = None,
|
|
|
|
|
+ ) -> None:
|
|
|
|
|
+ self.api_key = (api_key or os.getenv("SERPER_API_KEY") or "").strip()
|
|
|
|
|
+ self.timeout_seconds = timeout_seconds
|
|
|
|
|
+ self.client_factory = client_factory
|
|
|
|
|
+ self.cost_per_search_usd = ValidatorSearchResponse(
|
|
|
|
|
+ results=[],
|
|
|
|
|
+ cost_usd=cost_per_search_usd,
|
|
|
|
|
+ ).cost_usd
|
|
|
|
|
+
|
|
|
|
|
+ async def search(self, query: str, max_results: int) -> ValidatorSearchResponse:
|
|
|
|
|
+ if not self.api_key:
|
|
|
|
|
+ raise ProviderUnavailable("SERPER_API_KEY is not configured")
|
|
|
|
|
+ client = (
|
|
|
|
|
+ self.client_factory()
|
|
|
|
|
+ if self.client_factory
|
|
|
|
|
+ else httpx.AsyncClient(
|
|
|
|
|
+ timeout=self.timeout_seconds,
|
|
|
|
|
+ trust_env=False,
|
|
|
|
|
+ follow_redirects=False,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ try:
|
|
|
|
|
+ async with client:
|
|
|
|
|
+ async with client.stream(
|
|
|
|
|
+ "POST",
|
|
|
|
|
+ self.endpoint,
|
|
|
|
|
+ headers={
|
|
|
|
|
+ "X-API-KEY": self.api_key,
|
|
|
|
|
+ "Content-Type": "application/json",
|
|
|
|
|
+ },
|
|
|
|
|
+ json={"q": query, "num": max_results},
|
|
|
|
|
+ ) as response:
|
|
|
|
|
+ response.raise_for_status()
|
|
|
|
|
+ chunks: list[bytes] = []
|
|
|
|
|
+ size = 0
|
|
|
|
|
+ async for chunk in response.aiter_bytes():
|
|
|
|
|
+ size += len(chunk)
|
|
|
|
|
+ if size > MAX_RESPONSE_BYTES:
|
|
|
|
|
+ raise ProviderUnavailable(
|
|
|
|
|
+ "Serper response exceeds the 512 KiB limit"
|
|
|
|
|
+ )
|
|
|
|
|
+ chunks.append(chunk)
|
|
|
|
|
+ payload = json.loads(b"".join(chunks))
|
|
|
|
|
+ except ProviderUnavailable:
|
|
|
|
|
+ raise
|
|
|
|
|
+ except (httpx.HTTPError, ValueError) as exc:
|
|
|
|
|
+ raise ProviderUnavailable(f"Serper search failed: {exc}") from exc
|
|
|
|
|
+ organic = payload.get("organic", []) if isinstance(payload, dict) else []
|
|
|
|
|
+ return ValidatorSearchResponse(
|
|
|
|
|
+ results=[
|
|
|
|
|
+ item for item in organic if isinstance(item, Mapping)
|
|
|
|
|
+ ][:max_results],
|
|
|
|
|
+ cost_usd=self.cost_per_search_usd,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class _VisibleTextParser(HTMLParser):
|
|
|
|
|
+ def __init__(self) -> None:
|
|
|
|
|
+ super().__init__(convert_charrefs=True)
|
|
|
|
|
+ self.title_parts: list[str] = []
|
|
|
|
|
+ self.text_parts: list[str] = []
|
|
|
|
|
+ self._ignored_depth = 0
|
|
|
|
|
+ self._in_title = False
|
|
|
|
|
+
|
|
|
|
|
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
|
|
|
+ del attrs
|
|
|
|
|
+ lowered = tag.lower()
|
|
|
|
|
+ if lowered in {"script", "style", "noscript", "svg"}:
|
|
|
|
|
+ self._ignored_depth += 1
|
|
|
|
|
+ if lowered == "title":
|
|
|
|
|
+ self._in_title = True
|
|
|
|
|
+
|
|
|
|
|
+ def handle_endtag(self, tag: str) -> None:
|
|
|
|
|
+ lowered = tag.lower()
|
|
|
|
|
+ if lowered in {"script", "style", "noscript", "svg"} and self._ignored_depth:
|
|
|
|
|
+ self._ignored_depth -= 1
|
|
|
|
|
+ if lowered == "title":
|
|
|
|
|
+ self._in_title = False
|
|
|
|
|
+
|
|
|
|
|
+ def handle_data(self, data: str) -> None:
|
|
|
|
|
+ text = " ".join(data.split())
|
|
|
|
|
+ if not text or self._ignored_depth:
|
|
|
|
|
+ return
|
|
|
|
|
+ self.text_parts.append(text)
|
|
|
|
|
+ if self._in_title:
|
|
|
|
|
+ self.title_parts.append(text)
|
|
|
|
|
+
|
|
|
|
|
+ @property
|
|
|
|
|
+ def title(self) -> str:
|
|
|
|
|
+ return " ".join(self.title_parts).strip()
|
|
|
|
|
+
|
|
|
|
|
+ @property
|
|
|
|
|
+ def text(self) -> str:
|
|
|
|
|
+ return "\n".join(self.text_parts).strip()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _utc_now() -> str:
|
|
|
|
|
+ return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _source_id(url: str) -> str:
|
|
|
|
|
+ return "src_" + sha256(url.encode("utf-8")).hexdigest()[:24]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _blocked_ip(address: str) -> bool:
|
|
|
|
|
+ ip = ipaddress.ip_address(address)
|
|
|
|
|
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
|
|
|
|
|
+ ip = ip.ipv4_mapped
|
|
|
|
|
+ return any((
|
|
|
|
|
+ ip.is_private,
|
|
|
|
|
+ ip.is_loopback,
|
|
|
|
|
+ ip.is_link_local,
|
|
|
|
|
+ ip.is_multicast,
|
|
|
|
|
+ ip.is_reserved,
|
|
|
|
|
+ ip.is_unspecified,
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def normalize_public_url(url: str) -> str:
|
|
|
|
|
+ """校验静态 URL 结构;DNS和每次重定向由异步检查继续确认。"""
|
|
|
|
|
+ if not isinstance(url, str) or not url.strip():
|
|
|
|
|
+ raise UnsafeUrlError("URL must be a non-empty string")
|
|
|
|
|
+ parsed = urlsplit(url.strip())
|
|
|
|
|
+ if parsed.scheme.lower() not in {"http", "https"}:
|
|
|
|
|
+ raise UnsafeUrlError("Only HTTP(S) URLs are allowed")
|
|
|
|
|
+ if parsed.username or parsed.password:
|
|
|
|
|
+ raise UnsafeUrlError("URLs with user information are not allowed")
|
|
|
|
|
+ host = (parsed.hostname or "").rstrip(".").lower()
|
|
|
|
|
+ if not host or host == "localhost" or host.endswith(".local"):
|
|
|
|
|
+ raise UnsafeUrlError("Local host names are not allowed")
|
|
|
|
|
+ try:
|
|
|
|
|
+ port = parsed.port
|
|
|
|
|
+ except ValueError as exc:
|
|
|
|
|
+ raise UnsafeUrlError(f"Invalid URL port: {exc}") from exc
|
|
|
|
|
+ if port not in {None, 80, 443}:
|
|
|
|
|
+ raise UnsafeUrlError("Only ports 80 and 443 are allowed")
|
|
|
|
|
+ try:
|
|
|
|
|
+ if _blocked_ip(host):
|
|
|
|
|
+ raise UnsafeUrlError("Private or special-purpose IPs are not allowed")
|
|
|
|
|
+ except ValueError:
|
|
|
|
|
+ pass
|
|
|
|
|
+ netloc = host
|
|
|
|
|
+ if port is not None:
|
|
|
|
|
+ netloc = f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
|
|
|
|
|
+ return urlunsplit((parsed.scheme.lower(), netloc, parsed.path or "/", parsed.query, ""))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _default_resolve(host: str, port: int) -> Sequence[str]:
|
|
|
|
|
+ loop = asyncio.get_running_loop()
|
|
|
|
|
+ infos = await loop.getaddrinfo(
|
|
|
|
|
+ host,
|
|
|
|
|
+ port,
|
|
|
|
|
+ family=socket.AF_UNSPEC,
|
|
|
|
|
+ type=socket.SOCK_STREAM,
|
|
|
|
|
+ )
|
|
|
|
|
+ return list({item[4][0] for item in infos})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def require_public_dns(
|
|
|
|
|
+ url: str,
|
|
|
|
|
+ resolver: Callable[[str, int], Awaitable[Sequence[str]]] | None = None,
|
|
|
|
|
+) -> str:
|
|
|
|
|
+ normalized, _host, _port, _addresses = await _resolve_public_url(
|
|
|
|
|
+ url,
|
|
|
|
|
+ resolver,
|
|
|
|
|
+ )
|
|
|
|
|
+ return normalized
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _resolve_public_url(
|
|
|
|
|
+ url: str,
|
|
|
|
|
+ resolver: Callable[[str, int], Awaitable[Sequence[str]]] | None,
|
|
|
|
|
+) -> tuple[str, str, int, list[str]]:
|
|
|
|
|
+ """解析一次公网地址,并返回后续建立连接所需的冻结 IP。"""
|
|
|
|
|
+ normalized = normalize_public_url(url)
|
|
|
|
|
+ parsed = urlsplit(normalized)
|
|
|
|
|
+ host = parsed.hostname or ""
|
|
|
|
|
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
|
|
|
+ try:
|
|
|
|
|
+ raw_addresses = await (resolver or _default_resolve)(host, port)
|
|
|
|
|
+ except (OSError, socket.gaierror) as exc:
|
|
|
|
|
+ raise UnsafeUrlError(f"URL host could not be resolved: {exc}") from exc
|
|
|
|
|
+ addresses = list(dict.fromkeys(raw_addresses))
|
|
|
|
|
+ if not addresses:
|
|
|
|
|
+ raise UnsafeUrlError("URL host resolved to no addresses")
|
|
|
|
|
+ for address in addresses:
|
|
|
|
|
+ if _blocked_ip(address):
|
|
|
|
|
+ raise UnsafeUrlError(
|
|
|
|
|
+ f"URL host resolves to a private or special-purpose IP: {address}"
|
|
|
|
|
+ )
|
|
|
|
|
+ return normalized, host, port, addresses
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _pinned_request_url(url: str, address: str) -> str:
|
|
|
|
|
+ """把逻辑 URL 替换为已校验 IP,防止 DNS 检查后再绑定。"""
|
|
|
|
|
+ parsed = urlsplit(url)
|
|
|
|
|
+ host = f"[{address}]" if ":" in address else address
|
|
|
|
|
+ if parsed.port is not None:
|
|
|
|
|
+ host = f"{host}:{parsed.port}"
|
|
|
|
|
+ return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, ""))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _host_header(url: str) -> str:
|
|
|
|
|
+ parsed = urlsplit(url)
|
|
|
|
|
+ host = parsed.hostname or ""
|
|
|
|
|
+ rendered = f"[{host}]" if ":" in host else host
|
|
|
|
|
+ if parsed.port is not None:
|
|
|
|
|
+ rendered = f"{rendered}:{parsed.port}"
|
|
|
|
|
+ return rendered
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _decode_page(body: bytes, content_type: str) -> tuple[str, str]:
|
|
|
|
|
+ mime = content_type.split(";", 1)[0].strip().lower()
|
|
|
|
|
+ allowed = {"text/html", "text/plain", "application/json", "text/json"}
|
|
|
|
|
+ if mime not in allowed and not mime.endswith("+json"):
|
|
|
|
|
+ raise ValidatorWebError(f"Unsupported page content type: {mime or 'unknown'}")
|
|
|
|
|
+ text = body.decode("utf-8", errors="replace")
|
|
|
|
|
+ if mime == "text/html":
|
|
|
|
|
+ parser = _VisibleTextParser()
|
|
|
|
|
+ parser.feed(text)
|
|
|
|
|
+ return parser.title, parser.text
|
|
|
|
|
+ if "json" in mime:
|
|
|
|
|
+ try:
|
|
|
|
|
+ parsed = json.loads(text)
|
|
|
|
|
+ text = json.dumps(parsed, ensure_ascii=False, sort_keys=True)
|
|
|
|
|
+ except ValueError:
|
|
|
|
|
+ pass
|
|
|
|
|
+ return "", text
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def fetch_public_page(
|
|
|
|
|
+ url: str,
|
|
|
|
|
+ *,
|
|
|
|
|
+ resolver: Callable[[str, int], Awaitable[Sequence[str]]] | None = None,
|
|
|
|
|
+ client_factory: Callable[[], httpx.AsyncClient] | None = None,
|
|
|
|
|
+) -> dict[str, Any]:
|
|
|
|
|
+ """逐跳校验并读取一个公开文本页面,响应正文严格限长。"""
|
|
|
|
|
+ current = url
|
|
|
|
|
+ client = (
|
|
|
|
|
+ client_factory()
|
|
|
|
|
+ if client_factory
|
|
|
|
|
+ else httpx.AsyncClient(
|
|
|
|
|
+ timeout=20.0,
|
|
|
|
|
+ trust_env=False,
|
|
|
|
|
+ follow_redirects=False,
|
|
|
|
|
+ headers={"Accept": "text/html,text/plain,application/json"},
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ async with client:
|
|
|
|
|
+ for hop in range(MAX_REDIRECTS + 1):
|
|
|
|
|
+ current, host, _port, addresses = await _resolve_public_url(
|
|
|
|
|
+ current,
|
|
|
|
|
+ resolver,
|
|
|
|
|
+ )
|
|
|
|
|
+ client.cookies.clear()
|
|
|
|
|
+ request = client.build_request(
|
|
|
|
|
+ "GET",
|
|
|
|
|
+ _pinned_request_url(current, addresses[0]),
|
|
|
|
|
+ headers={
|
|
|
|
|
+ "Accept": "text/html,text/plain,application/json",
|
|
|
|
|
+ "Connection": "close",
|
|
|
|
|
+ "Host": _host_header(current),
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ if urlsplit(current).scheme == "https":
|
|
|
|
|
+ request.extensions["sni_hostname"] = host
|
|
|
|
|
+ response = await client.send(request, stream=True)
|
|
|
|
|
+ try:
|
|
|
|
|
+ if response.status_code in {301, 302, 303, 307, 308}:
|
|
|
|
|
+ location = response.headers.get("location")
|
|
|
|
|
+ if not location:
|
|
|
|
|
+ raise ValidatorWebError("Redirect response has no Location header")
|
|
|
|
|
+ if hop >= MAX_REDIRECTS:
|
|
|
|
|
+ raise ValidatorWebError("Too many redirects")
|
|
|
|
|
+ current = urljoin(current, location)
|
|
|
|
|
+ continue
|
|
|
|
|
+ response.raise_for_status()
|
|
|
|
|
+ chunks: list[bytes] = []
|
|
|
|
|
+ size = 0
|
|
|
|
|
+ async for chunk in response.aiter_bytes():
|
|
|
|
|
+ size += len(chunk)
|
|
|
|
|
+ if size > MAX_RESPONSE_BYTES:
|
|
|
|
|
+ raise ValidatorWebError("Page exceeds the 512 KiB response limit")
|
|
|
|
|
+ chunks.append(chunk)
|
|
|
|
|
+ body = b"".join(chunks)
|
|
|
|
|
+ title, text = _decode_page(
|
|
|
|
|
+ body,
|
|
|
|
|
+ response.headers.get("content-type", ""),
|
|
|
|
|
+ )
|
|
|
|
|
+ truncated = len(text) > MAX_PAGE_CHARS
|
|
|
|
|
+ text = text[:MAX_PAGE_CHARS]
|
|
|
|
|
+ final_url = current
|
|
|
|
|
+ return {
|
|
|
|
|
+ "source_id": _source_id(final_url),
|
|
|
|
|
+ "url": url,
|
|
|
|
|
+ "final_url": final_url,
|
|
|
|
|
+ "title": title,
|
|
|
|
|
+ "content_type": response.headers.get("content-type", ""),
|
|
|
|
|
+ "retrieved_at": _utc_now(),
|
|
|
|
|
+ "content_sha256": sha256(body).hexdigest(),
|
|
|
|
|
+ "text": text,
|
|
|
|
|
+ "truncated": truncated,
|
|
|
|
|
+ "untrusted_material": True,
|
|
|
|
|
+ }
|
|
|
|
|
+ finally:
|
|
|
|
|
+ await response.aclose()
|
|
|
|
|
+ raise ValidatorWebError("Page could not be fetched")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+UsageRecorder = Callable[[int, int, float], Awaitable[None]]
|
|
|
|
|
+PageFetcher = Callable[..., Awaitable[dict[str, Any]]]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass
|
|
|
|
|
+class ValidatorToolLimits:
|
|
|
|
|
+ max_searches: int
|
|
|
|
|
+ max_opens: int
|
|
|
|
|
+ max_tool_calls: int
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass
|
|
|
|
|
+class ValidatorToolSession:
|
|
|
|
|
+ """单个 Scope Validator Trace 的私有工具、白名单和调用账本。"""
|
|
|
|
|
+
|
|
|
|
|
+ provider: ValidatorWebSearchProvider | None
|
|
|
|
|
+ allowed_urls: set[str]
|
|
|
|
|
+ limits: ValidatorToolLimits
|
|
|
|
|
+ usage_recorder: UsageRecorder | None = None
|
|
|
|
|
+ resolver: Callable[[str, int], Awaitable[Sequence[str]]] | None = None
|
|
|
|
|
+ page_fetcher: PageFetcher = fetch_public_page
|
|
|
|
|
+ search_calls: int = 0
|
|
|
|
|
+ open_calls: int = 0
|
|
|
|
|
+ tool_calls: int = 0
|
|
|
|
|
+ page_chars: int = 0
|
|
|
|
|
+ opened_source_ids: set[str] = field(default_factory=set)
|
|
|
|
|
+ _search_urls: set[str] = field(default_factory=set)
|
|
|
|
|
+
|
|
|
|
|
+ def registry(self) -> ToolRegistry:
|
|
|
|
|
+ registry = ToolRegistry()
|
|
|
|
|
+
|
|
|
|
|
+ async def validator_web_search(query: str, max_results: int = 5) -> dict[str, Any]:
|
|
|
|
|
+ return await self.search(query, max_results)
|
|
|
|
|
+
|
|
|
|
|
+ async def validator_open_url(url: str) -> dict[str, Any]:
|
|
|
|
|
+ return await self.open(url)
|
|
|
|
|
+
|
|
|
|
|
+ registry.register(validator_web_search)
|
|
|
|
|
+ registry.register(validator_open_url)
|
|
|
|
|
+ return registry
|
|
|
|
|
+
|
|
|
|
|
+ async def _reserve_call(self) -> None:
|
|
|
|
|
+ self.tool_calls += 1
|
|
|
|
|
+ if self.tool_calls > self.limits.max_tool_calls:
|
|
|
|
|
+ raise ValidatorWebError("Validator tool-call limit reached")
|
|
|
|
|
+ if self.usage_recorder:
|
|
|
|
|
+ await self.usage_recorder(1, 0, 0.0)
|
|
|
|
|
+
|
|
|
|
|
+ async def _record_material(
|
|
|
|
|
+ self,
|
|
|
|
|
+ material_chars: int,
|
|
|
|
|
+ provider_cost_usd: float = 0.0,
|
|
|
|
|
+ ) -> None:
|
|
|
|
|
+ if (material_chars or provider_cost_usd) and self.usage_recorder:
|
|
|
|
|
+ await self.usage_recorder(0, material_chars, provider_cost_usd)
|
|
|
|
|
+
|
|
|
|
|
+ async def search(self, query: str, max_results: int = 5) -> dict[str, Any]:
|
|
|
|
|
+ query = query.strip()
|
|
|
|
|
+ if not query:
|
|
|
|
|
+ raise ValidatorWebError("Search query must not be empty")
|
|
|
|
|
+ if isinstance(max_results, bool) or not 1 <= max_results <= MAX_SEARCH_RESULTS:
|
|
|
|
|
+ raise ValidatorWebError("max_results must be between 1 and 5")
|
|
|
|
|
+ if self.search_calls >= self.limits.max_searches:
|
|
|
|
|
+ raise ValidatorWebError("Validator search limit reached")
|
|
|
|
|
+ self.search_calls += 1
|
|
|
|
|
+ await self._reserve_call()
|
|
|
|
|
+ if self.provider is None:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "status": "provider_unavailable",
|
|
|
|
|
+ "query": query,
|
|
|
|
|
+ "results": [],
|
|
|
|
|
+ }
|
|
|
|
|
+ try:
|
|
|
|
|
+ provider_result = await self.provider.search(query, max_results)
|
|
|
|
|
+ except ProviderUnavailable as exc:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "status": "provider_unavailable",
|
|
|
|
|
+ "query": query,
|
|
|
|
|
+ "error": str(exc),
|
|
|
|
|
+ "results": [],
|
|
|
|
|
+ }
|
|
|
|
|
+ if isinstance(provider_result, ValidatorSearchResponse):
|
|
|
|
|
+ raw_results = provider_result.results
|
|
|
|
|
+ provider_cost_usd = provider_result.cost_usd
|
|
|
|
|
+ else:
|
|
|
|
|
+ raw_results = provider_result
|
|
|
|
|
+ provider_cost_usd = 0.0
|
|
|
|
|
+ results = []
|
|
|
|
|
+ for index, item in enumerate(raw_results[:max_results], start=1):
|
|
|
|
|
+ raw_url = str(item.get("url") or item.get("link") or "")
|
|
|
|
|
+ try:
|
|
|
|
|
+ normalized_url = normalize_public_url(raw_url)
|
|
|
|
|
+ except ValidatorWebError:
|
|
|
|
|
+ continue
|
|
|
|
|
+ self._search_urls.add(normalized_url)
|
|
|
|
|
+ results.append({
|
|
|
|
|
+ "source_id": _source_id(normalized_url),
|
|
|
|
|
+ "title": str(item.get("title") or ""),
|
|
|
|
|
+ "url": normalized_url,
|
|
|
|
|
+ "snippet": str(item.get("snippet") or ""),
|
|
|
|
|
+ "rank": index,
|
|
|
|
|
+ "retrieved_at": _utc_now(),
|
|
|
|
|
+ })
|
|
|
|
|
+ payload = {"status": "completed", "query": query, "results": results}
|
|
|
|
|
+ await self._record_material(
|
|
|
|
|
+ len(json.dumps(payload, ensure_ascii=False)),
|
|
|
|
|
+ provider_cost_usd,
|
|
|
|
|
+ )
|
|
|
|
|
+ return payload
|
|
|
|
|
+
|
|
|
|
|
+ async def open(self, url: str) -> dict[str, Any]:
|
|
|
|
|
+ if self.open_calls >= self.limits.max_opens:
|
|
|
|
|
+ raise ValidatorWebError("Validator page-open limit reached")
|
|
|
|
|
+ normalized = normalize_public_url(url)
|
|
|
|
|
+ normalized_allowed = {
|
|
|
|
|
+ normalize_public_url(item) for item in self.allowed_urls
|
|
|
|
|
+ }
|
|
|
|
|
+ if normalized not in normalized_allowed and normalized not in self._search_urls:
|
|
|
|
|
+ raise ValidatorWebError(
|
|
|
|
|
+ "URL was not declared by the TaskReport or returned by this search session"
|
|
|
|
|
+ )
|
|
|
|
|
+ self.open_calls += 1
|
|
|
|
|
+ await self._reserve_call()
|
|
|
|
|
+ page = await self.page_fetcher(normalized, resolver=self.resolver)
|
|
|
|
|
+ chars = len(str(page.get("text") or ""))
|
|
|
|
|
+ if self.page_chars + chars > MAX_TRACE_PAGE_CHARS:
|
|
|
|
|
+ raise ValidatorWebError("Validator page-text limit reached")
|
|
|
|
|
+ self.page_chars += chars
|
|
|
|
|
+ await self._record_material(chars)
|
|
|
|
|
+ self.opened_source_ids.add(str(page["source_id"]))
|
|
|
|
|
+ return page
|