diff --git a/README.md b/README.md index c4b578dc36..b2b0dd4bae 100644 --- a/README.md +++ b/README.md @@ -1749,6 +1749,8 @@ Image result screening also recognizes Unicode hostname separators (`。`, `.` `。`) as dots, matching IDNA client normalization before classifying local/private hosts. Accepted result URLs retain their original spelling. +DDG image search reports timeouts, rate limits, and setup failures as search errors with the original query. `No images found` is reserved for a returned empty result set. The SDK's `No results found.` exception remains a search error because it can also hide an unfinished backend request; this conservatively includes SDK exceptions from genuinely empty searches whose completion cannot be confirmed. + 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/AGENTS.md b/backend/packages/harness/deerflow/AGENTS.md index af24f6e5b0..4badff8de5 100644 --- a/backend/packages/harness/deerflow/AGENTS.md +++ b/backend/packages/harness/deerflow/AGENTS.md @@ -49,6 +49,11 @@ data directory, so clearing afterward would leave `config.json` with a dangling keychain reference. The snapshot still supplies the previous OAuth data for logout and restores the complete old tree on any switch failure. +### DDG image search + +Offload config/DDGS together. Only returned empties mean `No images found`; +the SDK sentinel can hide timeouts. See `tests/test_image_search_errors.py`. + ### Browser Progress Screenshots (`community/browser_automation/`) Hidden per-action browser progress frames use JPEG at quality 80 to keep their diff --git a/backend/packages/harness/deerflow/community/image_search/tools.py b/backend/packages/harness/deerflow/community/image_search/tools.py index 7b98d447f4..f5d071eec7 100644 --- a/backend/packages/harness/deerflow/community/image_search/tools.py +++ b/backend/packages/harness/deerflow/community/image_search/tools.py @@ -45,36 +45,33 @@ def _search_images( """ try: from ddgs import DDGS - except ImportError: - logger.error("ddgs library not installed. Run: pip install ddgs") - return [] + except ImportError as exc: + raise RuntimeError("ddgs library not installed. Run: pip install ddgs") from exc ddgs = DDGS(timeout=30) - try: - kwargs = { - "region": region, - "safesearch": safesearch, - "max_results": max_results, - } + kwargs = { + "region": region, + "safesearch": safesearch, + "max_results": max_results, + } - if size: - kwargs["size"] = size - if color: - kwargs["color"] = color - if type_image: - kwargs["type_image"] = type_image - if layout: - kwargs["layout"] = layout - if license_image: - kwargs["license_image"] = license_image + if size: + kwargs["size"] = size + if color: + kwargs["color"] = color + if type_image: + kwargs["type_image"] = type_image + if layout: + kwargs["layout"] = layout + if license_image: + kwargs["license_image"] = license_image - results = ddgs.images(query, **kwargs) - return list(results) if results else [] - - except Exception as e: - logger.error(f"Failed to search images: {e}") - return [] + # DDGS 9.14.1 can raise its "No results found." sentinel after a backend + # exceeds the dispatcher deadline. Only a returned empty collection proves + # an empty search; propagate every exception to the async error boundary. + results = ddgs.images(query, **kwargs) + return list(results) if results else [] @tool("image_search", parse_docstring=True) @@ -96,6 +93,7 @@ async def image_search_tool( - Before generating fashion/clothing: search for style and detail references The returned image URLs can be used as reference images in image generation to significantly improve quality. + An error other than "No images found" means the search failed, not that the query has no matching images. Args: query: Search keywords describing the images you want to find. Be specific for better results (e.g., "Japanese woman street photography 1990s" instead of just "woman"). @@ -128,7 +126,11 @@ async def image_search_tool( license_image=license_image, ) - results = await asyncio.to_thread(search_with_config) + try: + results = await asyncio.to_thread(search_with_config) + except Exception as exc: + logger.exception("Failed to search images") + return json.dumps({"error": f"DuckDuckGo image search failed: {type(exc).__name__}: {exc}", "query": query}, ensure_ascii=False) if not results: return json.dumps({"error": "No images found", "query": query}, ensure_ascii=False) diff --git a/backend/tests/test_image_search_errors.py b/backend/tests/test_image_search_errors.py new file mode 100644 index 0000000000..79c963ead8 --- /dev/null +++ b/backend/tests/test_image_search_errors.py @@ -0,0 +1,137 @@ +"""Image search outages must not be reported as an empty result set.""" + +import json +import sys +import threading +from concurrent.futures import wait as wait_for_futures +from types import SimpleNamespace + +import ddgs +import ddgs.ddgs as ddgs_dispatch +import pytest +from ddgs.ddgs import DDGS +from ddgs.exceptions import DDGSException, RatelimitException, TimeoutException + +from deerflow.community.image_search import tools + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture(autouse=True) +def configured_search(monkeypatch): + monkeypatch.setattr(tools, "get_app_config", lambda: SimpleNamespace(get_tool_config=lambda name: None)) + + +@pytest.mark.parametrize("failure", ["rate-limit", "timeout", "constructor", "missing-dependency", "iteration"]) +async def test_image_search_reports_execution_failure(monkeypatch, failure): + if failure == "missing-dependency": + monkeypatch.setitem(sys.modules, "ddgs", None) + expected = "not installed" + elif failure == "constructor": + + def unavailable_client(**kwargs): + raise RuntimeError("search client unavailable") + + monkeypatch.setattr(ddgs, "DDGS", unavailable_client) + expected = "search client unavailable" + else: + error = RatelimitException("HTTP 429") if failure == "rate-limit" else TimeoutException("search timed out") + + def failing_search(self, query, **kwargs): + if failure == "iteration": + + def incomplete_results(): + yield {"title": "partial", "image": "https://example.com/image.jpg", "thumbnail": "https://example.com/thumb.jpg"} + raise error + + return incomplete_results() + raise error + + monkeypatch.setattr(DDGS, "images", failing_search) + expected = "429" if failure == "rate-limit" else "timed out" + + output = json.loads(await tools.image_search_tool.ainvoke({"query": "雪山"})) + + assert output["query"] == "雪山" + assert expected in output["error"] + assert output["error"] != "No images found" + assert "results" not in output + + +async def test_image_search_reports_configuration_failure(monkeypatch): + def invalid_config(): + raise ValueError("invalid image search configuration") + + monkeypatch.setattr(tools, "get_app_config", invalid_config) + + output = json.loads(await tools.image_search_tool.ainvoke({"query": "a cat"})) + + assert "invalid image search configuration" in output["error"] + assert output["query"] == "a cat" + + +async def test_image_search_preserves_genuinely_empty_results(monkeypatch): + def empty_search(self, query, **kwargs): + return [] + + monkeypatch.setattr(DDGS, "images", empty_search) + + output = json.loads(await tools.image_search_tool.ainvoke({"query": "a cat"})) + + assert output == {"error": "No images found", "query": "a cat"} + + +@pytest.mark.parametrize("backend_outcome", ["empty", "timeout", "late-timeout"]) +async def test_image_search_does_not_mistake_sdk_dispatcher_failure_for_empty_results(monkeypatch, backend_outcome): + engine_started = threading.Event() + engine_outcome_observed = threading.Event() + pending_at_deadline = threading.Event() + deadline_expired = threading.Event() + + def search(query, **kwargs): + engine_started.set() + if backend_outcome == "late-timeout": + assert deadline_expired.wait(timeout=5), "dispatcher must release the pending engine" + engine_outcome_observed.set() + if backend_outcome != "empty": + raise TimeoutException("image backend timed out") + return [] + + def expire_wait(futures, **kwargs): + try: + assert engine_started.wait(timeout=5), "engine must run before the wait deadline" + done, pending = wait_for_futures(futures, timeout=0.01, return_when=kwargs["return_when"]) + assert pending, "regression must leave the failed request pending at the SDK deadline" + pending_at_deadline.set() + return done, pending + finally: + deadline_expired.set() + + engine = SimpleNamespace(provider="offline", name="offline", search=search) + monkeypatch.setattr(DDGS, "_get_network_client", lambda self: None) + monkeypatch.setattr(DDGS, "_get_engines", lambda self, category, backend: [engine]) + if backend_outcome == "late-timeout": + monkeypatch.setattr(ddgs_dispatch, "wait", expire_wait) + + output = json.loads(await tools.image_search_tool.ainvoke({"query": "snow mountains"})) + + assert engine_started.is_set() + assert engine_outcome_observed.is_set(), "the engine must reach its intended result, not fail a watchdog" + if backend_outcome == "late-timeout": + assert pending_at_deadline.is_set(), "the SDK must observe an unfinished request at the deadline" + assert output["query"] == "snow mountains" + assert output["error"].startswith("DuckDuckGo image search failed:") + assert output["error"] != "No images found" + assert "results" not in output + + +async def test_image_search_does_not_confuse_embedded_empty_result_text_with_success(monkeypatch): + def failed_search(self, query, **kwargs): + raise DDGSException("No results found for query: HTTP 429") + + monkeypatch.setattr(DDGS, "images", failed_search) + + output = json.loads(await tools.image_search_tool.ainvoke({"query": "a cat"})) + + assert "429" in output["error"] + assert output["error"] != "No images found"