mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
c04946a73f
commit
ab43439a54
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
137
backend/tests/test_image_search_errors.py
Normal file
137
backend/tests/test_image_search_errors.py
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user