diff --git a/backend/packages/harness/deerflow/community/serper/tools.py b/backend/packages/harness/deerflow/community/serper/tools.py index 232962a102..4fe49f5497 100644 --- a/backend/packages/harness/deerflow/community/serper/tools.py +++ b/backend/packages/harness/deerflow/community/serper/tools.py @@ -21,6 +21,7 @@ from urllib.parse import urlparse import httpx from langchain.tools import tool +from deerflow.community.search_max_results import coerce_max_results from deerflow.community.search_time_range import SearchTimeRange from deerflow.community.url_safety import safe_public_result_url as _safe_public_url from deerflow.config import get_app_config @@ -53,15 +54,18 @@ def _get_api_key(tool_name: str, *, extras: Mapping[str, object] | None = None) return None -def _coerce_max_results(value: object, default: int = 5, max_allowed: int = _SERPER_MAX_RESULTS) -> int: - """Coerce config/parameter input into a bounded positive result count.""" - try: - count = int(value) - except (TypeError, ValueError, OverflowError): - return default - if count <= 0: - return default - return min(count, max_allowed) +def _count_for_request(value: object) -> int: + """Coerce ``max_results`` via the shared owner, then mirror Serper's clamp. + + ``deerflow.community.search_max_results.coerce_max_results`` owns what + "invalid" means (booleans, non-integral floats and ``OverflowError`` all + fall back to the default); the 10-result cap stays here because clamping + policy is deliberately per-provider. + """ + return min( + coerce_max_results(value, provider="Serper", logger=logger), + _SERPER_MAX_RESULTS, + ) def _missing_key_error(query: str, tool_name: str) -> str: @@ -392,7 +396,7 @@ def web_search_tool(query: str, max_results: int = 5, time_range: SearchTimeRang config = get_app_config().get_tool_config("web_search") if config is not None and "max_results" in config.model_extra: max_results = config.model_extra.get("max_results", max_results) - max_results = _coerce_max_results(max_results) + max_results = _count_for_request(max_results) query = _clean_query(query) try: @@ -457,7 +461,7 @@ def image_search_tool(query: str, max_results: int = 5) -> str: config = get_app_config().get_tool_config("image_search") if config is not None and "max_results" in config.model_extra: max_results = config.model_extra.get("max_results", max_results) - max_results = _coerce_max_results(max_results) + max_results = _count_for_request(max_results) query = _clean_query(query) extra = config.model_extra if config is not None else {} diff --git a/backend/tests/test_search_max_results_single_owner.py b/backend/tests/test_search_max_results_single_owner.py index fae8c4043a..b27259b0b9 100644 --- a/backend/tests/test_search_max_results_single_owner.py +++ b/backend/tests/test_search_max_results_single_owner.py @@ -31,6 +31,7 @@ from deerflow.community.fastcrw import tools as fastcrw_tools from deerflow.community.firecrawl import tools as firecrawl_tools from deerflow.community.image_search import tools as image_search_tools from deerflow.community.search_max_results import DEFAULT_MAX_RESULTS, coerce_max_results +from deerflow.community.serper import tools as serper_tools from deerflow.community.youcom import tools as youcom_tools COMMUNITY_ROOT = Path(ddg_search_tools.__file__).resolve().parent.parent @@ -42,6 +43,7 @@ COMMUNITY_ROOT = Path(ddg_search_tools.__file__).resolve().parent.parent SHARED_OWNER_PROVIDERS = { "ddg_search": ("DDG Search", ddg_search_tools), "image_search": ("DDG image search", image_search_tools), + "serper": ("Serper", serper_tools), "fastcrw": ("fastCRW", fastcrw_tools), "firecrawl": ("Firecrawl", firecrawl_tools), "youcom": ("You.com", youcom_tools), @@ -58,7 +60,6 @@ DEFERRED_PROVIDERS = { "exa", "groundroute", "searxng", - "serper", "serply", "sofya", "tencent_wsa", diff --git a/backend/tests/test_serper_tools.py b/backend/tests/test_serper_tools.py index d2af2d35ec..525a068762 100644 --- a/backend/tests/test_serper_tools.py +++ b/backend/tests/test_serper_tools.py @@ -223,56 +223,56 @@ class TestGetApiKey: mock.return_value.get_tool_config.assert_called_with("image_search") -class TestCoerceMaxResults: - def test_returns_value_when_valid_positive_int(self): - from deerflow.community.serper.tools import _coerce_max_results +class TestCountForRequest: + """Serper delegates "invalid" to the shared owner, then applies its 10-result cap.""" - assert _coerce_max_results(3) == 3 + def test_returns_value_when_valid_positive_int(self): + from deerflow.community.serper.tools import _count_for_request + + assert _count_for_request(3) == 3 def test_returns_value_for_numeric_string(self): - from deerflow.community.serper.tools import _coerce_max_results + from deerflow.community.serper.tools import _count_for_request - assert _coerce_max_results("7") == 7 + assert _count_for_request("7") == 7 - def test_caps_value_at_default_maximum(self): - from deerflow.community.serper.tools import _coerce_max_results + def test_keeps_integral_float(self): + from deerflow.community.serper.tools import _count_for_request - assert _coerce_max_results(999) == 10 + assert _count_for_request(4.0) == 4 - def test_respects_custom_maximum(self): - from deerflow.community.serper.tools import _coerce_max_results + def test_caps_value_at_serper_maximum(self): + from deerflow.community.serper.tools import _count_for_request - assert _coerce_max_results(999, max_allowed=3) == 3 + assert _count_for_request(999) == 10 - def test_returns_default_for_non_numeric_string(self): - from deerflow.community.serper.tools import _coerce_max_results + @pytest.mark.parametrize("value", ["oops", None, object(), 0, -3, float("inf")]) + def test_returns_default_for_invalid_values(self, value): + from deerflow.community.serper.tools import _count_for_request - assert _coerce_max_results("oops") == 5 + assert _count_for_request(value) == 5 - def test_returns_default_for_none(self): - from deerflow.community.serper.tools import _coerce_max_results + @pytest.mark.parametrize("value", [True, False]) + def test_rejects_booleans(self, value): + """bool is an int subclass, so it must never be coerced to 1 (#5865).""" + from deerflow.community.serper.tools import _count_for_request - assert _coerce_max_results(None) == 5 + assert _count_for_request(value) == 5 - def test_returns_default_for_non_coercible_object(self): - from deerflow.community.serper.tools import _coerce_max_results + def test_rejects_non_integral_float(self): + """A fractional float is a miswritten config value, not a truncation candidate (#5865).""" + from deerflow.community.serper.tools import _count_for_request - assert _coerce_max_results(object()) == 5 + assert _count_for_request(3.5) == 5 - def test_returns_default_for_zero(self): - from deerflow.community.serper.tools import _coerce_max_results + def test_warns_under_the_serper_label_on_the_module_logger(self, caplog): + from deerflow.community.serper.tools import _count_for_request - assert _coerce_max_results(0) == 5 + with caplog.at_level(logging.WARNING, logger="deerflow.community.serper.tools"): + _count_for_request(True) - def test_returns_default_for_negative(self): - from deerflow.community.serper.tools import _coerce_max_results - - assert _coerce_max_results(-3) == 5 - - def test_respects_custom_default(self): - from deerflow.community.serper.tools import _coerce_max_results - - assert _coerce_max_results("bad", default=2) == 2 + assert "Invalid Serper max_results=True; using default 5" in caplog.text + assert [record.name for record in caplog.records] == ["deerflow.community.serper.tools"] class TestMissingKeyError: @@ -1346,13 +1346,6 @@ def test_package_exports_image_search_tool(): assert image_search_tool is direct_image_search_tool -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.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):