fix(image-search): distinguish DDG failures from empty results (#6680)

* fix(image-search): distinguish DDG failures from empty results

* fix(image-search): preserve ambiguous DDGS dispatcher failures

---------

Co-authored-by: xyi74976-del <290725798+xyi74976-del@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
yi-coding 2026-10-21 22:06:16 +08:00 committed by GitHub
parent c04946a73f
commit ab43439a54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 172 additions and 26 deletions

View File

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

View File

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

View File

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

View File

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