mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
a8dc1da205
commit
71f109275b
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user