diff --git a/.env.example b/.env.example index d1ed25e28..88a7b2f46 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index bc8374eef..644a0a4f0 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -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 diff --git a/backend/packages/harness/deerflow/community/serply/__init__.py b/backend/packages/harness/deerflow/community/serply/__init__.py new file mode 100644 index 000000000..876167859 --- /dev/null +++ b/backend/packages/harness/deerflow/community/serply/__init__.py @@ -0,0 +1,3 @@ +from .tools import web_search_tool + +__all__ = ["web_search_tool"] diff --git a/backend/packages/harness/deerflow/community/serply/tools.py b/backend/packages/harness/deerflow/community/serply/tools.py new file mode 100644 index 000000000..7d476d959 --- /dev/null +++ b/backend/packages/harness/deerflow/community/serply/tools.py @@ -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) diff --git a/backend/packages/harness/deerflow/tools/AGENTS.md b/backend/packages/harness/deerflow/tools/AGENTS.md index 47549a811..bbbc242be 100644 --- a/backend/packages/harness/deerflow/tools/AGENTS.md +++ b/backend/packages/harness/deerflow/tools/AGENTS.md @@ -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. diff --git a/backend/tests/test_doctor.py b/backend/tests/test_doctor.py index 88523bae1..d92b145e5 100644 --- a/backend/tests/test_doctor.py +++ b/backend/tests/test_doctor.py @@ -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") diff --git a/backend/tests/test_serply_tools.py b/backend/tests/test_serply_tools.py new file mode 100644 index 000000000..89fed878e --- /dev/null +++ b/backend/tests/test_serply_tools.py @@ -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": "Genuine attention & 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" diff --git a/config.example.yaml b/config.example.yaml index 4377f3619..0d50d0cd7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/frontend/src/content/en/harness/tools.mdx b/frontend/src/content/en/harness/tools.mdx index 5fc6dc1d8..c3294c784 100644 --- a/frontend/src/content/en/harness/tools.mdx +++ b/frontend/src/content/en/harness/tools.mdx @@ -113,7 +113,7 @@ Community tools connect the agent to external services. They are configured in ` ### Web search - + ```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. + + + +```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. diff --git a/frontend/src/content/zh/harness/tools.mdx b/frontend/src/content/zh/harness/tools.mdx index ba544febd..a701c10d4 100644 --- a/frontend/src/content/zh/harness/tools.mdx +++ b/frontend/src/content/zh/harness/tools.mdx @@ -110,7 +110,7 @@ tools: ### 网络搜索 - + ```yaml tools: @@ -137,6 +137,18 @@ tools: ``` 通过官方 [Brave Search API](https://brave.com/search/api/) 返回 Brave 独立索引的结果。需要 API Key,`max_results` 上限为 20。 +无需额外依赖。 + + + +```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)。 + 无需额外依赖。 diff --git a/scripts/doctor.py b/scripts/doctor.py index b77485c50..3304b6f09 100644 --- a/scripts/doctor.py +++ b/scripts/doctor.py @@ -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": { diff --git a/scripts/wizard/providers.py b/scripts/wizard/providers.py index bb60fdf92..f655b2bb9 100644 --- a/scripts/wizard/providers.py +++ b/scripts/wizard/providers.py @@ -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",