diff --git a/README.md b/README.md index 3f08c486e1..429cdce360 100644 --- a/README.md +++ b/README.md @@ -1172,6 +1172,8 @@ Public-skill CI waivers are exact, expiring exceptions in `.github/skill-review- 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. +For DDG 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. + Stdio MCP servers can set `cwd` in `extensions_config.json` when their entrypoint or data files depend on a specific working directory. The setting applies to both discovery and tool calls; see [MCP configuration](backend/docs/MCP_SERVER.md#stdio-working-directory). diff --git a/backend/packages/harness/deerflow/community/ddg_search/tools.py b/backend/packages/harness/deerflow/community/ddg_search/tools.py index a50fa28f83..a91aec1975 100644 --- a/backend/packages/harness/deerflow/community/ddg_search/tools.py +++ b/backend/packages/harness/deerflow/community/ddg_search/tools.py @@ -13,6 +13,7 @@ from deerflow.config import get_app_config logger = logging.getLogger(__name__) DEFAULT_BACKEND = "auto" +DEFAULT_MAX_RESULTS = 5 DEFAULT_REGION = "wt-wt" DEFAULT_SAFESEARCH = "moderate" DEFAULT_WIKIPEDIA_REGION = "us-en" @@ -30,6 +31,18 @@ WIKIPEDIA_LANGUAGE_ALIASES = { } +def _coerce_max_results(value: object) -> int: + """Normalize config/parameter values before passing them to DDGS.""" + try: + count = int(value) # type: ignore[call-overload] + except (TypeError, ValueError, OverflowError): + count = 0 + if count <= 0: + logger.warning("Invalid DDG Search max_results=%r; using default %s", value, DEFAULT_MAX_RESULTS) + return DEFAULT_MAX_RESULTS + return count + + def _normalize_backend(backend: str | list[str] | tuple[str, ...] | None) -> str: if backend is None: return DEFAULT_BACKEND @@ -105,7 +118,7 @@ def _resolve_ddgs_region(query: str, region: str | None, backend: str | list[str def _search_text( query: str, - max_results: int = 5, + max_results: int = DEFAULT_MAX_RESULTS, region: str | None = DEFAULT_REGION, safesearch: str | None = DEFAULT_SAFESEARCH, backend: str | list[str] | tuple[str, ...] | None = DEFAULT_BACKEND, @@ -156,7 +169,7 @@ def _search_text( @tool("web_search", parse_docstring=True) def web_search_tool( query: str, - max_results: int = 5, + max_results: int = DEFAULT_MAX_RESULTS, time_range: SearchTimeRange | None = None, ) -> str: """Search the web for information. Use this tool to find current information, news, articles, and facts from the internet. @@ -180,7 +193,7 @@ def web_search_tool( results = _search_text( query=query, - max_results=max_results, + max_results=_coerce_max_results(max_results), region=region, safesearch=safesearch, backend=backend, diff --git a/backend/tests/test_ddg_search_tools.py b/backend/tests/test_ddg_search_tools.py index b7c28a0f82..be37a9eedf 100644 --- a/backend/tests/test_ddg_search_tools.py +++ b/backend/tests/test_ddg_search_tools.py @@ -1,6 +1,7 @@ """Unit tests for the DDGS community web search tool.""" import json +import logging import sys from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -10,6 +11,60 @@ import pytest from deerflow.community.ddg_search import tools +@pytest.mark.parametrize( + ("configured_limit", "env_value", "call_args", "expected_count", "warns"), + [ + pytest.param("$DDG_TEST_MAX_RESULTS", "3", {"max_results": 2}, 3, False, id="environment-variable"), + pytest.param("3", "3", {"max_results": 2}, 3, False, id="numeric-string"), + pytest.param(3, "3", {"max_results": 2}, 3, False, id="integer-config"), + pytest.param(None, "3", {"max_results": 2}, 2, False, id="call-argument"), + pytest.param(None, "3", {}, 5, False, id="default"), + pytest.param("$DDG_TEST_MAX_RESULTS", "abc", {"max_results": 2}, 5, True, id="invalid-env"), + pytest.param("$DDG_TEST_MAX_RESULTS", "", {"max_results": 2}, 5, True, id="empty-env"), + pytest.param("$DDG_TEST_MAX_RESULTS", "3.5", {"max_results": 2}, 5, True, id="fractional-env"), + pytest.param("$DDG_TEST_MAX_RESULTS", "0", {"max_results": 2}, 5, True, id="zero-env"), + pytest.param("$DDG_TEST_MAX_RESULTS", "-2", {"max_results": 2}, 5, True, id="negative-env"), + pytest.param(0, "3", {}, 5, True, id="zero-config"), + pytest.param(-2, "3", {}, 5, True, id="negative-config"), + pytest.param([], "3", {}, 5, True, id="invalid-config-type"), + pytest.param(None, "3", {"max_results": 0}, 5, True, id="zero-call-argument"), + pytest.param(None, "3", {"max_results": -2}, 5, True, id="negative-call-argument"), + ], +) +def test_web_search_tool_max_results_with_real_ddgs(monkeypatch, caplog, configured_limit, env_value, call_args, expected_count, warns) -> None: + """Exercise SDK count arithmetic and slicing without contacting search engines.""" + from ddgs.ddgs import DDGS + from ddgs.results import TextResult + + from deerflow.config.app_config import AppConfig + from deerflow.config.tool_config import ToolConfig + + monkeypatch.setenv("DDG_TEST_MAX_RESULTS", env_value) + caplog.set_level(logging.WARNING, logger=tools.__name__) + raw_config = {"name": "web_search", "group": "web", "use": "deerflow.community.ddg_search.tools:web_search_tool"} + if configured_limit is not None: + raw_config["max_results"] = configured_limit + tool_config = ToolConfig.model_validate(AppConfig.resolve_env_variables(raw_config)) + monkeypatch.setattr(tools, "get_app_config", lambda: SimpleNamespace(get_tool_config=lambda name: tool_config)) + + rows = [TextResult(title=f"Result {i}", href=f"https://example.com/{i}", body=f"Snippet {i}") for i in range(10)] + engine = SimpleNamespace(provider="offline", name="offline", search=MagicMock(return_value=rows)) + monkeypatch.setattr(DDGS, "_get_network_client", lambda self: None) + monkeypatch.setattr(DDGS, "_get_engines", lambda self, category, backend: [engine]) + + parsed = json.loads(tools.web_search_tool.invoke({"query": "Result", **call_args})) + + assert "error" not in parsed + assert parsed["total_results"] == len(parsed["results"]) == expected_count + assert all(result["url"].startswith("https://example.com/") for result in parsed["results"]) + engine.search.assert_called_once() + warnings = [record for record in caplog.records if record.name == tools.__name__ and record.levelno == logging.WARNING] + assert len(warnings) == int(warns) + if warns: + assert "max_results" in warnings[0].getMessage() + assert "using default 5" in warnings[0].getMessage() + + def test_resolve_ddgs_region_maps_worldwide_chinese_query_for_wikipedia() -> None: assert tools._resolve_ddgs_region("\u4e16\u754c\u676f\u65b0\u95fb 2026", "wt-wt", "auto") == "cn-zh"