fix(serply): report a format error for results with no usable objects (#6796)

`web_search_tool` skipped non-object entries but returned the filtered list
even when it was empty, so a non-empty `results`/`entries`/`articles`
container holding no objects fell through to "No results found". That tells
the agent the query legitimately matched nothing instead of surfacing the
malformed payload -- the failure mode the sibling providers already contain
(brave #6550, groundroute #6603, tavily #6645, sofya #6719): a 200 body
wrapped around an upstream error page ends a research loop as "no hits".

Extract `_response_rows`, which treats a missing or null container as an
empty search and a container that is not a list, or a non-empty list holding
no objects, as the provider's format error, keeping non-object entries
skipped in order so valid rows survive. Both call sites (all three verticals
share one helper) now return the existing structured error.

RED/GREEN: the 7 new cases fail on main with "No results found" and pass
with the fix; test_serply_tools.py 34 passed, the five provider suites
(serply/serper/tavily/sofya/tencent_wsa) 284 passed, ruff check and format
clean.
This commit is contained in:
yetuge 2026-10-14 23:05:54 +08:00 committed by GitHub
parent 32d7dd5fe8
commit 0bbcccfeb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 89 additions and 7 deletions

View File

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

View File

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

View File

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