fix(brave): contain malformed web search results (#6550)

* fix(brave): contain malformed web search results

* fix(brave): log malformed responses and isolate test transport

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
四野 2026-10-10 09:52:40 +08:00 committed by GitHub
parent 061a7dfc14
commit 90aa5d4302
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 151 additions and 1 deletions

View File

@ -172,6 +172,8 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta
The wizard also lets you configure an optional web search provider, or skip it for now.
Brave web search preserves valid entries in mixed result lists. Malformed response containers or lists containing no usable entries return a structured format error; missing, null, or empty results keep the existing "No results found" response. Format errors also log the malformed container's path and type, or the absence of usable result objects, without including search queries, credentials, 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.
Jina fetches support opt-in bounded retries via `max_retries` (default `0`) and `retry_budget_seconds` (default `30`) in the tool configuration. Valid `Retry-After` hints set a minimum wait for HTTP 429/503; 429 without a valid hint stays terminal. Hints that cannot fit the remaining budget stop retries. Local backoff remains randomized. Retries may increase upstream requests and cost; see [Jina fetch retries](backend/docs/CONFIGURATION.md#jina-fetch-retries).

View File

@ -0,0 +1,18 @@
# Brave Search
Web search validates the HTTP-success payload before normalizing results:
`web` must be an object and `results` a list when present and non-null.
Reject wrong container types with the shared structured format error, including
falsy values. Skip non-object entries while preserving valid objects in order;
an entirely malformed non-empty list is a format error, not an empty search.
Missing/null containers and an empty results list keep "No results found".
Preserve existing field defaults, query/count parameters, and image-search behavior.
Log the malformed container's path and type, or that no result objects remain,
before returning a format error. Keep queries, credentials, and payload values
out of these diagnostics; ordinary missing/empty results are not format errors.
`tests/test_brave_response_shapes.py` exercises the real tool and `_brave_get`
through an offline HTTPX transport with synthetic JSON bodies. Do not mock the
normalizer or substitute its final output. Patch the provider's `httpx` reference
with a local client factory and `HTTPStatusError`; never replace the shared
`httpx.Client`. Cover both the unchanged global client and HTTP error handling.

View File

@ -258,7 +258,18 @@ def web_search_tool(query: str, max_results: int = 5, time_range: SearchTimeRang
if error_json is not None:
return error_json
web_results = (data.get("web") or {}).get("results", [])
web = data.get("web")
if web is None:
web = {}
if not isinstance(web, dict):
logger.error("Brave Search returned unexpected 'web' payload type: %s", type(web).__name__)
return _unexpected_format_error(query)
web_results = web.get("results")
if web_results is None:
web_results = []
if not isinstance(web_results, list):
logger.error("Brave Search returned unexpected 'web.results' payload type: %s", type(web_results).__name__)
return _unexpected_format_error(query)
if not web_results:
return json.dumps({"error": "No results found", "query": query}, ensure_ascii=False)
@ -269,7 +280,11 @@ def web_search_tool(query: str, max_results: int = 5, time_range: SearchTimeRang
"content": r.get("description", ""),
}
for r in web_results
if isinstance(r, dict)
]
if not normalized_results:
logger.error("Brave Search returned 'web.results' with no usable result objects")
return _unexpected_format_error(query)
output = {
"query": query,

View File

@ -20,6 +20,7 @@ EXPECTED_GUIDANCE_PATHS = {
"backend/packages/harness/deerflow/agents/middlewares/AGENTS.md",
"backend/packages/harness/deerflow/agents/memory/AGENTS.md",
"backend/packages/harness/deerflow/agents/task_continuity/AGENTS.md",
"backend/packages/harness/deerflow/community/brave/AGENTS.md",
"backend/packages/harness/deerflow/community/jina_ai/AGENTS.md",
"backend/packages/harness/deerflow/community/ragflow/AGENTS.md",
"backend/packages/harness/deerflow/community/serper/AGENTS.md",

View File

@ -0,0 +1,114 @@
"""Brave web search must contain malformed HTTP-success payloads at the tool boundary."""
import json
import logging
from types import SimpleNamespace
import httpx
import pytest
@pytest.fixture
def invoke_search(monkeypatch):
from deerflow.community.brave import tools
config = SimpleNamespace(get_tool_config=lambda _: SimpleNamespace(model_extra={"api_key": "synthetic-test-key"}))
monkeypatch.setattr(tools, "get_app_config", lambda: config)
real_client = httpx.Client
def invoke(payload, *, status_code=200):
requests = []
def respond(request):
requests.append(request)
return httpx.Response(status_code, content=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"})
transport = httpx.MockTransport(respond)
monkeypatch.setattr(
tools,
"httpx",
SimpleNamespace(Client=lambda **kwargs: real_client(transport=transport, **kwargs), HTTPStatusError=httpx.HTTPStatusError),
)
result = json.loads(tools.web_search_tool.invoke({"query": " 文档 search "}))
assert len(requests) == 1
assert requests[0].url.path == "/res/v1/web/search"
assert requests[0].url.params["q"] == "文档 search"
assert requests[0].url.params["count"] == "5"
assert result["query"] == "文档 search"
return result
return invoke
def _assert_format_error_log(caplog, expected):
records = [record for record in caplog.records if record.name == "deerflow.community.brave.tools" and record.levelno == logging.ERROR]
assert [record.getMessage() for record in records] == [expected]
assert "文档 search" not in caplog.text
assert "synthetic-test-key" not in caplog.text
assert "provider-private-payload" not in caplog.text
@pytest.mark.parametrize("web", [False, 0, "", "provider-private-payload", [], ["provider-private-payload"]])
def test_malformed_web_container_returns_format_error(invoke_search, web, caplog):
assert invoke_search({"web": web}) == {"error": "Brave Search returned an unexpected response format", "query": "文档 search"}
_assert_format_error_log(caplog, f"Brave Search returned unexpected 'web' payload type: {type(web).__name__}")
@pytest.mark.parametrize("payload", [None, False, 0, "invalid", []])
def test_malformed_top_level_keeps_existing_format_error(invoke_search, payload):
assert invoke_search(payload) == {"error": "Brave Search returned an unexpected response format", "query": "文档 search"}
@pytest.mark.parametrize("results", [False, 0, "", "provider-private-payload", {}, {"title": "provider-private-payload"}])
def test_malformed_results_container_returns_format_error(invoke_search, results, caplog):
assert invoke_search({"web": {"results": results}}) == {"error": "Brave Search returned an unexpected response format", "query": "文档 search"}
_assert_format_error_log(caplog, f"Brave Search returned unexpected 'web.results' payload type: {type(results).__name__}")
@pytest.mark.parametrize("results", [[None], [False, 0, "provider-private-payload", []]])
def test_all_malformed_entries_return_format_error(invoke_search, results, caplog):
assert invoke_search({"web": {"results": results}}) == {"error": "Brave Search returned an unexpected response format", "query": "文档 search"}
_assert_format_error_log(caplog, "Brave Search returned 'web.results' with no usable result objects")
def test_mixed_entries_preserve_valid_results_in_order(invoke_search):
result = invoke_search({"web": {"results": [None, {"title": "文档", "url": "https://example.com/one", "description": "概述"}, "invalid", {}, [], {"title": "Second"}]}})
assert result == {
"query": "文档 search",
"total_results": 3,
"results": [
{"title": "文档", "url": "https://example.com/one", "content": "概述"},
{"title": "", "url": "", "content": ""},
{"title": "Second", "url": "", "content": ""},
],
}
@pytest.mark.parametrize("payload", [{}, {"web": None}, {"web": {}}, {"web": {"results": None}}, {"web": {"results": []}}])
def test_missing_or_empty_results_keep_no_results_response(invoke_search, payload, caplog):
assert invoke_search(payload) == {"error": "No results found", "query": "文档 search"}
assert not [record for record in caplog.records if record.name == "deerflow.community.brave.tools" and record.levelno == logging.ERROR]
def test_valid_results_keep_existing_normalization(invoke_search):
assert invoke_search({"web": {"results": [{"title": "Title", "url": "https://example.com", "description": "Snippet"}, {}]}}) == {
"query": "文档 search",
"total_results": 2,
"results": [{"title": "Title", "url": "https://example.com", "content": "Snippet"}, {"title": "", "url": "", "content": ""}],
}
def test_http_transport_patch_does_not_replace_global_client(invoke_search):
real_client = httpx.Client
invoke_search({"web": {"results": []}})
assert httpx.Client is real_client
with httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(204))) as client:
assert client.get("https://unrelated.invalid/probe").status_code == 204
@pytest.mark.parametrize("status_code", [403, 503])
def test_http_error_keeps_existing_structured_response(invoke_search, status_code):
assert invoke_search({"error": "synthetic-denial"}, status_code=status_code) == {
"error": f"Brave Search API error: HTTP {status_code}",
"query": "文档 search",
}