diff --git a/README.md b/README.md index 842ad9524e..6bc22ac06e 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,8 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta Sofya web search and fetch contain malformed HTTP-success payloads the same way: a non-object payload, a `results` container that is not a list, or a non-empty list with no object entries returns the provider's format error, while non-object entries in an otherwise valid list are skipped in order. Missing, null, or empty results keep the existing "No results found" response. + Serper web and image search preserve valid entries in mixed result lists. A malformed `organic`/`images` container, or a non-empty list holding no object entries, returns Serper's format error; missing, null, or empty containers keep the existing "No results found" / "No images found" response. Format errors log the container field name only, without queries, credentials, or payload values. + Tencent Cloud WSA web search contains malformed HTTP-success payloads the same way: a `Pages` container that is not a list, or a non-empty container holding no usable page, returns the provider's format error, while malformed page entries in an otherwise valid container are skipped so the valid rows survive. Missing, null, or empty `Pages` keep the existing "No results found" response. Serply web search contains malformed HTTP-success payloads the same way in every vertical: a `results`/`entries`/`articles` container that is not a list, or a non-empty list with no object entries, returns the provider's format error, while non-object entries in an otherwise valid list are skipped in order. Missing, null, or empty containers keep the existing "No results found" response. diff --git a/backend/packages/harness/deerflow/community/serper/tools.py b/backend/packages/harness/deerflow/community/serper/tools.py index 65b8040a3c..e6063cfdf6 100644 --- a/backend/packages/harness/deerflow/community/serper/tools.py +++ b/backend/packages/harness/deerflow/community/serper/tools.py @@ -90,7 +90,14 @@ def _response_items(data: dict, field: str, query: str) -> tuple[list[dict] | No if not isinstance(items, list): logger.error("Serper returned unexpected '%s' payload type: %s", field, type(items).__name__) return None, _unexpected_format_error(query) - return [item for item in items if isinstance(item, dict)], None + usable = [item for item in items if isinstance(item, dict)] + if items and not usable: + # Same containment rule as groundroute/tavily: a populated container + # holding nothing usable is malformed, not an empty search. Reporting + # "No results found" tells the agent the query matched nothing. + logger.error("Serper returned '%s' with no usable result objects", field) + return None, _unexpected_format_error(query) + return usable, None def _clean_query(query: str) -> str: diff --git a/backend/tests/test_serper_tools.py b/backend/tests/test_serper_tools.py index d26ada07d6..f9a78c2998 100644 --- a/backend/tests/test_serper_tools.py +++ b/backend/tests/test_serper_tools.py @@ -776,6 +776,25 @@ class TestWebSearchTool: assert parsed["total_results"] == 1 assert parsed["results"][0]["title"] == "T" + def test_organic_with_no_usable_objects_returns_error(self, mock_config_with_key): + """A populated container holding nothing usable is malformed, not an empty search. + + Mirrors groundroute/tavily: reporting "No results found" would tell the + agent the query legitimately matched nothing. + """ + mock_resp = _make_serper_response(["bad", 3, None, ["nested"]]) + + with patch("deerflow.community.serper.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.post.return_value = mock_resp + + from deerflow.community.serper.tools import web_search_tool + + result = web_search_tool.invoke({"query": "test"}) + parsed = json.loads(result) + + assert parsed["error"] == "Serper returned an unexpected response format" + assert parsed["query"] == "test" + def test_timeout_returns_error(self, mock_config_with_key): with patch("deerflow.community.serper.tools.httpx.Client") as mock_client_cls: mock_client_cls.return_value.__enter__.return_value.post.side_effect = httpx.TimeoutException("Read timed out") @@ -991,6 +1010,21 @@ class TestImageSearchTool: assert parsed["error"] == "No images found" assert parsed["query"] == "no results" + def test_images_with_no_usable_objects_returns_error(self, mock_config_with_key): + """Same containment rule on the images container, which shares the helper.""" + mock_resp = _make_serper_images_response(["https://x/y.png", 7]) + + with patch("deerflow.community.serper.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.post.return_value = mock_resp + + from deerflow.community.serper.tools import image_search_tool + + result = image_search_tool.invoke({"query": "no results"}) + parsed = json.loads(result) + + assert parsed["error"] == "Serper returned an unexpected response format" + assert parsed["query"] == "no results" + def test_missing_api_key_returns_error_json(self, mock_config_no_key): with patch.dict("os.environ", {}, clear=True): import os