mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-06 10:40:39 +00:00
* 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>
211 lines
8.7 KiB
Python
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]}"
|