fix(image-search): coerce DDG image result limits from environment variables (#5807)

* fix(image-search): coerce DDG image result limits from environment variables

* fix(image-search): reject fractional max_results config values

A YAML value such as `max_results: 3.5` reaches the tool as a float, and
int() truncated it to 3 without the warning the README promises. Treat
non-integral floats as invalid and cover the config path in the test.

* fix(image-search): reject boolean result limits

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Zhengxu (Joshua) Jin 2026-09-24 18:00:04 +08:00 committed by GitHub
parent 363b0fc1b4
commit 3ce35a0348
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 92 additions and 1 deletions

View File

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

View File

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

View File

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