refactor(community): fold serper onto the shared max_results owner (#6966)

Serper keeps a private _coerce_max_results in the pre-#5852 shape: a bare
int() silently turns max_results: true into 1 and 3.5 into 3, and an invalid
value falls back without a warning (#5865).

Delegate to deerflow.community.search_max_results.coerce_max_results, which
owns what invalid means for the folded providers, and keep Serper's
10-result cap at the call site the way You.com does, because clamping policy
is deliberately per-provider.

Invalid configured counts now warn under the Serper label and fall back to
the default instead of being silently coerced.
This commit is contained in:
D41910 2026-10-16 08:57:16 +08:00 committed by GitHub
parent c3ac4b981f
commit 0f2c2f788a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 50 additions and 52 deletions

View File

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

View File

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

View File

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