mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
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:
parent
061a7dfc14
commit
90aa5d4302
@ -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).
|
||||
|
||||
18
backend/packages/harness/deerflow/community/brave/AGENTS.md
Normal file
18
backend/packages/harness/deerflow/community/brave/AGENTS.md
Normal 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.
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
114
backend/tests/test_brave_response_shapes.py
Normal file
114
backend/tests/test_brave_response_shapes.py
Normal 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",
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user