diff --git a/README.md b/README.md index 5685e1a23e..61696958f0 100644 --- a/README.md +++ b/README.md @@ -1175,7 +1175,7 @@ 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. +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. 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 diff --git a/backend/packages/harness/deerflow/community/image_search/tools.py b/backend/packages/harness/deerflow/community/image_search/tools.py index 6754307e95..83390816e5 100644 --- a/backend/packages/harness/deerflow/community/image_search/tools.py +++ b/backend/packages/harness/deerflow/community/image_search/tools.py @@ -11,6 +11,24 @@ from deerflow.config import get_app_config logger = logging.getLogger(__name__) +DEFAULT_MAX_RESULTS = 5 + + +def _coerce_max_results(value: object) -> int: + """Normalize config/parameter values before passing them to DDGS.""" + if isinstance(value, bool) or (isinstance(value, float) and not value.is_integer()): + # int() accepts booleans and silently truncates a YAML value such as 3.5. + count = 0 + else: + try: + count = int(value) # type: ignore[call-overload] + except (TypeError, ValueError, OverflowError): + count = 0 + if count <= 0: + logger.warning("Invalid DDG image search max_results=%r; using default %s", value, DEFAULT_MAX_RESULTS) + return DEFAULT_MAX_RESULTS + return count + def _search_images( query: str, @@ -111,6 +129,7 @@ def image_search_tool( # Override max_results from config if set 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) results = _search_images( query=query, diff --git a/backend/tests/test_image_search.py b/backend/tests/test_image_search.py index 77c8f449f3..f67248cf80 100644 --- a/backend/tests/test_image_search.py +++ b/backend/tests/test_image_search.py @@ -1,6 +1,11 @@ import json +import logging +from types import SimpleNamespace from unittest.mock import MagicMock, patch +import pytest + +from deerflow.community.image_search import tools from deerflow.community.image_search.tools import image_search_tool @@ -70,3 +75,70 @@ def test_image_search_omits_unset_filters(): assert kwargs["layout"] == "Square" assert kwargs.get("color") is None assert kwargs.get("license_image") is None + + +@pytest.mark.parametrize( + ("configured_limit", "env_value", "call_args", "expected_count", "warns"), + [ + pytest.param("$IMAGE_SEARCH_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("$IMAGE_SEARCH_TEST_MAX_RESULTS", "abc", {"max_results": 2}, 5, True, id="invalid-env"), + pytest.param("$IMAGE_SEARCH_TEST_MAX_RESULTS", "", {"max_results": 2}, 5, True, id="empty-env"), + pytest.param("$IMAGE_SEARCH_TEST_MAX_RESULTS", "3.5", {"max_results": 2}, 5, True, id="fractional-env"), + pytest.param(3.5, "3", {"max_results": 2}, 5, True, id="fractional-config"), + pytest.param(True, "3", {"max_results": 2}, 5, True, id="boolean-config"), + pytest.param("$IMAGE_SEARCH_TEST_MAX_RESULTS", "0", {"max_results": 2}, 5, True, id="zero-env"), + pytest.param(-2, "3", {}, 5, True, id="negative-config"), + pytest.param(None, "3", {"max_results": 0}, 5, True, id="zero-call-argument"), + ], +) +def test_image_search_max_results_with_real_ddgs(monkeypatch, caplog, configured_limit, env_value, call_args, expected_count, warns): + """Exercise DDGS image count arithmetic and slicing without contacting search engines. + + Environment substitution keeps `max_results: $VAR` as a string; DDGS then raised + `TypeError` while sizing its workers and the tool reported "No images found". + """ + from ddgs.ddgs import DDGS + from ddgs.results import ImagesResult + + from deerflow.config.app_config import AppConfig + from deerflow.config.tool_config import ToolConfig + + monkeypatch.setenv("IMAGE_SEARCH_TEST_MAX_RESULTS", env_value) + caplog.set_level(logging.WARNING, logger=tools.__name__) + raw_config = {"name": "image_search", "group": "web", "use": "deerflow.community.image_search.tools:image_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 = [ + ImagesResult( + title=f"Image {i}", + image=f"https://example.com/{i}.jpg", + thumbnail=f"https://example.com/{i}-thumb.jpg", + url=f"https://example.com/{i}", + height=100, + width=100, + source="offline", + ) + 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(image_search_tool.invoke({"query": "Image", **call_args})) + + assert "error" not in parsed + assert parsed["total_results"] == len(parsed["results"]) == expected_count + assert all(result["image_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()