diff --git a/README.md b/README.md index b42d767449..f8781453b7 100644 --- a/README.md +++ b/README.md @@ -1207,7 +1207,7 @@ uv run python -m deerflow.skills.review.cli ../skills/public/data-analysis --for Public-skill CI waivers are exact, expiring exceptions in `.github/skill-review-waivers.v1.json`. Because only the trusted base manifest can suppress a finding, a file-changing pull request can be preauthorized safely by first merging a manifest-only change that lists the reviewed future full-file SHA-256 in `preapproved_file_sha256s`; the file change can then land in a later pull request. -Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, rendered web capture, file operations, bash execution — and supports custom tools via MCP servers and Python functions. The bundled DDG, Brave, Tavily, and SearXNG search providers accept an optional `time_range` of `day`, `week`, `month`, or `year`; omitting it preserves existing search behavior. For DDG recency searches, DeerFlow excludes DDGS backends that ignore time limits. Swap anything. Add anything. +Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, rendered web capture, file operations, bash execution — and supports custom tools via MCP servers and Python functions. The bundled DDG, Brave, Tavily, SearXNG, and Serper search providers accept an optional `time_range` of `day`, `week`, `month`, or `year`; omitting it preserves existing search behavior. For DDG recency searches, DeerFlow excludes DDGS backends that ignore time limits. Swap anything. Add anything. For DDG web search and DDG image search, `max_results` in `config.yaml` can be a positive integer or an environment-variable reference such as `max_results: $DDG_MAX_RESULTS` with `DDG_MAX_RESULTS=5`. The configured value takes precedence over the tool call's `max_results` argument. Invalid values (for example, `abc`, an empty string, or `3.5`), zero, and negative counts produce a warning and fall back to the default of 5 results. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 28e72b8b36..0f4f197793 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -318,13 +318,13 @@ When using `make dev` from root, the frontend automatically connects through ngi ### Web Search Recency -DDG, Brave, Tavily, SearXNG, and Sofya `web_search` share optional -`time_range=day|week|month|year`; omission preserves request shape. DDG maps to -`d|w|m|y`, Brave to `pd|pw|pm|py`, Tavily/SearXNG pass values unchanged, and -Sofya passes them unchanged as `freshness`. -For recency, DDGS 9.14.1 uses only enabled Brave, DuckDuckGo, and Yahoo engines -that honor `timelimit`: `auto`/`all` resolves to this set, incompatible configured -engines are removed, and an empty set falls back to it. Re-check on DDGS upgrades. +Optional `web_search` `time_range=day|week|month|year` mappings: +DDG → `d|w|m|y`; Brave → `pd|pw|pm|py`; Tavily/SearXNG → unchanged; +Sofya → unchanged in `freshness`; Serper → `qdr:d|w|m|y` in `tbs`. +Omission preserves request shape; Serper null also omits `tbs`. Image search is unchanged. +DDGS 9.14.1 recency uses only enabled Brave/DuckDuckGo/Yahoo engines honoring +`timelimit`: `auto`/`all` selects this set; drop incompatible engines and fall back +to this set if empty. Re-check on DDGS upgrades. ### Tavily Fetch diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 11e85ab2e2..ac78610dc1 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -575,8 +575,14 @@ empty or omitted `include_domains`. These filters compose with `max_results` and the model's optional `time_range`. The model-visible arguments remain `query` and `time_range`; the filters do not apply to `web_fetch` or other search providers. +Serper `web_search` also accepts the optional model argument +`time_range: "day" | "week" | "month" | "year"`. For example, +`{"query": "Python releases", "time_range": "week"}` sends `tbs: "qdr:w"` +to Serper. Omitting `time_range` or passing `null` keeps the existing unrestricted +search request. This option does not change Serper `image_search`. + **Built-in Tools**: -- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Serply, Exa, InfoQuest, Tencent Cloud WSA, Firecrawl, fastCRW, GroundRoute, Sofya) +- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Serper, Serply, Exa, InfoQuest, Tencent Cloud WSA, Firecrawl, fastCRW, GroundRoute, Sofya) - `web_fetch` - Fetch web pages (Jina AI, Crawl4AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute, Browserless, Sofya, Unbrowse) - `web_capture` - Capture rendered webpage screenshots as artifacts (Browserless) - `image_search` - Search for reference images (DuckDuckGo, InfoQuest, Serper, Brave) diff --git a/backend/packages/harness/deerflow/community/serper/tools.py b/backend/packages/harness/deerflow/community/serper/tools.py index 8ca23ab957..bee14d8f14 100644 --- a/backend/packages/harness/deerflow/community/serper/tools.py +++ b/backend/packages/harness/deerflow/community/serper/tools.py @@ -14,6 +14,7 @@ from urllib.parse import urlparse import httpx from langchain.tools import tool +from deerflow.community.search_time_range import SearchTimeRange from deerflow.config import get_app_config logger = logging.getLogger(__name__) @@ -21,6 +22,12 @@ logger = logging.getLogger(__name__) _SERPER_SEARCH_ENDPOINT = "https://google.serper.dev/search" _SERPER_IMAGES_ENDPOINT = "https://google.serper.dev/images" _SERPER_MAX_RESULTS = 10 +_SERPER_TBS_BY_TIME_RANGE: dict[SearchTimeRange, str] = { + "day": "qdr:d", + "week": "qdr:w", + "month": "qdr:m", + "year": "qdr:y", +} _api_key_warned: set[str] = set() @@ -174,7 +181,7 @@ def _safe_public_url(value: object) -> str: return url if ip.is_global else "" -def _serper_post(endpoint: str, api_key: str, query: str, max_results: int) -> tuple[dict | None, str | None]: +def _serper_post(endpoint: str, api_key: str, query: str, max_results: int, *, time_range: SearchTimeRange | None = None) -> tuple[dict | None, str | None]: """Send a POST request to a Serper endpoint. ``query`` is expected to already be normalized via :func:`_clean_query`. @@ -188,6 +195,8 @@ def _serper_post(endpoint: str, api_key: str, query: str, max_results: int) -> t "Content-Type": "application/json", } payload = {"q": query, "num": max_results} + if time_range is not None: + payload["tbs"] = _SERPER_TBS_BY_TIME_RANGE[time_range] try: with httpx.Client(timeout=30) as client: @@ -211,12 +220,13 @@ def _serper_post(endpoint: str, api_key: str, query: str, max_results: int) -> t @tool("web_search", parse_docstring=True) -def web_search_tool(query: str, max_results: int = 5) -> str: +def web_search_tool(query: str, max_results: int = 5, time_range: SearchTimeRange | None = None) -> str: """Search the web for information using Google Search via Serper. 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 10. + time_range: Optional relative publication/update window. Use only when the request requires recent results. """ config = get_app_config().get_tool_config("web_search") if config is not None and "max_results" in config.model_extra: @@ -228,7 +238,7 @@ def web_search_tool(query: str, max_results: int = 5) -> str: if not api_key: return _missing_key_error(query, "web_search") - data, error_json = _serper_post(_SERPER_SEARCH_ENDPOINT, api_key, query, max_results) + data, error_json = _serper_post(_SERPER_SEARCH_ENDPOINT, api_key, query, max_results, time_range=time_range) if error_json is not None: return error_json diff --git a/backend/tests/test_serper_tools.py b/backend/tests/test_serper_tools.py index 199d680364..522534feb5 100644 --- a/backend/tests/test_serper_tools.py +++ b/backend/tests/test_serper_tools.py @@ -1219,3 +1219,63 @@ def test_coerce_max_results_inf_falls_back_to_default(): import deerflow.community.serper.tools as serper_mod assert serper_mod._coerce_max_results(float("inf")) == 5 + + +class TestSearchTimeRange: + @pytest.mark.parametrize(("time_range", "expected_tbs"), [("day", "qdr:d"), ("week", "qdr:w"), ("month", "qdr:m"), ("year", "qdr:y")]) + def test_forwards_native_time_filter(self, mock_config_with_key, time_range, expected_tbs): + from deerflow.community.serper.tools import web_search_tool + + with patch("deerflow.community.serper.tools.httpx.Client") as client: + post = client.return_value.__enter__.return_value.post + post.return_value = _make_serper_response([{"title": "Recent result", "link": "https://example.com", "snippet": "Recent content"}]) + result = json.loads(web_search_tool.invoke({"query": " recent releases ", "time_range": time_range})) + + assert post.call_args.args == ("https://google.serper.dev/search",) + assert post.call_args.kwargs["json"] == {"q": "recent releases", "num": 5, "tbs": expected_tbs} + assert result == {"query": "recent releases", "total_results": 1, "results": [{"title": "Recent result", "url": "https://example.com", "content": "Recent content"}]} + + @pytest.mark.parametrize("arguments", [{"query": "releases"}, {"query": "releases", "time_range": None}]) + def test_omitted_or_null_keeps_original_request(self, mock_config_with_key, arguments): + from deerflow.community.serper.tools import web_search_tool + + with patch("deerflow.community.serper.tools.httpx.Client") as client: + post = client.return_value.__enter__.return_value.post + post.return_value = _make_serper_response([]) + web_search_tool.invoke(arguments) + + assert post.call_args.kwargs["json"] == {"q": "releases", "num": 5} + + @pytest.mark.parametrize("time_range", ["hour", "", "WEEK", 7]) + def test_invalid_time_range_rejected_before_http(self, mock_config_with_key, time_range): + from pydantic import ValidationError + + from deerflow.community.serper.tools import web_search_tool + + with patch("deerflow.community.serper.tools.httpx.Client") as client: + client.return_value.__enter__.return_value.post.return_value = _make_serper_response([]) + with pytest.raises(ValidationError): + web_search_tool.invoke({"query": "releases", "time_range": time_range}) + client.assert_not_called() + + def test_tool_schema_advertises_optional_shared_values(self): + from deerflow.community.serper.tools import web_search_tool + + schema = web_search_tool.args_schema.model_json_schema() + parameter = schema["properties"]["time_range"] + assert "time_range" not in schema["required"] + assert parameter["default"] is None + assert {"$ref": "#/$defs/SearchTimeRange"} in parameter["anyOf"] + assert schema["$defs"]["SearchTimeRange"]["enum"] == ["day", "week", "month", "year"] + + def test_image_search_keeps_its_schema_and_payload(self, mock_config_with_key): + from deerflow.community.serper.tools import image_search_tool + + assert "time_range" not in image_search_tool.args_schema.model_json_schema()["properties"] + with patch("deerflow.community.serper.tools.httpx.Client") as client: + post = client.return_value.__enter__.return_value.post + post.return_value = _make_serper_images_response([]) + image_search_tool.invoke({"query": "landscape"}) + + assert post.call_args.args == ("https://google.serper.dev/images",) + assert post.call_args.kwargs["json"] == {"q": "landscape", "num": 5}