Younes Beriane 1794c40888
fix(community): screen web_search result URLs in every provider (#7037)
* fix(community): screen tavily and exa web_search result URLs

The Tavily and Exa web_search tools returned provider URLs to the model
unfiltered, so a loopback, link-local or metadata-service address in a
result reached the agent. Brave and Serper already pass result URLs
through safe_public_result_url; do the same here, blanking a non-public
URL while keeping the row's title and snippet.

* fix(community): screen result URLs in the remaining web_search providers

youcom, tencent_wsa, serply (url and scholar pdf_url), groundroute, sofya,
ddg_search, fastcrw, firecrawl and searxng handed provider-supplied URLs
to the model unfiltered. Route each through safe_public_result_url, as
brave, serper, tavily and exa do: a non-public URL is blanked and the
row's title and snippet are kept.

The test now drives every listed provider's web_search tool with a
stubbed provider payload carrying a metadata, loopback or public URL.

* fix(community): screen brave, serper and webz web_search result URLs

The remaining text-search providers still returned result URLs verbatim.
Serper's comment documented that policy; it now screens organic links like
its image results. The shared provider test covers all fourteen providers
and asserts the snippet survives screening.

* docs(tests): describe text-search URL screening coverage

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-22 09:23:31 +08:00

211 lines
8.7 KiB
Python

"""GroundRoute community web search + fetch tools.
GroundRoute (https://groundroute.ai) is a meta search layer: one API in front of
six search engines (Serper, Brave, Exa, Tavily, Firecrawl, Perplexity). It routes
each query to the cheapest engine that clears a quality bar and caches repeats, so
high-volume research runs keep working when one engine is down and pay no more than
going to a single engine direct. Pricing is gain-share: the caller keeps about half
of any cache savings.
This module is self-contained (httpx only, no GroundRoute SDK). The /v1/search
request and response mapping mirrors the GroundRoute MCP server and the verified
Langflow component:
results[] = {url, title, snippet, content, source_engine, published_at}
`web_search` returns a normalized JSON list of {title, url, snippet, source_engine}.
`web_fetch` reads one URL via GroundRoute mode=page and returns its extracted text.
"""
import asyncio
import json
import logging
import os
import httpx
from langchain.tools import tool
from deerflow.community.url_safety import safe_public_result_url
from deerflow.config import get_app_config
logger = logging.getLogger(__name__)
_GROUNDROUTE_ENDPOINT = "https://api.groundroute.ai/v1/search"
_DEFAULT_MAX_RESULTS = 5
# GroundRoute clamps max_results to 1-50 server-side; clamp here too to mirror it.
_MAX_RESULTS_CAP = 50
_TIMEOUT_S = 30.0
_FETCH_SNIPPET_LIMIT = 4096
# Warn at most once per tool ("web_search" / "web_fetch") about a missing key.
_api_key_warned: set[str] = set()
def _resolve_tool_settings(tool_name: str) -> tuple[str | None, object]:
"""Resolve the API key and the configured `max_results` in one config read.
Config resolution stats the config file, so callers must run this off the
agent event loop. Reading the key and the result count from a single
resolution keeps the tool from touching the config file twice per call.
`tool_name` is the config section to read (web_search vs web_fetch) so a
flow that runs GroundRoute for fetch but a different engine for search
still reads the right key.
"""
tool_config = get_app_config().get_tool_config(tool_name)
max_results = None
api_key = None
if tool_config is not None:
extra = tool_config.model_extra or {}
max_results = extra.get("max_results")
candidate = extra.get("api_key")
if isinstance(candidate, str) and candidate.strip():
api_key = candidate.strip()
if api_key is None:
api_key = os.getenv("GROUNDROUTE_API_KEY")
return api_key, max_results
def _coerce_max_results(value: object, *, default: int = _DEFAULT_MAX_RESULTS) -> int:
try:
coerced = int(value)
except (TypeError, ValueError, OverflowError):
logger.warning("Invalid GroundRoute max_results=%r; using default %s", value, default)
coerced = default
return max(1, min(coerced, _MAX_RESULTS_CAP))
def _missing_key_error(tool_name: str, **context: str) -> str:
if tool_name not in _api_key_warned:
_api_key_warned.add(tool_name)
logger.warning(
"GroundRoute API key is not set for '%s'. Set GROUNDROUTE_API_KEY in your environment or provide api_key in config.yaml. Get a free key at https://groundroute.ai/keys",
tool_name,
)
return json.dumps({"error": "GROUNDROUTE_API_KEY is not configured", **context}, ensure_ascii=False)
def _unexpected_format_error(query: str) -> str:
return json.dumps({"error": "GroundRoute returned an unexpected response format", "query": query}, ensure_ascii=False)
def _response_results(data: object) -> list[dict] | None:
"""Return the result objects of a GroundRoute response, or None if malformed.
A missing or null ``results`` is an empty search. A non-list container, or a
non-empty list holding no objects, is malformed: the caller reports a format
error rather than "No results found", which would tell the agent the query
legitimately matched nothing.
"""
if not isinstance(data, dict):
logger.error("GroundRoute returned unexpected payload type: %s", type(data).__name__)
return None
results = data.get("results")
if results is None:
return []
if not isinstance(results, list):
logger.error("GroundRoute returned unexpected 'results' payload type: %s", type(results).__name__)
return None
usable = [item for item in results if isinstance(item, dict)]
if results and not usable:
logger.error("GroundRoute returned 'results' with no usable result objects")
return None
return usable
async def _post_search(api_key: str, body: dict) -> dict:
async with httpx.AsyncClient(timeout=_TIMEOUT_S) as client:
response = await client.post(
_GROUNDROUTE_ENDPOINT,
json=body,
headers={"Authorization": f"Bearer {api_key}"},
)
response.raise_for_status()
return response.json()
@tool("web_search", parse_docstring=True)
async def web_search_tool(query: str, max_results: int | None = None) -> str:
"""Search the web for information using GroundRoute.
GroundRoute routes the query across six search engines and returns the result
set from the engine it selected, with failover if one engine is unavailable.
Args:
query: Search keywords describing what you want to find. Be specific for better results.
max_results: Maximum number of search results to return. If omitted, uses the configured value (default 5). Clamped to 1-50.
"""
# Config resolution can read files; keep it off the event loop.
api_key, configured_max_results = await asyncio.to_thread(_resolve_tool_settings, "web_search")
# Honor the caller-supplied max_results; fall back to config only when omitted.
if max_results is None:
max_results = configured_max_results
count = _DEFAULT_MAX_RESULTS if max_results is None else _coerce_max_results(max_results)
if not api_key:
return _missing_key_error("web_search", query=query)
try:
data = await _post_search(api_key, {"query": query, "max_results": count})
except httpx.HTTPStatusError as e:
logger.error("GroundRoute API returned HTTP %s: %s", e.response.status_code, e.response.text)
return json.dumps(
{"error": f"GroundRoute API error: HTTP {e.response.status_code}", "query": query},
ensure_ascii=False,
)
except Exception as e:
logger.error("GroundRoute search failed: %s: %s", type(e).__name__, e)
return json.dumps({"error": str(e), "query": query}, ensure_ascii=False)
results = _response_results(data)
if results is None:
return _unexpected_format_error(query)
if not results:
return json.dumps({"error": "No results found", "query": query}, ensure_ascii=False)
normalized_results = [
{
"title": r.get("title", ""),
"url": safe_public_result_url(r.get("url")),
"snippet": r.get("snippet", ""),
"source_engine": r.get("source_engine", ""),
}
for r in results
]
return json.dumps(normalized_results, indent=2, ensure_ascii=False)
@tool("web_fetch", parse_docstring=True)
async def web_fetch_tool(url: str) -> str:
"""Fetch the contents of a web page at a given URL via GroundRoute.
Only fetch EXACT URLs that have been provided directly by the user or have been returned in results from the web_search and web_fetch tools.
This tool can NOT access content that requires authentication, such as private Google Docs or pages behind login walls.
Do NOT add www. to URLs that do NOT have them.
URLs must include the schema: https://example.com is a valid URL while example.com is an invalid URL.
Args:
url: The URL to fetch the contents of.
"""
api_key, _ = await asyncio.to_thread(_resolve_tool_settings, "web_fetch")
if not api_key:
return _missing_key_error("web_fetch", url=url)
try:
data = await _post_search(api_key, {"query": url, "mode": "page", "max_results": 1})
except httpx.HTTPStatusError as e:
logger.error("GroundRoute fetch returned HTTP %s: %s", e.response.status_code, e.response.text)
return f"Error: GroundRoute API error: HTTP {e.response.status_code}"
except Exception as e:
logger.error("GroundRoute fetch failed: %s: %s", type(e).__name__, e)
return f"Error: {e}"
results = _response_results(data)
if results is None:
return "Error: GroundRoute returned an unexpected response format"
if not results:
return "Error: No results found"
result = results[0]
content = result.get("content") or result.get("snippet") or ""
content = content if isinstance(content, str) else str(content)
title = result.get("title") or ""
return f"# {title}\n\n{content[:_FETCH_SNIPPET_LIMIT]}"