feat(serper): support relative search time ranges (#6113)

* feat(serper): support relative search time ranges

Signed-off-by: 97three <2212371308@qq.com>

* docs(serper): address review and trim inherited guidance

---------

Signed-off-by: 97three <2212371308@qq.com>
This commit is contained in:
creed 2026-10-01 20:12:43 +08:00 committed by GitHub
parent a8dc1da205
commit 71f109275b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 88 additions and 12 deletions

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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}