fix(infoquest): contain malformed search payloads instead of crashing on them (#6932)

* fix(infoquest): contain malformed search payloads instead of crashing on them

`clean_results` indexed the envelope directly:

    for content_list in raw_results:
        content = content_list["content"]
        results = content["results"]
        if results.get("organic"):
            for result in results["organic"]:

so a payload that did not match the expected shape either raised a raw Python
error that the tool then reported to the agent as the search result, or was
silently swallowed:

    {"search_result": {"results": "maintenance"}}      -> {"error": "... error='string indices must be integers'"}
    {"search_result": {"results": [{"content": "x"}]}} -> {"error": "... error='string indices must be integers'"}
    {"search_result": {"results": [1, 2]}}             -> {"error": "... error='int' object is not subscriptable"}
    {"search_result": {}}                              -> {"error": "... error='results'"}

and, worst of them, a malformed container was skipped silently, so the call
answered `[]` -- telling the agent the query legitimately matched nothing:

    {"results": [{"content": {"results": {"organic": "x"}}}]}          -> []
    {"results": [{"content": {"results": {"organic": {"a": 1}}}}]}     -> []

This is the containment rule the sibling bundled providers already follow
(brave #6550, groundroute #6603, tavily #6645, sofya #6719, serper #6779,
serply #6796, tencent-wsa #6799): a payload that is not a result page is a
format error, not an empty search.

`_envelope_entries` now validates the `content.results` envelope and
`_rows` validates one vertical's container, both shared by the web and image
verticals. A missing or null container stays an empty search; a container that
is not a list, or a non-empty one holding no object entries, returns the
provider's format error; malformed entries in an otherwise valid payload are
skipped in order so the usable rows survive. Transport failures keep their
existing error text.

Diagnostics log the container, the envelope position, or the unexpected type --
never queries or payload values.

* test(infoquest): cover the envelope guards and log skipped rows by position

Three review follow-ups on the malformed-payload containment: drop the
accidentally stacked @staticmethod on _envelope_entries, give _rows the same
indexed skip warning _envelope_entries already emits (with the container name
so a provider-side drift leaves a trace), and pin the four isinstance guards --
a non-object top-level payload and a non-object search_result across both
verticals -- which no existing case reached.
This commit is contained in:
yetuge 2026-10-17 09:00:50 +08:00 committed by GitHub
parent 072e48a338
commit 4f35eb586a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 373 additions and 47 deletions

View File

@ -190,6 +190,8 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta
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.
InfoQuest web and image search contain malformed HTTP-success payloads the same way: a `search_result` envelope that is not an object, or an `organic`/`top_stories.items`/`images_results` container that is not a list or that holds no usable entry, returns the provider's format error, while malformed entries in an otherwise valid payload are skipped so the usable rows survive. Missing, null, or empty containers keep the existing empty result. Format errors log the container or entry position only, without queries or payload values.
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

@ -18,6 +18,16 @@ logger = logging.getLogger(__name__)
_REQUEST_TIMEOUT_SECONDS = 30
class InfoQuestResponseError(ValueError):
"""Raised when InfoQuest answers HTTP 200 with a payload that is not a result page.
Search answers HTTP 200 with a ``search_result`` envelope as its only
contract. An error document delivered with the same status is not an empty
search, so the caller reports a format error rather than an empty result --
the containment rule the sibling bundled providers already follow.
"""
class InfoQuestClient:
"""Client for interacting with the InfoQuest web search and fetch API."""
@ -182,7 +192,67 @@ class InfoQuestClient:
return response_json
@staticmethod
def clean_results(raw_results: list[dict[str, dict[str, dict[str, Any]]]]) -> list[dict]:
def _envelope_entries(raw_results: object) -> list[dict]:
"""Return the ``content``/``results`` envelopes of a search payload.
Search answers HTTP 200 with a ``search_result`` envelope as its only
contract. A payload that is not a list, or whose entries are not objects
carrying object ``content.results`` mappings, is a format error: indexing
it directly raised ``TypeError``/``KeyError`` and the tool reported that
raw Python error text to the agent instead of the provider's format
error. Entries that do not fit the envelope are skipped in order so the
usable pages of an otherwise valid payload survive.
"""
if not isinstance(raw_results, list):
logger.error("InfoQuest returned an unexpected search payload type: %s", type(raw_results).__name__)
raise InfoQuestResponseError("InfoQuest returned an unexpected response format")
entries: list[dict] = []
for index, entry in enumerate(raw_results):
if not isinstance(entry, dict):
logger.warning("Skipping malformed InfoQuest result entry at index %d", index)
continue
content = entry.get("content")
results = content.get("results") if isinstance(content, dict) else None
if not isinstance(results, dict):
logger.warning("Skipping InfoQuest result entry without a usable content.results mapping at index %d", index)
continue
entries.append(results)
if raw_results and not entries:
logger.error("InfoQuest returned a search payload with no usable result envelopes")
raise InfoQuestResponseError("InfoQuest returned an unexpected response format")
return entries
@staticmethod
def _rows(value: object, container: str) -> list[dict]:
"""Return the object rows of one vertical, or [] when the key is absent.
A missing or null container is an empty search. A container that is not
a list, or a non-empty list holding no object entries, is malformed: the
caller reports the provider's format error rather than an empty result,
which would tell the agent the query legitimately matched nothing.
Non-object entries are skipped in order so the valid rows survive, and
each skip is logged with the container name and the entry index --
``_envelope_entries`` reports its skips the same way, and a provider-side
drift would otherwise shed entries with no trace at all.
"""
if value is None:
return []
if not isinstance(value, list):
logger.error("InfoQuest returned an unexpected result container type: %s", type(value).__name__)
raise InfoQuestResponseError("InfoQuest returned an unexpected response format")
rows: list[dict] = []
for index, row in enumerate(value):
if not isinstance(row, dict):
logger.warning("Skipping malformed InfoQuest result entry at %s[%d]", container, index)
continue
rows.append(row)
if value and not rows:
logger.error("InfoQuest returned a result container with no usable entries")
raise InfoQuestResponseError("InfoQuest returned an unexpected response format")
return rows
@classmethod
def clean_results(cls, raw_results: list[dict[str, dict[str, dict[str, Any]]]]) -> list[dict]:
"""Clean results from InfoQuest Web-Search API."""
logger.debug("Processing web-search results")
@ -190,32 +260,29 @@ class InfoQuestClient:
clean_results = []
counts = {"pages": 0, "news": 0}
for content_list in raw_results:
content = content_list["content"]
results = content["results"]
for results in cls._envelope_entries(raw_results):
organic_results = cls._rows(results.get("organic"), "organic")
for result in organic_results:
clean_result = {
"type": "page",
}
if "title" in result:
clean_result["title"] = result["title"]
if "desc" in result:
clean_result["desc"] = result["desc"]
clean_result["snippet"] = result["desc"]
if "url" in result:
clean_result["url"] = result["url"]
url = clean_result["url"]
if isinstance(url, str) and url and url not in seen_urls:
seen_urls.add(url)
clean_results.append(clean_result)
counts["pages"] += 1
if results.get("organic"):
organic_results = results["organic"]
for result in organic_results:
clean_result = {
"type": "page",
}
if "title" in result:
clean_result["title"] = result["title"]
if "desc" in result:
clean_result["desc"] = result["desc"]
clean_result["snippet"] = result["desc"]
if "url" in result:
clean_result["url"] = result["url"]
url = clean_result["url"]
if isinstance(url, str) and url and url not in seen_urls:
seen_urls.add(url)
clean_results.append(clean_result)
counts["pages"] += 1
if results.get("top_stories"):
news = results["top_stories"]
for obj in news["items"]:
top_stories = results.get("top_stories")
if isinstance(top_stories, dict):
news_items = cls._rows(top_stories.get("items"), "top_stories.items")
for obj in news_items:
clean_result = {
"type": "news",
}
@ -233,6 +300,9 @@ class InfoQuestClient:
seen_urls.add(url)
clean_results.append(clean_result)
counts["news"] += 1
elif top_stories is not None:
logger.error("InfoQuest returned an unexpected 'top_stories' container type: %s", type(top_stories).__name__)
raise InfoQuestResponseError("InfoQuest returned an unexpected response format")
logger.debug(f"Results processing completed | total_results={len(clean_results)} | pages={counts['pages']} | news_items={counts['news']} | unique_urls={len(seen_urls)}")
return clean_results
@ -261,12 +331,21 @@ class InfoQuestClient:
site,
output_format,
)
if not isinstance(raw_results, dict):
logger.error("InfoQuest returned an unexpected search payload type: %s", type(raw_results).__name__)
return "Error: InfoQuest returned an unexpected response format"
if "search_result" in raw_results:
logger.debug("InfoQuest Web-Search - Successfully extracted search_result from JSON response")
results = raw_results["search_result"]
if not isinstance(results, dict):
logger.error("InfoQuest returned an unexpected 'search_result' payload type: %s", type(results).__name__)
return "Error: InfoQuest returned an unexpected response format"
logger.debug("InfoQuest Web-Search - Processing raw search results")
cleaned_results = self.clean_results(results["results"])
# A missing or null ``results`` is an empty search; a container
# that is not a list, or one holding no usable entry, is the
# provider's format error (raised by ``clean_results``).
cleaned_results = self.clean_results(results.get("results") if results.get("results") is not None else [])
result_json = json.dumps(cleaned_results, indent=2, ensure_ascii=False)
@ -283,13 +362,15 @@ class InfoQuestClient:
logger.warning("InfoQuest Web-Search - Neither search_result nor content field found in JSON response")
return json.dumps(raw_results, indent=2, ensure_ascii=False)
except InfoQuestResponseError:
return "Error: InfoQuest returned an unexpected response format"
except Exception as e:
error_message = f"InfoQuest Web-Search - Search tool execution failed | mode=asynchronous | error={str(e)}"
logger.error(error_message)
return f"Error: {error_message}"
@staticmethod
def clean_results_with_image_search(raw_results: list[dict[str, dict[str, dict[str, Any]]]]) -> list[dict]:
@classmethod
def clean_results_with_image_search(cls, raw_results: list[dict[str, dict[str, dict[str, Any]]]]) -> list[dict]:
"""Clean results from InfoQuest Web-Search API."""
logger.debug("Processing web-search results")
@ -297,23 +378,18 @@ class InfoQuestClient:
clean_results = []
counts = {"images": 0}
for content_list in raw_results:
content = content_list["content"]
results = content["results"]
if results.get("images_results"):
images_results = results["images_results"]
for result in images_results:
clean_result = {}
if "original" in result:
clean_result["image_url"] = result["original"]
url = clean_result["image_url"]
if isinstance(url, str) and url and url not in seen_urls:
seen_urls.add(url)
clean_results.append(clean_result)
counts["images"] += 1
if "title" in result:
clean_result["title"] = result["title"]
for results in cls._envelope_entries(raw_results):
for result in cls._rows(results.get("images_results"), "images_results"):
clean_result = {}
if "original" in result:
clean_result["image_url"] = result["original"]
url = clean_result["image_url"]
if isinstance(url, str) and url and url not in seen_urls:
seen_urls.add(url)
clean_results.append(clean_result)
counts["images"] += 1
if "title" in result:
clean_result["title"] = result["title"]
logger.debug(f"Results processing completed | total_results={len(clean_results)} | images={counts['images']} | unique_urls={len(seen_urls)}")
return clean_results
@ -383,12 +459,19 @@ class InfoQuestClient:
output_format,
)
if not isinstance(raw_results, dict):
logger.error("InfoQuest returned an unexpected image search payload type: %s", type(raw_results).__name__)
return "Error: InfoQuest returned an unexpected response format"
if "search_result" in raw_results:
logger.debug("InfoQuest Image Search - Successfully extracted search_result from JSON response")
results = raw_results["search_result"]
if not isinstance(results, dict):
logger.error("InfoQuest returned an unexpected 'search_result' payload type: %s", type(results).__name__)
return "Error: InfoQuest returned an unexpected response format"
logger.debug(f"InfoQuest Image Search - Processing raw image search results: {results}")
cleaned_results = self.clean_results_with_image_search(results["results"])
images = results.get("results")
cleaned_results = self.clean_results_with_image_search(images if images is not None else [])
result_json = json.dumps(cleaned_results, indent=2, ensure_ascii=False)
@ -405,6 +488,8 @@ class InfoQuestClient:
logger.warning("InfoQuest Image Search - Neither search_result nor content field found in JSON response")
return json.dumps(raw_results, indent=2, ensure_ascii=False)
except InfoQuestResponseError:
return "Error: InfoQuest returned an unexpected response format"
except Exception as e:
error_message = f"InfoQuest Image Search - Image search tool execution failed | mode=asynchronous | error={str(e)}"
logger.error(error_message)

View File

@ -1,6 +1,7 @@
"""Tests for InfoQuest client and tools."""
import json
import logging
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
@ -420,3 +421,241 @@ class TestRedirectParity:
await InfoQuestClient().image_search_raw_results("query")
mock_async_client_cls.assert_called_once_with(follow_redirects=True)
class TestInfoQuestMalformedSearchPayloads:
"""A malformed 200 search payload is a format error, not an empty search.
InfoQuest answers HTTP 200 with a ``search_result`` envelope as its only
contract. An error document delivered with the same status used to either
end the research loop as "the query matched nothing" or surface a raw
Python error string, so the contraction rule the sibling bundled providers
already follow applies here too.
"""
@staticmethod
def _client(payload):
if payload is None:
# `json=None` omits the body entirely; a literal `null` document is
# what actually reaches the outer "is it an object" guard.
response = httpx.Response(200, text="null", request=httpx.Request("POST", "https://search.infoquest.bytepluses.com"))
else:
response = httpx.Response(200, json=payload, request=httpx.Request("POST", "https://search.infoquest.bytepluses.com"))
return _mock_async_client(response)
@pytest.mark.anyio
@pytest.mark.parametrize(
"payload",
[
{"search_result": {"results": "maintenance window"}},
{"search_result": {"results": {"organic": "x"}}},
{"search_result": {"results": [1, 2]}},
{"search_result": {"results": [{"content": "x"}]}},
{"search_result": {"results": [{"content": {"results": {"organic": "x"}}}]}},
{"search_result": {"results": [{"content": {"results": {"organic": {"a": 1}}}}]}},
{"search_result": {"results": [{"content": {"results": {"top_stories": {"items": "x"}}}}]}},
],
ids=[
"results-is-a-string",
"organic-is-a-string",
"results-entries-not-objects",
"content-is-not-an-object",
"organic-container-is-not-a-list",
"organic-container-is-a-populated-dict",
"top-stories-items-is-not-a-list",
],
)
async def test_web_search_reports_a_format_error(self, payload, monkeypatch):
"""Each malformed container returns the provider's format error."""
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
result = await InfoQuestClient().web_search("test query")
assert result == "Error: InfoQuest returned an unexpected response format"
@pytest.mark.anyio
@pytest.mark.parametrize(
"payload",
[
["not-an-object"],
"maintenance window",
7,
None,
{"search_result": "not-an-object"},
{"search_result": []},
{"search_result": [1, 2]},
{"search_result": None},
],
ids=[
"payload-is-a-list",
"payload-is-a-string",
"payload-is-an-int",
"payload-is-null",
"search-result-is-a-string",
"search-result-is-a-list",
"search-result-is-a-populated-list",
"search-result-is-null",
],
)
async def test_web_search_rejects_non_object_envelopes(self, payload, monkeypatch):
"""A top-level payload or ``search_result`` that is not an object is malformed.
The two outermost ``isinstance`` guards were the last uncovered branches:
a top-level list/scalar used to reach ``"search_result" in raw_results``
and a non-object ``search_result`` used to reach ``.get``, neither of
which is the provider's format error.
"""
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
result = await InfoQuestClient().web_search("test query")
assert result == "Error: InfoQuest returned an unexpected response format"
@pytest.mark.anyio
@pytest.mark.parametrize(
"payload",
[
["not-an-object"],
"maintenance window",
None,
{"search_result": "not-an-object"},
{"search_result": []},
{"search_result": None},
],
ids=[
"payload-is-a-list",
"payload-is-a-string",
"payload-is-null",
"search-result-is-a-string",
"search-result-is-a-list",
"search-result-is-null",
],
)
async def test_image_search_rejects_non_object_envelopes(self, payload, monkeypatch):
"""The image vertical shares the outer-envelope guards."""
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
result = await InfoQuestClient().image_search("test query")
assert result == "Error: InfoQuest returned an unexpected response format"
@pytest.mark.anyio
async def test_web_search_logs_the_container_and_index_of_a_skipped_row(self, monkeypatch, caplog):
"""A dropped non-object row is reported with its container and position.
``_envelope_entries`` already warned this way; ``_rows`` shed the same
kind of drift silently, so a payload like ``{"organic": [1, {"url": ...}]}``
lost entries with no trace. The README documents logging the container
name and entry position only, never the payload values.
"""
payload = {
"search_result": {
"results": [
{
"content": {
"results": {
"organic": [
1,
{"title": "Kept", "desc": "d", "url": "https://example.com/1"},
]
}
}
}
]
}
}
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
with caplog.at_level(logging.WARNING, logger="deerflow.community.infoquest.infoquest_client"):
result = await InfoQuestClient().web_search("test query")
assert [row["title"] for row in json.loads(result)] == ["Kept"]
warnings = [record.getMessage() for record in caplog.records if record.levelno == logging.WARNING]
assert any("organic[0]" in message for message in warnings), warnings
assert not any("example.com" in message for message in warnings), warnings
@pytest.mark.anyio
@pytest.mark.parametrize(
"payload",
[
{"search_result": {}},
{"search_result": {"results": None}},
{"search_result": {"results": []}},
{"search_result": {"results": [{"content": {"results": {"organic": []}}}]}},
],
ids=["search-result-is-empty", "results-is-null", "results-is-empty-list", "organic-is-empty"],
)
async def test_web_search_keeps_an_empty_search_empty(self, payload, monkeypatch):
"""A missing, null or empty container stays an empty search."""
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
result = await InfoQuestClient().web_search("test query")
assert json.loads(result) == []
@pytest.mark.anyio
async def test_web_search_keeps_the_valid_rows_of_a_mixed_payload(self, monkeypatch):
"""A payload with usable rows still answers with those rows."""
payload = {
"search_result": {
"results": [
{"content": {"results": {"organic": [{"title": "Test Page", "desc": "d", "url": "https://example.com/1"}]}}},
"malformed",
]
}
}
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
result = await InfoQuestClient().web_search("test query")
rows = json.loads(result)
assert [row["title"] for row in rows] == ["Test Page"]
@pytest.mark.anyio
@pytest.mark.parametrize(
"payload",
[
{"search_result": {"results": "maintenance window"}},
{"search_result": {"results": [{"content": {"results": {"images_results": "x"}}}]}},
{"search_result": {"results": [{"content": {"results": {"images_results": [1, 2]}}}]}},
],
ids=["results-is-a-string", "images-container-is-not-a-list", "images-entries-not-objects"],
)
async def test_image_search_reports_a_format_error(self, payload, monkeypatch):
"""The image vertical shares the containment rule."""
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
result = await InfoQuestClient().image_search("test query")
assert result == "Error: InfoQuest returned an unexpected response format"
@pytest.mark.anyio
@pytest.mark.parametrize(
"payload",
[
{"search_result": {"results": []}},
{"search_result": {"results": [{"content": {"results": {"images_results": []}}}]}},
],
ids=["results-is-empty-list", "images-is-empty"],
)
async def test_image_search_keeps_an_empty_search_empty(self, payload, monkeypatch):
"""The image vertical keeps empty containers empty as well."""
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
result = await InfoQuestClient().image_search("test query")
assert json.loads(result) == []
@pytest.mark.anyio
async def test_web_search_keeps_other_failures_as_their_own_errors(self, monkeypatch):
"""Transport failures keep their existing error text."""
client = MagicMock()
client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=None)
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=client))
result = await InfoQuestClient().web_search("test query")
assert result.startswith("Error: ")
assert "unexpected response format" not in result