mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(tools): make the search coercion guards catch OverflowError
The community search tools coerce max_results (and Sofya's content
limit) from YAML config or tool parameters through guards that catch
only TypeError and ValueError. `int(float('inf'))` raises OverflowError
instead, so a YAML `.inf` (or a JSON `Infinity`) value still crashes
the coercion - SearXNG's search even fails wholesale with
'cannot convert float infinity to integer'.
Catch OverflowError alongside them so an infinite value falls back to
the default like every other invalid input. NaN is already covered by
ValueError.
Signed-off-by: yetuge <2219677952@qq.com>
* test(tools): move the GroundRoute inf test to module scope
The regression test was appended inside TestWebSearchTool, ending the
class early and turning its last two methods into uncollected nested
functions. Move it after TestWebFetchTool so pytest collects all 20
tests again.
Signed-off-by: yetuge <2219677952@qq.com>
---------
Signed-off-by: yetuge <2219677952@qq.com>
338 lines
14 KiB
Python
338 lines
14 KiB
Python
"""Unit tests for the GroundRoute community web search + fetch tools."""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_api_key_warned():
|
|
"""Reset the per-tool warning set before and after each test."""
|
|
import deerflow.community.groundroute.tools as gr_mod
|
|
|
|
gr_mod._api_key_warned = set()
|
|
yield
|
|
gr_mod._api_key_warned = set()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config_with_key():
|
|
with patch("deerflow.community.groundroute.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "test-gr-key", "max_results": 5}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config_no_key():
|
|
with patch("deerflow.community.groundroute.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
yield mock
|
|
|
|
|
|
def _make_search_response(payload: dict) -> MagicMock:
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = payload
|
|
mock_resp.raise_for_status = MagicMock()
|
|
return mock_resp
|
|
|
|
|
|
def _patch_post(mock_resp: MagicMock):
|
|
"""Patch httpx.Client so the context-managed .post returns mock_resp."""
|
|
patcher = patch("deerflow.community.groundroute.tools.httpx.Client")
|
|
mock_client_cls = patcher.start()
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = mock_resp
|
|
return patcher, mock_client_cls
|
|
|
|
|
|
def _per_tool_config(**by_tool):
|
|
"""Build a get_app_config mock whose get_tool_config returns a distinct config per tool name."""
|
|
configs = {}
|
|
for tool_name, extra in by_tool.items():
|
|
cfg = MagicMock()
|
|
cfg.model_extra = extra
|
|
configs[tool_name] = cfg
|
|
app = MagicMock()
|
|
app.get_tool_config.side_effect = lambda name: configs.get(name)
|
|
return app
|
|
|
|
|
|
class TestGetApiKey:
|
|
def test_returns_config_key_when_present(self):
|
|
with patch("deerflow.community.groundroute.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "from-config"}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
|
|
from deerflow.community.groundroute.tools import _get_api_key
|
|
|
|
assert _get_api_key("web_search") == "from-config"
|
|
|
|
def test_falls_back_to_env_when_config_key_empty(self):
|
|
with patch("deerflow.community.groundroute.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": " "}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
with patch.dict("os.environ", {"GROUNDROUTE_API_KEY": "env-key"}, clear=True):
|
|
from deerflow.community.groundroute.tools import _get_api_key
|
|
|
|
assert _get_api_key("web_search") == "env-key"
|
|
|
|
def test_returns_none_when_no_key_anywhere(self):
|
|
with patch("deerflow.community.groundroute.tools.get_app_config") as mock:
|
|
mock.return_value.get_tool_config.return_value = None
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
from deerflow.community.groundroute.tools import _get_api_key
|
|
|
|
assert _get_api_key("web_search") is None
|
|
|
|
def test_reads_the_named_tools_config_block(self):
|
|
"""web_fetch must read the web_fetch block, not web_search (multi-engine flows)."""
|
|
with patch("deerflow.community.groundroute.tools.get_app_config") as mock:
|
|
mock.return_value = _per_tool_config(
|
|
web_search={"api_key": "search-key"},
|
|
web_fetch={"api_key": "fetch-key"},
|
|
)
|
|
from deerflow.community.groundroute.tools import _get_api_key
|
|
|
|
assert _get_api_key("web_search") == "search-key"
|
|
assert _get_api_key("web_fetch") == "fetch-key"
|
|
|
|
|
|
class TestWebSearchTool:
|
|
def test_basic_search_returns_normalized_list_with_source_engine(self, mock_config_with_key):
|
|
payload = {
|
|
"request_id": "r1",
|
|
"results": [
|
|
{"url": "https://ex.com/a", "title": "A", "snippet": "s1", "source_engine": "serper"},
|
|
{"url": "https://ex.com/b", "title": "B", "snippet": "s2", "source_engine": "exa"},
|
|
],
|
|
}
|
|
patcher, _ = _patch_post(_make_search_response(payload))
|
|
try:
|
|
from deerflow.community.groundroute.tools import web_search_tool
|
|
|
|
result = web_search_tool.invoke({"query": "vector databases"})
|
|
parsed = json.loads(result)
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert isinstance(parsed, list)
|
|
assert len(parsed) == 2
|
|
assert parsed[0] == {
|
|
"title": "A",
|
|
"url": "https://ex.com/a",
|
|
"snippet": "s1",
|
|
"source_engine": "serper",
|
|
}
|
|
assert {r["source_engine"] for r in parsed} == {"serper", "exa"}
|
|
|
|
def test_uses_web_search_config_key(self):
|
|
"""web_search authenticates with the web_search config block's key."""
|
|
with patch("deerflow.community.groundroute.tools.get_app_config") as mock:
|
|
mock.return_value = _per_tool_config(
|
|
web_search={"api_key": "search-key"},
|
|
web_fetch={"api_key": "fetch-key"},
|
|
)
|
|
payload = {"results": [{"url": "u", "title": "t", "snippet": "s", "source_engine": "exa"}]}
|
|
patcher, mock_client_cls = _patch_post(_make_search_response(payload))
|
|
try:
|
|
from deerflow.community.groundroute.tools import web_search_tool
|
|
|
|
web_search_tool.invoke({"query": "hello world"})
|
|
call = mock_client_cls.return_value.__enter__.return_value.post.call_args
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert call.kwargs["headers"]["Authorization"] == "Bearer search-key"
|
|
assert call.kwargs["json"]["query"] == "hello world"
|
|
|
|
def test_agent_max_results_is_honored_over_config(self):
|
|
"""A caller-supplied max_results wins over the config value (not silently discarded)."""
|
|
with patch("deerflow.community.groundroute.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "k", "max_results": 5}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
payload = {"results": [{"url": "u", "title": "t", "snippet": "s", "source_engine": "exa"}]}
|
|
patcher, mock_client_cls = _patch_post(_make_search_response(payload))
|
|
try:
|
|
from deerflow.community.groundroute.tools import web_search_tool
|
|
|
|
web_search_tool.invoke({"query": "test", "max_results": 20})
|
|
body = mock_client_cls.return_value.__enter__.return_value.post.call_args.kwargs["json"]
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert body["max_results"] == 20
|
|
|
|
def test_config_max_results_used_when_caller_omits(self, mock_config_with_key):
|
|
"""When the caller omits max_results, the configured value is used."""
|
|
payload = {"results": [{"url": "u", "title": "t", "snippet": "s", "source_engine": "exa"}]}
|
|
patcher, mock_client_cls = _patch_post(_make_search_response(payload))
|
|
try:
|
|
from deerflow.community.groundroute.tools import web_search_tool
|
|
|
|
web_search_tool.invoke({"query": "test"})
|
|
body = mock_client_cls.return_value.__enter__.return_value.post.call_args.kwargs["json"]
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert body["max_results"] == 5
|
|
|
|
def test_max_results_clamped_to_cap(self):
|
|
with patch("deerflow.community.groundroute.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "k", "max_results": "500"}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
payload = {"results": [{"url": "u", "title": "t", "snippet": "s", "source_engine": "exa"}]}
|
|
patcher, mock_client_cls = _patch_post(_make_search_response(payload))
|
|
try:
|
|
from deerflow.community.groundroute.tools import web_search_tool
|
|
|
|
web_search_tool.invoke({"query": "test"})
|
|
body = mock_client_cls.return_value.__enter__.return_value.post.call_args.kwargs["json"]
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert body["max_results"] == 50
|
|
|
|
def test_empty_results_returns_error_json(self, mock_config_with_key):
|
|
patcher, _ = _patch_post(_make_search_response({"results": []}))
|
|
try:
|
|
from deerflow.community.groundroute.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "no results"}))
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert parsed["error"] == "No results found"
|
|
assert parsed["query"] == "no results"
|
|
|
|
def test_missing_api_key_returns_error_json(self, mock_config_no_key):
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
from deerflow.community.groundroute.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
|
|
assert "error" in parsed
|
|
assert "GROUNDROUTE_API_KEY" in parsed["error"]
|
|
|
|
def test_missing_api_key_logs_warning_once(self, mock_config_no_key, caplog):
|
|
import logging
|
|
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
from deerflow.community.groundroute.tools import web_search_tool
|
|
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.groundroute.tools"):
|
|
web_search_tool.invoke({"query": "q1"})
|
|
web_search_tool.invoke({"query": "q2"})
|
|
|
|
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
assert len(warnings) == 1
|
|
|
|
def test_http_error_returns_structured_error(self, mock_config_with_key):
|
|
mock_resp = MagicMock()
|
|
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError("402", request=MagicMock(), response=MagicMock(status_code=402, text="Payment Required"))
|
|
patcher, _ = _patch_post(mock_resp)
|
|
try:
|
|
from deerflow.community.groundroute.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert "error" in parsed
|
|
assert "402" in parsed["error"]
|
|
|
|
def test_network_exception_returns_error_json(self, mock_config_with_key):
|
|
patcher, mock_client_cls = _patch_post(MagicMock())
|
|
mock_client_cls.return_value.__enter__.return_value.post.side_effect = Exception("timeout")
|
|
try:
|
|
from deerflow.community.groundroute.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert "error" in parsed
|
|
|
|
def test_partial_fields_default_to_empty_string(self, mock_config_with_key):
|
|
patcher, _ = _patch_post(_make_search_response({"results": [{}]}))
|
|
try:
|
|
from deerflow.community.groundroute.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert parsed[0] == {"title": "", "url": "", "snippet": "", "source_engine": ""}
|
|
|
|
|
|
class TestWebFetchTool:
|
|
def test_fetch_returns_titled_content(self, mock_config_with_key):
|
|
payload = {"results": [{"title": "Page", "content": "Body text", "url": "https://ex.com"}]}
|
|
patcher, mock_client_cls = _patch_post(_make_search_response(payload))
|
|
try:
|
|
from deerflow.community.groundroute.tools import web_fetch_tool
|
|
|
|
result = web_fetch_tool.invoke({"url": "https://ex.com"})
|
|
body = mock_client_cls.return_value.__enter__.return_value.post.call_args.kwargs["json"]
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert result == "# Page\n\nBody text"
|
|
# web_fetch uses mode=page with the URL as the query.
|
|
assert body["mode"] == "page"
|
|
assert body["query"] == "https://ex.com"
|
|
|
|
def test_fetch_uses_web_fetch_config_key(self):
|
|
"""web_fetch must authenticate with the web_fetch config block's key, not web_search's."""
|
|
with patch("deerflow.community.groundroute.tools.get_app_config") as mock:
|
|
mock.return_value = _per_tool_config(
|
|
web_search={"api_key": "search-key"},
|
|
web_fetch={"api_key": "fetch-key"},
|
|
)
|
|
payload = {"results": [{"title": "P", "content": "b", "url": "https://ex.com"}]}
|
|
patcher, mock_client_cls = _patch_post(_make_search_response(payload))
|
|
try:
|
|
from deerflow.community.groundroute.tools import web_fetch_tool
|
|
|
|
web_fetch_tool.invoke({"url": "https://ex.com"})
|
|
call = mock_client_cls.return_value.__enter__.return_value.post.call_args
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert call.kwargs["headers"]["Authorization"] == "Bearer fetch-key"
|
|
|
|
def test_fetch_missing_key_returns_error(self, mock_config_no_key):
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
from deerflow.community.groundroute.tools import web_fetch_tool
|
|
|
|
parsed = json.loads(web_fetch_tool.invoke({"url": "https://ex.com"}))
|
|
|
|
assert "error" in parsed
|
|
assert "GROUNDROUTE_API_KEY" in parsed["error"]
|
|
|
|
def test_fetch_no_results_returns_error_string(self, mock_config_with_key):
|
|
patcher, _ = _patch_post(_make_search_response({"results": []}))
|
|
try:
|
|
from deerflow.community.groundroute.tools import web_fetch_tool
|
|
|
|
result = web_fetch_tool.invoke({"url": "https://ex.com"})
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert result == "Error: No results found"
|
|
|
|
|
|
def test_coerce_max_results_inf_falls_back_to_default():
|
|
"""A YAML `.inf` max_results must fall back to the default, not crash."""
|
|
import deerflow.community.groundroute.tools as groundroute_mod
|
|
|
|
assert groundroute_mod._coerce_max_results(float("inf")) == groundroute_mod._DEFAULT_MAX_RESULTS
|