diff --git a/README.md b/README.md index db77c362d5..6a0a59f307 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,8 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta 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. + Jina, Browserless, and InfoQuest web fetches resolve relative links and image sources using the requested page URL (or a usable HTML base URL), so returned Markdown includes complete destinations. Link resolution preserves the surrounding HTML source, including malformed-page formatting. Self-hosted Browserless, Crawl4AI, Firecrawl, and fastCRW fetch backends require outbound isolation from private, loopback, link-local, and cloud-metadata networks before setting `network_isolation_confirmed: true` on each tool. DeerFlow rejects private or unverifiable backends by default because those services handle redirects, DNS resolution, and subresources themselves. `allow_private_addresses` permits intentional internal target URLs and does not bypass the backend check. See [delegated fetch deployment](backend/docs/CONFIGURATION.md#delegated-fetch-backend-isolation); `make doctor` reports backend configurations that would be refused. diff --git a/backend/packages/harness/deerflow/community/serply/tools.py b/backend/packages/harness/deerflow/community/serply/tools.py index e0f65062a5..f64f146659 100644 --- a/backend/packages/harness/deerflow/community/serply/tools.py +++ b/backend/packages/harness/deerflow/community/serply/tools.py @@ -104,6 +104,30 @@ def _unexpected_format_error(query: str) -> str: return json.dumps({"error": "Serply returned an unexpected response format", "query": query}, ensure_ascii=False) +def _response_rows(data: dict, rows_key: str, query: str) -> tuple[list[dict], str | None]: + """Return the object rows of a Serply vertical container, or a format error. + + A missing or null container is an empty search. A container that is not a + list, or a non-empty list holding no objects, is malformed: the caller + reports the provider's format error rather than "No results found", which + would tell the agent the query legitimately matched nothing — the + containment rule the sibling providers already apply (brave, groundroute, + tavily, sofya). Non-object entries in an otherwise valid list are skipped + in order so the valid rows survive. + """ + rows = data.get(rows_key) + if rows is None: + return [], None + if not isinstance(rows, list): + logger.error("Serply returned unexpected '%s' payload type: %s", rows_key, type(rows).__name__) + return [], _unexpected_format_error(query) + usable = [row for row in rows if isinstance(row, dict)] + if rows and not usable: + logger.error("Serply returned '%s' with no usable result objects", rows_key) + return [], _unexpected_format_error(query) + return usable, None + + def _serply_get(path: str, api_key: str, query: str, params: dict[str, object]) -> tuple[dict | None, str | None]: """Send a GET request to a Serply endpoint. @@ -190,13 +214,9 @@ def web_search_tool(query: str, max_results: int = 5) -> str: if error_json is not None: return error_json - rows = data.get(rows_key) - if rows is None: - rows = [] - if not isinstance(rows, list): - logger.error("Serply returned unexpected '%s' payload type: %s", rows_key, type(rows).__name__) - return _unexpected_format_error(query) - rows = [row for row in rows if isinstance(row, dict)] + rows, error_json = _response_rows(data, rows_key, query) + if error_json is not None: + return error_json if not rows: return json.dumps({"error": "No results found", "query": query}, ensure_ascii=False) diff --git a/backend/tests/test_serply_tools.py b/backend/tests/test_serply_tools.py index 557f92df31..981c51b1af 100644 --- a/backend/tests/test_serply_tools.py +++ b/backend/tests/test_serply_tools.py @@ -266,6 +266,66 @@ class TestWebSearchTool: mock_client.return_value.__enter__.return_value.get.return_value = _make_response(["not", "a", "dict"]) assert "unexpected response format" in _run("q")["error"] + @pytest.mark.parametrize("rows", [["junk", "more junk"], [None, None], [1, 2], [[]], [False]]) + def test_rows_without_any_objects_return_format_error(self, mock_config_with_key, rows, caplog): + """A non-empty rows container holding no objects is malformed, not an empty search. + + Reporting "No results found" for it would tell the agent the query + legitimately matched nothing, hiding a broken upstream payload. + """ + with patch("deerflow.community.serply.tools.httpx.Client") as mock_client: + mock_client.return_value.__enter__.return_value.get.return_value = _make_response({"results": rows}) + + with caplog.at_level(logging.ERROR): + result = _run("q") + + assert "unexpected response format" in result["error"] + assert any("no usable result objects" in r.getMessage() for r in caplog.records if r.levelno == logging.ERROR) + + def test_mixed_rows_keep_the_object_entries(self, mock_config_with_key): + rows = ["junk", {"title": "Kept", "link": "https://example.com/kept", "description": "Body"}, 7] + + with patch("deerflow.community.serply.tools.httpx.Client") as mock_client: + mock_client.return_value.__enter__.return_value.get.return_value = _make_response({"results": rows}) + result = _run("q") + + assert result["total_results"] == 1 + assert result["results"][0] == {"title": "Kept", "url": "https://example.com/kept", "content": "Body"} + + @pytest.mark.parametrize( + ("vertical", "rows_key"), + [("news", "entries"), ("scholar", "articles")], + ) + def test_vertical_rows_without_any_objects_return_format_error(self, vertical, rows_key): + """The news and scholar verticals share the helper, so they contain malformed rows too.""" + patcher, _ = _patch_config({"api_key": "test-serply-key", "max_results": 5, "vertical": vertical}) + try: + with patch("deerflow.community.serply.tools.httpx.Client") as mock_client: + mock_client.return_value.__enter__.return_value.get.return_value = _make_response({rows_key: ["junk", 3]}) + result = _run("q") + finally: + patcher.stop() + + assert "unexpected response format" in result["error"] + + @pytest.mark.parametrize( + ("vertical", "rows_key"), + [("news", "entries"), ("scholar", "articles")], + ) + def test_vertical_missing_and_empty_rows_keep_no_results(self, vertical, rows_key): + """A missing or empty vertical container stays a legitimate empty search.""" + patcher, _ = _patch_config({"api_key": "test-serply-key", "max_results": 5, "vertical": vertical}) + try: + with patch("deerflow.community.serply.tools.httpx.Client") as mock_client: + mock_client.return_value.__enter__.return_value.get.return_value = _make_response({rows_key: []}) + assert _run("nothing")["error"] == "No results found" + + with patch("deerflow.community.serply.tools.httpx.Client") as mock_client: + mock_client.return_value.__enter__.return_value.get.return_value = _make_response({}) + assert _run("nothing")["error"] == "No results found" + finally: + patcher.stop() + def test_missing_api_key_returns_error_json_and_warns_once(self, mock_config_no_key, caplog): with patch.dict("os.environ", {}, clear=True), caplog.at_level(logging.WARNING): first = _run("q")