fix(sofya): report a format error for a results list with no usable objects (#6719)

* fix(sofya): report a format error for results with no usable objects

`_response_results` skipped non-object entries but returned the filtered
list even when it was empty, so a non-empty `results` array holding no
objects fell through to "No results found" -- telling the agent the query
legitimately matched nothing instead of surfacing the malformed payload.

Treat a non-empty list with no object entries as malformed, matching the
containment rule #6603 documents for this provider family (brave #6550,
groundroute #6603, tavily #6645, webz). Both `web_search` and
`web_fetch` share this helper, so both now report the provider's format
error. Missing, null, and empty `results` keep returning "No results
found", and mixed lists still keep their valid entries in order.

* test(sofya): cover mixed fetch result entries

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
D41910 2026-10-12 21:00:24 +08:00 committed by GitHub
parent 17f774b442
commit 62588f3675
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 71 additions and 2 deletions

View File

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

View File

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

View File

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