diff --git a/README.md b/README.md index 9fe5213e7b..1e5e13a6d1 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,8 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta Tavily web search and fetch contain malformed HTTP-success payloads the same way: a non-object payload, or a `results`/`failed_results` container that is not a list, returns the provider's format error, and non-object entries in an otherwise valid list are skipped in order. Missing, null, or empty results keep the existing response (`[]` for search, `Error: No results found` for fetch); missing or null search fields (`title`, `url`, `content`) normalize to empty text, and a non-string fetch `raw_content` is coerced to text before the truncation limit. + 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. + 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/sofya/tools.py b/backend/packages/harness/deerflow/community/sofya/tools.py index b78ff64c7b..ca85156bfe 100644 --- a/backend/packages/harness/deerflow/community/sofya/tools.py +++ b/backend/packages/harness/deerflow/community/sofya/tools.py @@ -117,14 +117,24 @@ def _clip(value: object, limit: int) -> str: def _response_results(data: dict) -> list[dict] | None: - """Return the result dicts of a Sofya response, or None if malformed.""" + """Return the result dicts of a Sofya response, or None if malformed. + + A missing or null ``results`` is an empty search. A non-list container, or a + non-empty list holding no objects, is malformed: the caller reports a format + error rather than "No results found", which would tell the agent the query + legitimately matched nothing. + """ results = data.get("results") if results is None: return [] if not isinstance(results, list): logger.error("Sofya returned an unexpected 'results' payload type: %s", type(results).__name__) return None - return [item for item in results if isinstance(item, dict)] + usable = [item for item in results if isinstance(item, dict)] + if results and not usable: + logger.error("Sofya returned 'results' with no usable result objects") + return None + return usable @tool("web_search", parse_docstring=True) diff --git a/backend/tests/test_sofya_tools.py b/backend/tests/test_sofya_tools.py index 09d91157d4..a0c2fc1ca9 100644 --- a/backend/tests/test_sofya_tools.py +++ b/backend/tests/test_sofya_tools.py @@ -437,6 +437,33 @@ class TestWebSearchTool: assert parsed["error"] == "Sofya returned an unexpected response format" + @pytest.mark.parametrize("results", [["a", "b"], [None, None], [1, 2], [[]], [False]]) + def test_results_without_any_objects_returns_format_error(self, mock_config_with_key, results, caplog): + """A non-empty `results` holding no objects is malformed, not an empty search.""" + with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_search_response(results) + + from deerflow.community.sofya.tools import web_search_tool + + with caplog.at_level(logging.ERROR): + parsed = json.loads(web_search_tool.invoke({"query": "test"})) + + assert parsed["error"] == "Sofya returned an unexpected response format" + assert any("no usable result objects" in r.getMessage() for r in caplog.records if r.levelno == logging.ERROR) + + def test_mixed_results_keep_the_object_entries(self, mock_config_with_key): + results = ["junk", {"title": "Kept", "url": "https://example.com/1", "content": "Body"}, 7] + + with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_search_response(results) + + from deerflow.community.sofya.tools import web_search_tool + + parsed = json.loads(web_search_tool.invoke({"query": "test"})) + + assert parsed["total_results"] == 1 + assert parsed["results"][0] == {"title": "Kept", "url": "https://example.com/1", "content": "Body"} + def test_http_error_returns_error_json(self, mock_config_with_key): request = httpx.Request("POST", "https://sofya.co/v1/search") response = httpx.Response(402, text="Insufficient credits", request=request) @@ -569,6 +596,36 @@ class TestWebFetchTool: assert result == "Error: No results found" + def test_results_without_any_objects_returns_format_error(self, mock_config_with_key): + """A non-empty `results` holding no objects is malformed, not an empty fetch.""" + with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_fetch_response(["a", 3]) + + from deerflow.community.sofya.tools import web_fetch_tool + + result = web_fetch_tool.invoke({"url": "https://example.com"}) + + assert result == "Error: Sofya returned an unexpected response format" + + def test_mixed_results_returns_first_object_content(self, mock_config_with_key): + results = [ + "junk", + None, + {"title": "First page", "content": "First body", "success": True}, + 7, + {"title": "Second page", "content": "Second body", "success": True}, + False, + ] + + with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_fetch_response(results) + + from deerflow.community.sofya.tools import web_fetch_tool + + result = web_fetch_tool.invoke({"url": "https://example.com"}) + + assert result == "# First page\n\nFirst body" + def test_http_error_returns_error_string(self, mock_config_with_key): request = httpx.Request("POST", "https://sofya.co/v1/fetch") response = httpx.Response(401, text="Invalid API key", request=request)