feat(community): add Serply web search tool (#5023)

Add deerflow.community.serply.tools:web_search_tool, a Google SERP
provider for the web_search slot that also covers Google News and Google
Scholar through an optional `vertical` config option. Reads the key from
api_key in config.yaml or SERPLY_API_KEY, clamps max_results to Serply's
1-100 range, and returns the same structured JSON errors as the Serper
and Brave tools.

Register the provider in config.example.yaml, scripts/doctor.py,
scripts/wizard/providers.py, .env.example, backend/docs/CONFIGURATION.md,
the en/zh tools.mdx provider tabs, and tools/AGENTS.md. Tests mock httpx.
This commit is contained in:
Serply 2026-08-27 22:30:30 -04:00 committed by GitHub
parent e09b2d48df
commit 4dbfe37ff3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 601 additions and 4 deletions

View File

@ -1,6 +1,9 @@
# Serper API Key (Google Search) - https://serper.dev
SERPER_API_KEY=your-serper-api-key
# Serply API Key (Google Search, News and Scholar) - https://serply.io
SERPLY_API_KEY=your-serply-api-key
# TAVILY API Key
TAVILY_API_KEY=your-tavily-api-key

View File

@ -367,7 +367,7 @@ tools:
```
**Built-in Tools**:
- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Exa, InfoQuest, Tencent Cloud WSA, Firecrawl, fastCRW, GroundRoute)
- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Serply, Exa, InfoQuest, Tencent Cloud WSA, Firecrawl, fastCRW, GroundRoute)
- `web_fetch` - Fetch web pages (Jina AI, Crawl4AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute, Browserless)
- `web_capture` - Capture rendered webpage screenshots as artifacts (Browserless)
- `image_search` - Search for reference images (DuckDuckGo, InfoQuest, Serper, Brave)
@ -809,6 +809,7 @@ models:
- `TAVILY_API_KEY` - Tavily search API key
- `BRAVE_SEARCH_API_KEY` - Brave Search API key for `web_search` and `image_search`
- `SERPER_API_KEY` - Serper (Google Search/Images API) key for `web_search` and `image_search`
- `SERPLY_API_KEY` - [Serply](https://serply.io) key for `web_search` (Google Search, plus Google News and Google Scholar via `vertical`)
- `GROUNDROUTE_API_KEY` - GroundRoute meta-search API key for `web_search` and `web_fetch` (routes across Serper, Brave, Exa, Tavily, Firecrawl, Perplexity with gain-share pricing)
- `BROWSERLESS_TOKEN` - Browserless Cloud token for `web_capture` (optional for self-hosted Browserless)
- `DEER_FLOW_PROJECT_ROOT` - Project root for relative runtime paths

View File

@ -0,0 +1,3 @@
from .tools import web_search_tool
__all__ = ["web_search_tool"]

View File

@ -0,0 +1,211 @@
"""
Web search tool powered by the Serply API.
Serply returns live Google results as JSON. One API key covers the regular
web SERP plus the Google News and Google Scholar verticals, so a research run
can be pointed at recent coverage or at papers by switching ``vertical`` in
config.yaml. An API key is required. Sign up at https://serply.io and see
https://serply.io/docs for the endpoint reference.
"""
import html
import json
import logging
import os
import re
import httpx
from langchain.tools import tool
from deerflow.config import get_app_config
logger = logging.getLogger(__name__)
_SERPLY_BASE_URL = "https://api.serply.io/v1"
_DEFAULT_MAX_RESULTS = 5
# Serply accepts ``num`` values from 1 to 100 per request.
_SERPLY_MAX_RESULTS = 100
_DEFAULT_VERTICAL = "search"
# vertical -> (URL path segment, response key that holds the result rows)
_VERTICALS: dict[str, tuple[str, str]] = {
"search": ("search", "results"),
"news": ("news", "entries"),
"scholar": ("scholar", "articles"),
}
# Optional request parameters that are passed through from config.yaml as-is.
_PASSTHROUGH_PARAMS = ("gl", "hl")
_TAG_RE = re.compile(r"<[^>]+>")
_api_key_warned: set[str] = set()
def _get_api_key(tool_name: str = "web_search") -> str | None:
config = get_app_config().get_tool_config(tool_name)
if config is not None:
api_key = (config.model_extra or {}).get("api_key")
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
env_key = os.getenv("SERPLY_API_KEY")
if isinstance(env_key, str) and env_key.strip():
return env_key.strip()
return None
def _coerce_max_results(
value: object,
*,
default: int = _DEFAULT_MAX_RESULTS,
max_allowed: int = _SERPLY_MAX_RESULTS,
) -> int:
try:
coerced = int(value)
except (TypeError, ValueError):
logger.warning("Invalid Serply max_results=%r; using default %s", value, default)
coerced = default
if coerced < 1:
logger.warning("Invalid Serply max_results=%r; using default %s", value, default)
coerced = default
return min(coerced, max_allowed)
def _coerce_vertical(value: object) -> str:
if value is None:
return _DEFAULT_VERTICAL
if isinstance(value, str) and value.strip().lower() in _VERTICALS:
return value.strip().lower()
logger.warning("Invalid Serply vertical=%r; using %r (one of %s)", value, _DEFAULT_VERTICAL, sorted(_VERTICALS))
return _DEFAULT_VERTICAL
def _clean_query(query: str, *, max_length: int = 500) -> str:
query = query.strip()
if len(query) > max_length:
query = query[:max_length]
return query
def _clean_text(value: object) -> str:
"""Flatten a Serply text field to plain text (news summaries arrive as HTML)."""
if not isinstance(value, str):
return ""
return html.unescape(_TAG_RE.sub("", value)).strip()
def _missing_key_error(query: str, tool_name: str) -> str:
if tool_name not in _api_key_warned:
_api_key_warned.add(tool_name)
logger.warning(
"Serply API key is not set for '%s'. Set SERPLY_API_KEY in your environment or provide api_key in config.yaml. Sign up at https://serply.io",
tool_name,
)
return json.dumps({"error": "SERPLY_API_KEY is not configured", "query": query}, ensure_ascii=False)
def _unexpected_format_error(query: str) -> str:
return json.dumps({"error": "Serply returned an unexpected response format", "query": query}, ensure_ascii=False)
def _serply_get(path: str, api_key: str, query: str, params: dict[str, object]) -> tuple[dict | None, str | None]:
"""Send a GET request to a Serply endpoint.
Returns a ``(data, error_json)`` tuple: on success ``data`` is the parsed
JSON response and ``error_json`` is ``None``; on failure ``data`` is ``None``
and ``error_json`` is a serialized structured error ready to return.
"""
headers = {
"X-Api-Key": api_key,
"Accept": "application/json",
"User-Agent": "deerflow",
}
try:
with httpx.Client(timeout=30) as client:
response = client.get(f"{_SERPLY_BASE_URL}/{path}/", headers=headers, params=params)
response.raise_for_status()
data = response.json()
if not isinstance(data, dict):
logger.error("Serply returned an unexpected payload type: %s", type(data).__name__)
return None, _unexpected_format_error(query)
return data, None
except httpx.HTTPStatusError as e:
resp_text = (e.response.text or "")[:500]
logger.error("Serply API returned HTTP %s: %s", e.response.status_code, resp_text)
return None, json.dumps({"error": f"Serply API error: HTTP {e.response.status_code}", "query": query}, ensure_ascii=False)
except Exception as e:
logger.error("Serply request failed: %s: %s", type(e).__name__, str(e)[:500])
return None, json.dumps({"error": str(e)[:500], "query": query}, ensure_ascii=False)
def _normalize_row(vertical: str, row: dict) -> dict:
"""Map one Serply row onto the common ``title``/``url``/``content`` shape.
News and Scholar rows carry a few extra fields worth surfacing to the model.
"""
result = {"title": row.get("title", ""), "url": row.get("link", "")}
if vertical == "news":
result["content"] = _clean_text(row.get("summary"))
result["published"] = row.get("published", "")
source = row.get("source") if isinstance(row.get("source"), dict) else {}
result["source"] = source.get("title", "")
elif vertical == "scholar":
result["content"] = row.get("description", "")
author = row.get("author") if isinstance(row.get("author"), dict) else {}
authors = author.get("authors") if isinstance(author.get("authors"), list) else []
result["authors"] = [a.get("name", "") for a in authors if isinstance(a, dict)]
extras = row.get("extras") if isinstance(row.get("extras"), dict) else {}
citations = extras.get("citations") if isinstance(extras.get("citations"), dict) else {}
result["cited_by"] = citations.get("count", 0)
doc = row.get("doc") if isinstance(row.get("doc"), dict) else {}
result["pdf_url"] = doc.get("link", "")
else:
result["content"] = row.get("description", "")
return result
@tool("web_search", parse_docstring=True)
def web_search_tool(query: str, max_results: int = 5) -> str:
"""Search the web for information using Google Search via Serply.
Args:
query: Search keywords describing what you want to find. Be specific for better results.
max_results: Maximum number of search results to return. Default is 5, capped at 100.
"""
config = get_app_config().get_tool_config("web_search")
extra = (config.model_extra or {}) if config is not None else {}
if "max_results" in extra:
max_results = extra["max_results"]
max_results = _coerce_max_results(max_results)
vertical = _coerce_vertical(extra.get("vertical"))
query = _clean_query(query)
api_key = _get_api_key("web_search")
if not api_key:
return _missing_key_error(query, "web_search")
path, rows_key = _VERTICALS[vertical]
params: dict[str, object] = {"q": query, "num": max_results}
for key in _PASSTHROUGH_PARAMS:
if key in extra:
params[key] = extra[key]
data, error_json = _serply_get(path, api_key, query, params)
if error_json is not None:
return error_json
rows = data.get(rows_key)
if rows is None:
rows = []
if not isinstance(rows, list):
logger.error("Serply returned unexpected '%s' payload type: %s", rows_key, type(rows).__name__)
return _unexpected_format_error(query)
rows = [row for row in rows if isinstance(row, dict)]
if not rows:
return json.dumps({"error": "No results found", "query": query}, ensure_ascii=False)
# The news feed ignores ``num`` server-side, so the cap is also applied here.
normalized_results = [_normalize_row(vertical, row) for row in rows[:max_results]]
output = {
"query": query,
"total_results": len(normalized_results),
"results": normalized_results,
}
return json.dumps(output, indent=2, ensure_ascii=False)

View File

@ -28,7 +28,7 @@ Durable MCP task-management tools are added only while the process-local task su
- `browser_automation/` - Agentic browser control (stateful `navigate → observe → click/type` loop) via Playwright, distinct from the read-only `web_fetch`/`web_capture` tools. Tools: `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_get_text`, `browser_back`, `browser_screenshot`, `browser_close` (config `group: browser`). A process-local `BrowserSessionManager` owns one private, loop-affine Playwright event-loop thread (same pattern as the BoxLite provider) so a per-thread browser session survives across turns regardless of the caller's loop (Gateway / TUI / test). Each action returns a fresh page snapshot whose interactive elements are addressed by a stable numeric `[ref]` index (stamped as `data-df-ref` during snapshot), so the model acts on what it just observed instead of holding stale handles or guessing selectors. URLs are SSRF-screened via the shared `validate_public_http_url` (opt-out `allow_private_addresses` only for intentional internal targets). CDP attachment cannot install the request guard on an existing Chrome context, so `cdp_url` fails closed unless the operator explicitly sets `allow_unguarded_cdp: true` for a trusted local browser. Browser REST/Live access also requires an exact non-NULL thread owner, rather than the general legacy shared-thread policy, because retained pages may contain authenticated state. Session admission is a hard `max_sessions` cap: pinned Live/operation sessions are never evicted, and a new thread is rejected when no unpinned session can be closed; one Live viewer owns a session at a time. Optional dependency: `cd backend && uv sync --extra browser && uv run playwright install chromium`; `scripts/detect_uv_extras.py` preserves the extra when `config.yaml` enables `browser_navigate`, and Gateway startup fails fast if configured browser control cannot import Playwright. Tests: `tests/test_browser_automation.py` (mocked tools + a real-Chromium integration test guarded by `importorskip`); `tests/manual_browser_live_check.py` is a manual DeepSeek-driven end-to-end check (not collected by pytest).
Live UI input dispatch is kept independent from JPEG capture: non-move actions start a rate-limited background refresh loop, so pointer, wheel, or keyboard input stays responsive while continuous gestures still produce frames throughout the interaction.
Additional providers also live here (`boxlite`, `brave`, `browserless`, `crawl4ai`, `ddg_search`, `e2b_sandbox`, `exa`, `fastcrw`, `groundroute`, `infoquest`, `searxng`, `serper`, `tencent_wsa`, `tenki`); see each subpackage for specifics. `tencent_wsa` uses Tencent Cloud Web Search's service API key endpoint (`TENCENTCLOUD_WSA_APIKEY`), not Tencent Cloud SecretId/SecretKey signing. Its `max_results` is capped at 50; requests above 10 use Tencent's optional `Cnt` parameter, which needs a Tencent Cloud plan that supports it. E2B bootstrap is required. If it fails, the provider kills and closes the unusable remote sandbox. New sandbox creation raises an error. Warm-pool reclaim and remote discovery discard the sandbox and continue acquisition. E2B mounts remain optional.
Additional providers also live here (`boxlite`, `brave`, `browserless`, `crawl4ai`, `ddg_search`, `e2b_sandbox`, `exa`, `fastcrw`, `groundroute`, `infoquest`, `searxng`, `serper`, `serply`, `tencent_wsa`, `tenki`); see each subpackage for specifics. `tencent_wsa` uses Tencent Cloud Web Search's service API key endpoint (`TENCENTCLOUD_WSA_APIKEY`), not Tencent Cloud SecretId/SecretKey signing. Its `max_results` is capped at 50; requests above 10 use Tencent's optional `Cnt` parameter, which needs a Tencent Cloud plan that supports it. E2B bootstrap is required. If it fails, the provider kills and closes the unusable remote sandbox. New sandbox creation raises an error. Warm-pool reclaim and remote discovery discard the sandbox and continue acquisition. E2B mounts remain optional.
E2B output sync records remote file versions and actual host file metadata in a thread-local manifest. The manifest binds to the remote sandbox ID. A complete output listing removes entries for deleted files. This avoids repeat downloads when the host filesystem rounds modification times. A single release-time sync pass is bounded by aggregate ceilings (`_MAX_SYNC_TOTAL_BYTES`, `_MAX_SYNC_FILES`, `_SYNC_DEADLINE_SECONDS`) on top of the per-file `_MAX_DOWNLOAD_SIZE` cap, so a pathological outputs tree cannot make release download unboundedly; a truncated pass logs what it dropped and leaves the manifest un-pruned (only entries observed in that pass are reconciled), so files it never reached are retried on the next release rather than being forgotten.

View File

@ -367,6 +367,22 @@ class TestCheckWebSearch:
assert "tencent_wsa configured but TENCENTCLOUD_WSA_APIKEY not set" in result.detail
assert "TENCENTCLOUD_WSA_APIKEY" in (result.fix or "")
def test_serply_with_key_ok(self, tmp_path, monkeypatch):
monkeypatch.setenv("SERPLY_API_KEY", "test-key")
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.serply.tools:web_search_tool\n")
result = doctor.check_web_search(cfg)
assert result.status == "ok"
assert "serply" in result.detail
def test_serply_without_key_warns(self, tmp_path, monkeypatch):
monkeypatch.delenv("SERPLY_API_KEY", raising=False)
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.serply.tools:web_search_tool\n")
result = doctor.check_web_search(cfg)
assert result.status == "warn"
assert "SERPLY_API_KEY" in (result.fix or "")
def test_no_search_tool_warns(self, tmp_path):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools: []\n")

View File

@ -0,0 +1,318 @@
"""Unit tests for the Serply community web search tool."""
import json
import logging
from unittest.mock import MagicMock, patch
import httpx
import pytest
@pytest.fixture(autouse=True)
def reset_api_key_warned():
"""Reset the module-level warning flag before each test."""
import deerflow.community.serply.tools as serply_mod
serply_mod._api_key_warned = set()
yield
serply_mod._api_key_warned = set()
def _patch_config(extra: dict | None):
"""Patch get_app_config so web_search resolves to a tool config with ``extra``."""
patcher = patch("deerflow.community.serply.tools.get_app_config")
mock = patcher.start()
if extra is None:
mock.return_value.get_tool_config.return_value = None
else:
tool_config = MagicMock()
tool_config.model_extra = extra
mock.return_value.get_tool_config.return_value = tool_config
return patcher, mock
@pytest.fixture
def mock_config_with_key():
patcher, mock = _patch_config({"api_key": "test-serply-key", "max_results": 5})
yield mock
patcher.stop()
@pytest.fixture
def mock_config_no_key():
patcher, mock = _patch_config({})
yield mock
patcher.stop()
def _make_response(payload: object) -> MagicMock:
mock_resp = MagicMock()
mock_resp.json.return_value = payload
mock_resp.raise_for_status = MagicMock()
return mock_resp
def _search_rows(n: int) -> list[dict]:
return [{"title": f"Result {i}", "link": f"https://example.com/{i}", "description": f"Snippet {i}", "position": i} for i in range(1, n + 1)]
def _run(query: str = "test query", max_results: int = 5) -> dict:
from deerflow.community.serply.tools import web_search_tool
return json.loads(web_search_tool.invoke({"query": query, "max_results": max_results}))
class TestGetApiKey:
def test_returns_config_key_when_present(self):
patcher, _ = _patch_config({"api_key": "from-config"})
try:
from deerflow.community.serply.tools import _get_api_key
assert _get_api_key("web_search") == "from-config"
finally:
patcher.stop()
def test_falls_back_to_env_when_config_key_blank(self):
patcher, _ = _patch_config({"api_key": " "})
try:
with patch.dict("os.environ", {"SERPLY_API_KEY": "env-key"}):
from deerflow.community.serply.tools import _get_api_key
assert _get_api_key("web_search") == "env-key"
finally:
patcher.stop()
def test_falls_back_to_env_when_no_config(self):
patcher, _ = _patch_config(None)
try:
with patch.dict("os.environ", {"SERPLY_API_KEY": "env-only"}):
from deerflow.community.serply.tools import _get_api_key
assert _get_api_key("web_search") == "env-only"
finally:
patcher.stop()
def test_returns_none_when_no_key_anywhere(self):
patcher, _ = _patch_config(None)
try:
with patch.dict("os.environ", {}, clear=True):
from deerflow.community.serply.tools import _get_api_key
assert _get_api_key("web_search") is None
finally:
patcher.stop()
class TestCoerceMaxResults:
def test_returns_value_when_valid(self):
from deerflow.community.serply.tools import _coerce_max_results
assert _coerce_max_results(3) == 3
assert _coerce_max_results("7") == 7
def test_caps_at_serply_maximum(self):
from deerflow.community.serply.tools import _coerce_max_results
assert _coerce_max_results(999) == 100
def test_invalid_values_fall_back_to_default(self):
from deerflow.community.serply.tools import _coerce_max_results
assert _coerce_max_results("oops") == 5
assert _coerce_max_results(None) == 5
assert _coerce_max_results(0) == 5
assert _coerce_max_results(-3) == 5
class TestCoerceVertical:
def test_accepts_known_verticals(self):
from deerflow.community.serply.tools import _coerce_vertical
assert _coerce_vertical(None) == "search"
assert _coerce_vertical("news") == "news"
assert _coerce_vertical(" Scholar ") == "scholar"
def test_unknown_vertical_falls_back_to_search(self, caplog):
from deerflow.community.serply.tools import _coerce_vertical
with caplog.at_level(logging.WARNING):
assert _coerce_vertical("images") == "search"
assert "Invalid Serply vertical" in caplog.text
class TestWebSearchTool:
def test_basic_search_returns_normalized_results(self, mock_config_with_key):
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_client.return_value.__enter__.return_value.get.return_value = _make_response({"results": _search_rows(2)})
result = _run("test query")
assert result["query"] == "test query"
assert result["total_results"] == 2
assert result["results"][0] == {"title": "Result 1", "url": "https://example.com/1", "content": "Snippet 1"}
def test_sends_correct_headers_and_params(self, mock_config_with_key):
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_get = mock_client.return_value.__enter__.return_value.get
mock_get.return_value = _make_response({"results": _search_rows(1)})
_run(" padded query ")
args, kwargs = mock_get.call_args
assert args[0] == "https://api.serply.io/v1/search/"
assert kwargs["headers"]["X-Api-Key"] == "test-serply-key"
assert kwargs["headers"]["Accept"] == "application/json"
assert kwargs["params"] == {"q": "padded query", "num": 5}
def test_config_max_results_overrides_parameter(self):
patcher, _ = _patch_config({"api_key": "k", "max_results": 2})
try:
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_get = mock_client.return_value.__enter__.return_value.get
mock_get.return_value = _make_response({"results": _search_rows(5)})
result = _run("q", max_results=5)
finally:
patcher.stop()
assert mock_get.call_args.kwargs["params"]["num"] == 2
assert result["total_results"] == 2
def test_passes_through_locale_params_from_config(self):
patcher, _ = _patch_config({"api_key": "k", "gl": "fr", "hl": "fr", "ignored": "x"})
try:
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_get = mock_client.return_value.__enter__.return_value.get
mock_get.return_value = _make_response({"results": _search_rows(1)})
_run("q")
finally:
patcher.stop()
params = mock_get.call_args.kwargs["params"]
assert params["gl"] == "fr"
assert params["hl"] == "fr"
assert "ignored" not in params
def test_news_vertical_uses_news_endpoint_and_trims_client_side(self):
entries = [
{
"title": f"Story {i}",
"link": f"https://news.example.com/{i}",
"summary": "<a href='x'>Genuine&nbsp;attention</a> &amp; chatbots",
"published": "Mon, 01 Jun 2026 08:00:00 GMT",
"source": {"title": "Example Times"},
}
for i in range(10)
]
patcher, _ = _patch_config({"api_key": "k", "max_results": 3, "vertical": "news"})
try:
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_get = mock_client.return_value.__enter__.return_value.get
mock_get.return_value = _make_response({"entries": entries})
result = _run("q")
finally:
patcher.stop()
assert mock_get.call_args.args[0] == "https://api.serply.io/v1/news/"
assert result["total_results"] == 3
first = result["results"][0]
assert first["content"] == "Genuine\xa0attention & chatbots"
assert first["published"] == "Mon, 01 Jun 2026 08:00:00 GMT"
assert first["source"] == "Example Times"
def test_scholar_vertical_maps_authors_and_citations(self):
articles = [
{
"title": "Attention Is All You Need",
"link": "https://arxiv.org/abs/1706.03762",
"description": "The dominant sequence transduction models...",
"author": {
"names": "A Vaswani, N Shazeer - NeurIPS, 2017",
"authors": [{"name": "A Vaswani", "link": "https://openalex.org/A1"}, {"name": "N Shazeer", "link": "https://openalex.org/A2"}],
},
"extras": {"citations": {"count": 120000}},
"doc": {"link": "https://arxiv.org/pdf/1706.03762", "type": "PDF"},
},
{"title": "No metadata", "link": "https://example.org/paper"},
]
patcher, _ = _patch_config({"api_key": "k", "vertical": "scholar"})
try:
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_get = mock_client.return_value.__enter__.return_value.get
mock_get.return_value = _make_response({"articles": articles})
result = _run("transformers")
finally:
patcher.stop()
assert mock_get.call_args.args[0] == "https://api.serply.io/v1/scholar/"
assert result["results"][0]["authors"] == ["A Vaswani", "N Shazeer"]
assert result["results"][0]["cited_by"] == 120000
assert result["results"][0]["pdf_url"] == "https://arxiv.org/pdf/1706.03762"
assert result["results"][1] == {"title": "No metadata", "url": "https://example.org/paper", "content": "", "authors": [], "cited_by": 0, "pdf_url": ""}
def test_empty_results_returns_error_json(self, mock_config_with_key):
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_client.return_value.__enter__.return_value.get.return_value = _make_response({"results": []})
assert _run("nothing") == {"error": "No results found", "query": "nothing"}
def test_missing_results_key_is_treated_as_no_results(self, mock_config_with_key):
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_client.return_value.__enter__.return_value.get.return_value = _make_response({"results": None})
assert _run("nothing")["error"] == "No results found"
def test_unexpected_payload_shape_returns_error_json(self, mock_config_with_key):
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_client.return_value.__enter__.return_value.get.return_value = _make_response({"results": "not-a-list"})
assert "unexpected response format" in _run("q")["error"]
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_client.return_value.__enter__.return_value.get.return_value = _make_response(["not", "a", "dict"])
assert "unexpected response format" in _run("q")["error"]
def test_missing_api_key_returns_error_json_and_warns_once(self, mock_config_no_key, caplog):
with patch.dict("os.environ", {}, clear=True), caplog.at_level(logging.WARNING):
first = _run("q")
second = _run("q")
assert first == {"error": "SERPLY_API_KEY is not configured", "query": "q"}
assert second == first
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
assert "SERPLY_API_KEY" in warnings[0].getMessage()
def test_http_error_returns_structured_error(self, mock_config_with_key):
mock_resp = MagicMock()
mock_resp.status_code = 403
mock_resp.text = "Forbidden"
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError("403", request=MagicMock(), response=mock_resp)
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_client.return_value.__enter__.return_value.get.return_value = mock_resp
result = _run("q")
assert result == {"error": "Serply API error: HTTP 403", "query": "q"}
def test_network_exception_returns_error_json(self, mock_config_with_key):
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_client.return_value.__enter__.return_value.get.side_effect = httpx.ConnectError("boom")
result = _run("q")
assert result == {"error": "boom", "query": "q"}
def test_long_query_is_truncated(self, mock_config_with_key):
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_get = mock_client.return_value.__enter__.return_value.get
mock_get.return_value = _make_response({"results": _search_rows(1)})
_run("x" * 600)
assert len(mock_get.call_args.kwargs["params"]["q"]) == 500
def test_uses_env_key_when_config_absent(self):
patcher, _ = _patch_config(None)
try:
with patch.dict("os.environ", {"SERPLY_API_KEY": "env-key"}):
with patch("deerflow.community.serply.tools.httpx.Client") as mock_client:
mock_get = mock_client.return_value.__enter__.return_value.get
mock_get.return_value = _make_response({"results": _search_rows(1)})
result = _run("q")
finally:
patcher.stop()
assert result["total_results"] == 1
assert mock_get.call_args.kwargs["headers"]["X-Api-Key"] == "env-key"

View File

@ -730,6 +730,18 @@ tools:
# max_results: 5 # capped at 10 by the Serper provider
# # api_key: $SERPER_API_KEY # Optional explicit env-var reference
# Web search tool (uses Serply - Google Search API, requires SERPLY_API_KEY)
# Serply returns live Google results and covers Google News and Google Scholar
# with the same key. Sign up at https://serply.io (docs: https://serply.io/docs)
# - name: web_search
# group: web
# use: deerflow.community.serply.tools:web_search_tool
# max_results: 5 # Serply accepts 1-100 per request
# # vertical: search # search (default), news, or scholar
# # gl: us # Optional country code for the results
# # hl: en # Optional interface language
# # api_key: $SERPLY_API_KEY # Optional if the env var is set
# Web search tool (uses Brave Search API, requires BRAVE_SEARCH_API_KEY)
# Brave Search returns results from an independent index. Sign up at
# https://brave.com/search/api/ to get a key. Unlike the DuckDuckGo

View File

@ -113,7 +113,7 @@ Community tools connect the agent to external services. They are configured in `
### Web search
<Tabs items={["DuckDuckGo (default)", "Tavily", "Brave", "Exa", "InfoQuest", "Firecrawl", "GroundRoute"]}>
<Tabs items={["DuckDuckGo (default)", "Tavily", "Brave", "Serply", "Exa", "InfoQuest", "Firecrawl", "GroundRoute"]}>
<Tabs.Tab>
```yaml
tools:
@ -140,6 +140,18 @@ tools:
```
Results from Brave's independent index via the official [Brave Search API](https://brave.com/search/api/). Requires an API key. `max_results` is capped at 20.
No extra dependency required.
</Tabs.Tab>
<Tabs.Tab>
```yaml
tools:
- use: deerflow.community.serply.tools:web_search_tool
api_key: $SERPLY_API_KEY
# vertical: news # or scholar; defaults to regular Google Search
```
Live Google results as JSON. Requires a [Serply](https://serply.io) API key. Set `vertical: news` or `vertical: scholar` to search Google News or Google Scholar with the same key; `gl` and `hl` set the result country and language. See the [Serply docs](https://serply.io/docs) for details.
No extra dependency required.
</Tabs.Tab>

View File

@ -110,7 +110,7 @@ tools:
### 网络搜索
<Tabs items={["DuckDuckGo默认", "Tavily", "Brave", "Exa", "InfoQuest", "Firecrawl", "GroundRoute"]}>
<Tabs items={["DuckDuckGo默认", "Tavily", "Brave", "Serply", "Exa", "InfoQuest", "Firecrawl", "GroundRoute"]}>
<Tabs.Tab>
```yaml
tools:
@ -137,6 +137,18 @@ tools:
```
通过官方 [Brave Search API](https://brave.com/search/api/) 返回 Brave 独立索引的结果。需要 API Key`max_results` 上限为 20。
无需额外依赖。
</Tabs.Tab>
<Tabs.Tab>
```yaml
tools:
- use: deerflow.community.serply.tools:web_search_tool
api_key: $SERPLY_API_KEY
# vertical: news # 或 scholar默认为普通 Google 搜索
```
以 JSON 形式返回实时 Google 搜索结果。需要 [Serply](https://serply.io) API Key。设置 `vertical: news` 或 `vertical: scholar` 可用同一个 Key 搜索 Google News 或 Google Scholar`gl` 和 `hl` 用于设置结果的国家和语言。详见 [Serply 文档](https://serply.io/docs)。
无需额外依赖。
</Tabs.Tab>

View File

@ -496,6 +496,7 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
"fastcrw": "CRW_API_KEY",
"brave": "BRAVE_SEARCH_API_KEY",
"serper": "SERPER_API_KEY",
"serply": "SERPLY_API_KEY",
"tencent_wsa": "TENCENTCLOUD_WSA_APIKEY",
},
"web_fetch": {

View File

@ -606,6 +606,14 @@ SEARCH_PROVIDERS: list[SearchProvider] = [
env_var="BRAVE_SEARCH_API_KEY",
extra_config={"max_results": 5},
),
SearchProvider(
name="serply",
display_name="Serply",
description="Google Search, News and Scholar results, API key required",
use="deerflow.community.serply.tools:web_search_tool",
env_var="SERPLY_API_KEY",
extra_config={"max_results": 5},
),
SearchProvider(
name="groundroute",
display_name="GroundRoute",