mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
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:
parent
32d7dd5fe8
commit
0bbcccfeb1
@ -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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@ -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)
|
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]:
|
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.
|
"""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:
|
if error_json is not None:
|
||||||
return error_json
|
return error_json
|
||||||
|
|
||||||
rows = data.get(rows_key)
|
rows, error_json = _response_rows(data, rows_key, query)
|
||||||
if rows is None:
|
if error_json is not None:
|
||||||
rows = []
|
return error_json
|
||||||
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)]
|
|
||||||
if not rows:
|
if not rows:
|
||||||
return json.dumps({"error": "No results found", "query": query}, ensure_ascii=False)
|
return json.dumps({"error": "No results found", "query": query}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@ -266,6 +266,66 @@ class TestWebSearchTool:
|
|||||||
mock_client.return_value.__enter__.return_value.get.return_value = _make_response(["not", "a", "dict"])
|
mock_client.return_value.__enter__.return_value.get.return_value = _make_response(["not", "a", "dict"])
|
||||||
assert "unexpected response format" in _run("q")["error"]
|
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):
|
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):
|
with patch.dict("os.environ", {}, clear=True), caplog.at_level(logging.WARNING):
|
||||||
first = _run("q")
|
first = _run("q")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user