mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-14 03:15:58 +00:00
feat(community): add Brave Search web search tool (#3528)
* feat(community): add Brave Search web search tool Add a community web_search provider backed by the official Brave Search API (https://api.search.brave.com/res/v1/web/search). API key is read from the tool config (inline api_key) or the BRAVE_SEARCH_API_KEY env var. Output schema (title/url/content) matches existing search tools. No new dependencies (uses the existing httpx). Also wires up the setup wizard, doctor health check, config example, and EN/ZH docs. * refactor(community): drop redundant [:count] slice in Brave search The Brave API already caps results via the `count` request param, so client-side slicing was redundant. Tests now simulate the API honoring `count` instead of relying on the slice. Addresses PR review nit. * style(tests): apply ruff format to test_doctor.py Collapse multiline write_text calls onto single lines to satisfy the CI ruff formatter (lint-backend was failing on format --check).
This commit is contained in:
@@ -234,7 +234,7 @@ tools:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Built-in Tools**:
|
**Built-in Tools**:
|
||||||
- `web_search` - Search the web (DuckDuckGo, Tavily, Exa, InfoQuest, Firecrawl)
|
- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Exa, InfoQuest, Firecrawl)
|
||||||
- `web_fetch` - Fetch web pages (Jina AI, Exa, InfoQuest, Firecrawl)
|
- `web_fetch` - Fetch web pages (Jina AI, Exa, InfoQuest, Firecrawl)
|
||||||
- `ls` - List directory contents
|
- `ls` - List directory contents
|
||||||
- `read_file` - Read file contents
|
- `read_file` - Read file contents
|
||||||
@@ -364,6 +364,7 @@ models:
|
|||||||
- `MIMO_API_KEY` - Xiaomi MiMo API key
|
- `MIMO_API_KEY` - Xiaomi MiMo API key
|
||||||
- `NOVITA_API_KEY` - Novita API key (OpenAI-compatible endpoint)
|
- `NOVITA_API_KEY` - Novita API key (OpenAI-compatible endpoint)
|
||||||
- `TAVILY_API_KEY` - Tavily search API key
|
- `TAVILY_API_KEY` - Tavily search API key
|
||||||
|
- `BRAVE_SEARCH_API_KEY` - Brave Search API key
|
||||||
- `DEER_FLOW_PROJECT_ROOT` - Project root for relative runtime paths
|
- `DEER_FLOW_PROJECT_ROOT` - Project root for relative runtime paths
|
||||||
- `DEER_FLOW_CONFIG_PATH` - Custom config file path
|
- `DEER_FLOW_CONFIG_PATH` - Custom config file path
|
||||||
- `DEER_FLOW_EXTENSIONS_CONFIG_PATH` - Custom extensions config file path
|
- `DEER_FLOW_EXTENSIONS_CONFIG_PATH` - Custom extensions config file path
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .tools import web_search_tool
|
||||||
|
|
||||||
|
__all__ = ["web_search_tool"]
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
Web Search Tool - Search the web using the Brave Search API.
|
||||||
|
|
||||||
|
Brave Search provides web results from an independent search index via a
|
||||||
|
REST API. An API key is required. Sign up at https://brave.com/search/api/
|
||||||
|
to get one.
|
||||||
|
|
||||||
|
Unlike the DuckDuckGo ``backend: brave`` option (which scrapes results via the
|
||||||
|
DDGS aggregator), this provider calls the official Brave Search API directly,
|
||||||
|
giving structured results, authenticated quota, and a documented SLA.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from langchain.tools import tool
|
||||||
|
|
||||||
|
from deerflow.config import get_app_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
|
||||||
|
_DEFAULT_MAX_RESULTS = 5
|
||||||
|
# Brave Search API caps the `count` parameter at 20 results per request.
|
||||||
|
_BRAVE_MAX_COUNT = 20
|
||||||
|
_api_key_warned = False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_key() -> str | None:
|
||||||
|
config = get_app_config().get_tool_config("web_search")
|
||||||
|
if config is not None:
|
||||||
|
api_key = (config.model_extra or {}).get("api_key")
|
||||||
|
if isinstance(api_key, str) and api_key.strip():
|
||||||
|
return api_key
|
||||||
|
return os.getenv("BRAVE_SEARCH_API_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_max_results(value: object, *, default: int = _DEFAULT_MAX_RESULTS) -> int:
|
||||||
|
try:
|
||||||
|
coerced = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning(
|
||||||
|
"Invalid Brave Search max_results=%r; using default %s",
|
||||||
|
value,
|
||||||
|
default,
|
||||||
|
)
|
||||||
|
coerced = default
|
||||||
|
|
||||||
|
return max(1, min(coerced, _BRAVE_MAX_COUNT))
|
||||||
|
|
||||||
|
|
||||||
|
@tool("web_search", parse_docstring=True)
|
||||||
|
def web_search_tool(query: str, max_results: int = 5) -> str:
|
||||||
|
"""Search the web for information using Brave Search.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search keywords describing what you want to find. Be specific for better results.
|
||||||
|
max_results: Maximum number of search results to return. Default is 5.
|
||||||
|
"""
|
||||||
|
global _api_key_warned
|
||||||
|
|
||||||
|
config = get_app_config().get_tool_config("web_search")
|
||||||
|
if config is not None and "max_results" in (config.model_extra or {}):
|
||||||
|
max_results = config.model_extra["max_results"]
|
||||||
|
|
||||||
|
count = _coerce_max_results(max_results)
|
||||||
|
|
||||||
|
api_key = _get_api_key()
|
||||||
|
if not api_key:
|
||||||
|
if not _api_key_warned:
|
||||||
|
_api_key_warned = True
|
||||||
|
logger.warning("Brave Search API key is not set. Set BRAVE_SEARCH_API_KEY in your environment or provide api_key in config.yaml. Sign up at https://brave.com/search/api/")
|
||||||
|
return json.dumps(
|
||||||
|
{"error": "BRAVE_SEARCH_API_KEY is not configured", "query": query},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"X-Subscription-Token": api_key,
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
params = {"q": query, "count": count, "text_decorations": False}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=30) as client:
|
||||||
|
response = client.get(_BRAVE_ENDPOINT, headers=headers, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(f"Brave Search API returned HTTP {e.response.status_code}: {e.response.text}")
|
||||||
|
return json.dumps(
|
||||||
|
{"error": f"Brave Search API error: HTTP {e.response.status_code}", "query": query},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Brave search failed: {type(e).__name__}: {e}")
|
||||||
|
return json.dumps({"error": str(e), "query": query}, ensure_ascii=False)
|
||||||
|
|
||||||
|
web_results = (data.get("web") or {}).get("results", [])
|
||||||
|
if not web_results:
|
||||||
|
return json.dumps({"error": "No results found", "query": query}, ensure_ascii=False)
|
||||||
|
|
||||||
|
normalized_results = [
|
||||||
|
{
|
||||||
|
"title": r.get("title", ""),
|
||||||
|
"url": r.get("url", ""),
|
||||||
|
"content": r.get("description", ""),
|
||||||
|
}
|
||||||
|
for r in web_results
|
||||||
|
]
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"query": query,
|
||||||
|
"total_results": len(normalized_results),
|
||||||
|
"results": normalized_results,
|
||||||
|
}
|
||||||
|
return json.dumps(output, indent=2, ensure_ascii=False)
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
"""Unit tests for the Brave Search community web search tool."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_api_key_warned():
|
||||||
|
"""Reset the module-level warning flag before each test."""
|
||||||
|
import deerflow.community.brave.tools as brave_mod
|
||||||
|
|
||||||
|
brave_mod._api_key_warned = False
|
||||||
|
yield
|
||||||
|
brave_mod._api_key_warned = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config_with_key():
|
||||||
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||||
|
tool_config = MagicMock()
|
||||||
|
tool_config.model_extra = {"api_key": "test-brave-key", "max_results": 5}
|
||||||
|
mock.return_value.get_tool_config.return_value = tool_config
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config_no_key():
|
||||||
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||||
|
tool_config = MagicMock()
|
||||||
|
tool_config.model_extra = {}
|
||||||
|
mock.return_value.get_tool_config.return_value = tool_config
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
def _make_brave_response(results: list) -> MagicMock:
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = {"web": {"results": results}}
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
return mock_resp
|
||||||
|
|
||||||
|
|
||||||
|
def _count_aware_get(results: list):
|
||||||
|
"""Mimic Brave returning at most `count` results for the request."""
|
||||||
|
|
||||||
|
def _get(url, **kwargs):
|
||||||
|
count = kwargs["params"]["count"]
|
||||||
|
return _make_brave_response(results[:count])
|
||||||
|
|
||||||
|
return _get
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetApiKey:
|
||||||
|
def test_returns_config_key_when_present(self):
|
||||||
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||||
|
tool_config = MagicMock()
|
||||||
|
tool_config.model_extra = {"api_key": "from-config"}
|
||||||
|
mock.return_value.get_tool_config.return_value = tool_config
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import _get_api_key
|
||||||
|
|
||||||
|
assert _get_api_key() == "from-config"
|
||||||
|
|
||||||
|
def test_falls_back_to_env_when_config_key_empty(self):
|
||||||
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||||
|
tool_config = MagicMock()
|
||||||
|
tool_config.model_extra = {"api_key": " "}
|
||||||
|
mock.return_value.get_tool_config.return_value = tool_config
|
||||||
|
with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-key"}, clear=True):
|
||||||
|
from deerflow.community.brave.tools import _get_api_key
|
||||||
|
|
||||||
|
assert _get_api_key() == "env-key"
|
||||||
|
|
||||||
|
def test_falls_back_to_env_when_no_config(self):
|
||||||
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||||
|
mock.return_value.get_tool_config.return_value = None
|
||||||
|
with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-only"}, clear=True):
|
||||||
|
from deerflow.community.brave.tools import _get_api_key
|
||||||
|
|
||||||
|
assert _get_api_key() == "env-only"
|
||||||
|
|
||||||
|
def test_ignores_legacy_brave_api_key(self):
|
||||||
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||||
|
mock.return_value.get_tool_config.return_value = None
|
||||||
|
with patch.dict("os.environ", {"BRAVE_API_KEY": "legacy"}, clear=True):
|
||||||
|
from deerflow.community.brave.tools import _get_api_key
|
||||||
|
|
||||||
|
assert _get_api_key() is None
|
||||||
|
|
||||||
|
def test_returns_none_when_no_key_anywhere(self):
|
||||||
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||||
|
mock.return_value.get_tool_config.return_value = None
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
from deerflow.community.brave.tools import _get_api_key
|
||||||
|
|
||||||
|
assert _get_api_key() is None
|
||||||
|
|
||||||
|
def test_model_extra_none_does_not_crash(self):
|
||||||
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||||
|
tool_config = MagicMock()
|
||||||
|
tool_config.model_extra = None
|
||||||
|
mock.return_value.get_tool_config.return_value = tool_config
|
||||||
|
with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-key"}, clear=True):
|
||||||
|
from deerflow.community.brave.tools import _get_api_key
|
||||||
|
|
||||||
|
assert _get_api_key() == "env-key"
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSearchTool:
|
||||||
|
def test_basic_search_returns_normalized_results(self, mock_config_with_key):
|
||||||
|
results = [
|
||||||
|
{"title": "Result 1", "url": "https://example.com/1", "description": "Desc 1"},
|
||||||
|
{"title": "Result 2", "url": "https://example.com/2", "description": "Desc 2"},
|
||||||
|
]
|
||||||
|
mock_resp = _make_brave_response(results)
|
||||||
|
|
||||||
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||||
|
mock_client_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
result = web_search_tool.invoke({"query": "python tutorial"})
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert parsed["query"] == "python tutorial"
|
||||||
|
assert parsed["total_results"] == 2
|
||||||
|
assert parsed["results"][0]["title"] == "Result 1"
|
||||||
|
assert parsed["results"][0]["url"] == "https://example.com/1"
|
||||||
|
assert parsed["results"][0]["content"] == "Desc 1"
|
||||||
|
|
||||||
|
def test_respects_max_results_from_config(self, mock_config_with_key):
|
||||||
|
mock_config_with_key.return_value.get_tool_config.return_value.model_extra = {
|
||||||
|
"api_key": "test-key",
|
||||||
|
"max_results": 3,
|
||||||
|
}
|
||||||
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(10)]
|
||||||
|
|
||||||
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||||
|
mock_client_cls.return_value.__enter__.return_value.get.side_effect = _count_aware_get(results)
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
result = web_search_tool.invoke({"query": "test"})
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert parsed["total_results"] == 3
|
||||||
|
assert len(parsed["results"]) == 3
|
||||||
|
|
||||||
|
def test_max_results_parameter_accepted(self, mock_config_no_key):
|
||||||
|
"""Tool accepts max_results as a call parameter when config does not override it."""
|
||||||
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(10)]
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-key"}, clear=True):
|
||||||
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||||
|
mock_client_cls.return_value.__enter__.return_value.get.side_effect = _count_aware_get(results)
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
result = web_search_tool.invoke({"query": "test", "max_results": 2})
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert parsed["total_results"] == 2
|
||||||
|
|
||||||
|
def test_config_max_results_overrides_parameter(self):
|
||||||
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||||
|
tool_config = MagicMock()
|
||||||
|
tool_config.model_extra = {"api_key": "test-key", "max_results": 3}
|
||||||
|
mock.return_value.get_tool_config.return_value = tool_config
|
||||||
|
|
||||||
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(10)]
|
||||||
|
|
||||||
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||||
|
mock_client_cls.return_value.__enter__.return_value.get.side_effect = _count_aware_get(results)
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
result = web_search_tool.invoke({"query": "test", "max_results": 8})
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert parsed["total_results"] == 3
|
||||||
|
|
||||||
|
def test_max_results_string_from_env_is_coerced_and_clamped(self):
|
||||||
|
"""Env-sourced max_results is a string and must be coerced and clamped to 20."""
|
||||||
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||||
|
tool_config = MagicMock()
|
||||||
|
tool_config.model_extra = {"api_key": "test-key", "max_results": "50"}
|
||||||
|
mock.return_value.get_tool_config.return_value = tool_config
|
||||||
|
|
||||||
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(30)]
|
||||||
|
|
||||||
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||||
|
mock_get = mock_client_cls.return_value.__enter__.return_value.get
|
||||||
|
mock_get.side_effect = _count_aware_get(results)
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
result = web_search_tool.invoke({"query": "test"})
|
||||||
|
parsed = json.loads(result)
|
||||||
|
params = mock_get.call_args.kwargs["params"]
|
||||||
|
|
||||||
|
assert params["count"] == 20
|
||||||
|
assert parsed["total_results"] == 20
|
||||||
|
|
||||||
|
def test_invalid_max_results_falls_back_to_default(self, caplog):
|
||||||
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||||
|
tool_config = MagicMock()
|
||||||
|
tool_config.model_extra = {"api_key": "test-key", "max_results": "abc"}
|
||||||
|
mock.return_value.get_tool_config.return_value = tool_config
|
||||||
|
|
||||||
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(10)]
|
||||||
|
|
||||||
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||||
|
mock_get = mock_client_cls.return_value.__enter__.return_value.get
|
||||||
|
mock_get.side_effect = _count_aware_get(results)
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING", logger="deerflow.community.brave.tools"):
|
||||||
|
result = web_search_tool.invoke({"query": "test"})
|
||||||
|
parsed = json.loads(result)
|
||||||
|
params = mock_get.call_args.kwargs["params"]
|
||||||
|
|
||||||
|
assert params["count"] == 5
|
||||||
|
assert parsed["total_results"] == 5
|
||||||
|
assert any("Invalid Brave Search max_results" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
def test_empty_results_returns_error_json(self, mock_config_with_key):
|
||||||
|
mock_resp = _make_brave_response([])
|
||||||
|
|
||||||
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||||
|
mock_client_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
result = web_search_tool.invoke({"query": "no results"})
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert parsed["error"] == "No results found"
|
||||||
|
assert parsed["query"] == "no results"
|
||||||
|
|
||||||
|
def test_missing_web_key_returns_error_json(self, mock_config_with_key):
|
||||||
|
"""A response without a `web` block should be treated as no results."""
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = {}
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||||
|
mock_client_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
result = web_search_tool.invoke({"query": "test"})
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert parsed["error"] == "No results found"
|
||||||
|
|
||||||
|
def test_missing_api_key_returns_error_json(self, mock_config_no_key):
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
result = web_search_tool.invoke({"query": "test"})
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert "error" in parsed
|
||||||
|
assert "BRAVE_SEARCH_API_KEY" in parsed["error"]
|
||||||
|
|
||||||
|
def test_missing_api_key_logs_warning_once(self, mock_config_no_key, caplog):
|
||||||
|
import logging
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.brave.tools"):
|
||||||
|
web_search_tool.invoke({"query": "q1"})
|
||||||
|
web_search_tool.invoke({"query": "q2"})
|
||||||
|
|
||||||
|
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||||
|
assert len(warnings) == 1
|
||||||
|
|
||||||
|
def test_http_error_returns_structured_error(self, mock_config_with_key):
|
||||||
|
mock_error_response = MagicMock()
|
||||||
|
mock_error_response.status_code = 403
|
||||||
|
mock_error_response.text = "Forbidden"
|
||||||
|
|
||||||
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||||
|
mock_client_cls.return_value.__enter__.return_value.get.side_effect = httpx.HTTPStatusError("403", request=MagicMock(), response=mock_error_response)
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
result = web_search_tool.invoke({"query": "test"})
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert "error" in parsed
|
||||||
|
assert "403" in parsed["error"]
|
||||||
|
|
||||||
|
def test_network_exception_returns_error_json(self, mock_config_with_key):
|
||||||
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||||
|
mock_client_cls.return_value.__enter__.return_value.get.side_effect = Exception("timeout")
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
result = web_search_tool.invoke({"query": "test"})
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert "error" in parsed
|
||||||
|
|
||||||
|
def test_sends_correct_headers_and_params(self, mock_config_with_key):
|
||||||
|
results = [{"title": "T", "url": "https://x.com", "description": "D"}]
|
||||||
|
mock_resp = _make_brave_response(results)
|
||||||
|
|
||||||
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||||
|
mock_get = mock_client_cls.return_value.__enter__.return_value.get
|
||||||
|
mock_get.return_value = mock_resp
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
web_search_tool.invoke({"query": "hello world"})
|
||||||
|
|
||||||
|
call_kwargs = mock_get.call_args
|
||||||
|
headers = call_kwargs.kwargs["headers"]
|
||||||
|
params = call_kwargs.kwargs["params"]
|
||||||
|
|
||||||
|
assert headers["X-Subscription-Token"] == "test-brave-key"
|
||||||
|
assert params["q"] == "hello world"
|
||||||
|
assert params["count"] == 5
|
||||||
|
|
||||||
|
def test_uses_env_key_when_config_absent(self):
|
||||||
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||||
|
mock.return_value.get_tool_config.return_value = None
|
||||||
|
with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-only-key"}, clear=True):
|
||||||
|
results = [{"title": "T", "url": "https://x.com", "description": "D"}]
|
||||||
|
mock_resp = _make_brave_response(results)
|
||||||
|
|
||||||
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||||
|
mock_get = mock_client_cls.return_value.__enter__.return_value.get
|
||||||
|
mock_get.return_value = mock_resp
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
web_search_tool.invoke({"query": "env key test"})
|
||||||
|
headers = mock_get.call_args.kwargs["headers"]
|
||||||
|
|
||||||
|
assert headers["X-Subscription-Token"] == "env-only-key"
|
||||||
|
|
||||||
|
def test_partial_fields_in_result(self, mock_config_with_key):
|
||||||
|
"""Missing title/url/description should default to empty string."""
|
||||||
|
results = [{}]
|
||||||
|
mock_resp = _make_brave_response(results)
|
||||||
|
|
||||||
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||||
|
mock_client_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||||
|
|
||||||
|
from deerflow.community.brave.tools import web_search_tool
|
||||||
|
|
||||||
|
result = web_search_tool.invoke({"query": "test"})
|
||||||
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
assert parsed["results"][0] == {"title": "", "url": "", "content": ""}
|
||||||
@@ -198,6 +198,38 @@ class TestCheckWebSearch:
|
|||||||
assert result.fix is not None
|
assert result.fix is not None
|
||||||
assert "make setup" in result.fix
|
assert "make setup" in result.fix
|
||||||
|
|
||||||
|
def test_brave_with_key_ok(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "bsa-test")
|
||||||
|
cfg = tmp_path / "config.yaml"
|
||||||
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.brave.tools:web_search_tool\n")
|
||||||
|
result = doctor.check_web_search(cfg)
|
||||||
|
assert result.status == "ok"
|
||||||
|
|
||||||
|
def test_brave_without_key_warns(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||||
|
cfg = tmp_path / "config.yaml"
|
||||||
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.brave.tools:web_search_tool\n")
|
||||||
|
result = doctor.check_web_search(cfg)
|
||||||
|
assert result.status == "warn"
|
||||||
|
assert result.fix is not None
|
||||||
|
assert "BRAVE_SEARCH_API_KEY" in result.fix
|
||||||
|
|
||||||
|
def test_brave_with_inline_api_key_ok(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||||
|
cfg = tmp_path / "config.yaml"
|
||||||
|
cfg.write_text('config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.brave.tools:web_search_tool\n api_key: "inline-key"\n')
|
||||||
|
result = doctor.check_web_search(cfg)
|
||||||
|
assert result.status == "ok"
|
||||||
|
assert "api_key configured" in result.detail
|
||||||
|
|
||||||
|
def test_brave_with_api_key_env_ref_ok(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "bsa-test")
|
||||||
|
cfg = tmp_path / "config.yaml"
|
||||||
|
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.brave.tools:web_search_tool\n api_key: $BRAVE_SEARCH_API_KEY\n")
|
||||||
|
result = doctor.check_web_search(cfg)
|
||||||
|
assert result.status == "ok"
|
||||||
|
assert "api_key" in result.detail
|
||||||
|
|
||||||
def test_no_search_tool_warns(self, tmp_path):
|
def test_no_search_tool_warns(self, tmp_path):
|
||||||
cfg = tmp_path / "config.yaml"
|
cfg = tmp_path / "config.yaml"
|
||||||
cfg.write_text("config_version: 5\ntools: []\n")
|
cfg.write_text("config_version: 5\ntools: []\n")
|
||||||
|
|||||||
@@ -530,6 +530,16 @@ tools:
|
|||||||
# max_results: 5
|
# max_results: 5
|
||||||
# # api_key: $SERPER_API_KEY # Optional if SERPER_API_KEY env var is set
|
# # api_key: $SERPER_API_KEY # Optional if SERPER_API_KEY env var is set
|
||||||
|
|
||||||
|
# Web search tool (uses Brave Search API, requires BRAVE_SEARCH_API_KEY)
|
||||||
|
# Brave Search returns results from an independent index. Sign up at
|
||||||
|
# https://brave.com/search/api/ to get a key. Unlike the DuckDuckGo
|
||||||
|
# `backend: brave` option above, this calls the official Brave API directly.
|
||||||
|
# - name: web_search
|
||||||
|
# group: web
|
||||||
|
# use: deerflow.community.brave.tools:web_search_tool
|
||||||
|
# max_results: 5 # Capped at 20 by the Brave Search API
|
||||||
|
# # api_key: $BRAVE_SEARCH_API_KEY # Optional if the env var is set
|
||||||
|
|
||||||
# Web search tool (requires Tavily API key)
|
# Web search tool (requires Tavily API key)
|
||||||
# - name: web_search
|
# - name: web_search
|
||||||
# group: web
|
# group: web
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ Community tools connect the agent to external services. They are configured in `
|
|||||||
|
|
||||||
### Web search
|
### Web search
|
||||||
|
|
||||||
<Tabs items={["DuckDuckGo (default)", "Tavily", "Exa", "InfoQuest", "Firecrawl"]}>
|
<Tabs items={["DuckDuckGo (default)", "Tavily", "Brave", "Exa", "InfoQuest", "Firecrawl"]}>
|
||||||
<Tabs.Tab>
|
<Tabs.Tab>
|
||||||
```yaml
|
```yaml
|
||||||
tools:
|
tools:
|
||||||
@@ -134,6 +134,17 @@ Install: `cd backend && uv add 'deerflow-harness[tavily]'`
|
|||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab>
|
<Tabs.Tab>
|
||||||
```yaml
|
```yaml
|
||||||
|
tools:
|
||||||
|
- use: deerflow.community.brave.tools:web_search_tool
|
||||||
|
api_key: $BRAVE_SEARCH_API_KEY
|
||||||
|
```
|
||||||
|
Results from Brave's independent index via the official [Brave Search API](https://brave.com/search/api/). Requires an API key. `max_results` is capped at 20.
|
||||||
|
|
||||||
|
No extra dependency required.
|
||||||
|
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab>
|
||||||
|
```yaml
|
||||||
tools:
|
tools:
|
||||||
- use: deerflow.community.exa.tools:web_search_tool
|
- use: deerflow.community.exa.tools:web_search_tool
|
||||||
api_key: $EXA_API_KEY
|
api_key: $EXA_API_KEY
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ tools:
|
|||||||
|
|
||||||
### 网络搜索
|
### 网络搜索
|
||||||
|
|
||||||
<Tabs items={["DuckDuckGo(默认)", "Tavily", "Exa", "Firecrawl"]}>
|
<Tabs items={["DuckDuckGo(默认)", "Tavily", "Brave", "Exa", "InfoQuest", "Firecrawl"]}>
|
||||||
<Tabs.Tab>
|
<Tabs.Tab>
|
||||||
```yaml
|
```yaml
|
||||||
tools:
|
tools:
|
||||||
@@ -131,6 +131,17 @@ tools:
|
|||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab>
|
<Tabs.Tab>
|
||||||
```yaml
|
```yaml
|
||||||
|
tools:
|
||||||
|
- use: deerflow.community.brave.tools:web_search_tool
|
||||||
|
api_key: $BRAVE_SEARCH_API_KEY
|
||||||
|
```
|
||||||
|
通过官方 [Brave Search API](https://brave.com/search/api/) 返回 Brave 独立索引的结果。需要 API Key,`max_results` 上限为 20。
|
||||||
|
|
||||||
|
无需额外依赖。
|
||||||
|
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab>
|
||||||
|
```yaml
|
||||||
tools:
|
tools:
|
||||||
- use: deerflow.community.exa.tools:web_search_tool
|
- use: deerflow.community.exa.tools:web_search_tool
|
||||||
api_key: $EXA_API_KEY
|
api_key: $EXA_API_KEY
|
||||||
@@ -142,6 +153,14 @@ tools:
|
|||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab>
|
<Tabs.Tab>
|
||||||
```yaml
|
```yaml
|
||||||
|
tools:
|
||||||
|
- use: deerflow.community.infoquest.tools:web_search_tool
|
||||||
|
api_key: $INFOQUEST_API_KEY
|
||||||
|
```
|
||||||
|
InfoQuest 搜索集成。
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab>
|
||||||
|
```yaml
|
||||||
tools:
|
tools:
|
||||||
- use: deerflow.community.firecrawl.tools:web_search_tool
|
- use: deerflow.community.firecrawl.tools:web_search_tool
|
||||||
api_key: $FIRECRAWL_API_KEY
|
api_key: $FIRECRAWL_API_KEY
|
||||||
|
|||||||
+16
-4
@@ -457,8 +457,9 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
|
|||||||
|
|
||||||
data = _load_yaml_file(config_path)
|
data = _load_yaml_file(config_path)
|
||||||
|
|
||||||
tool_uses = [t.get("use", "") for t in data.get("tools", []) if t.get("name") == tool_name]
|
tool_entries = [t for t in data.get("tools", []) if t.get("name") == tool_name]
|
||||||
if not tool_uses:
|
tool_uses = [t.get("use", "") for t in tool_entries]
|
||||||
|
if not tool_entries:
|
||||||
return CheckResult(
|
return CheckResult(
|
||||||
label,
|
label,
|
||||||
"warn",
|
"warn",
|
||||||
@@ -476,6 +477,7 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
|
|||||||
"infoquest": "INFOQUEST_API_KEY",
|
"infoquest": "INFOQUEST_API_KEY",
|
||||||
"exa": "EXA_API_KEY",
|
"exa": "EXA_API_KEY",
|
||||||
"firecrawl": "FIRECRAWL_API_KEY",
|
"firecrawl": "FIRECRAWL_API_KEY",
|
||||||
|
"brave": "BRAVE_SEARCH_API_KEY",
|
||||||
},
|
},
|
||||||
"web_fetch": {
|
"web_fetch": {
|
||||||
"infoquest": "INFOQUEST_API_KEY",
|
"infoquest": "INFOQUEST_API_KEY",
|
||||||
@@ -484,14 +486,24 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for use in tool_uses:
|
for tool_entry in tool_entries:
|
||||||
|
use = tool_entry.get("use", "")
|
||||||
for provider, detail in free_providers.get(tool_name, {}).items():
|
for provider, detail in free_providers.get(tool_name, {}).items():
|
||||||
if provider in use:
|
if provider in use:
|
||||||
return CheckResult(label, "ok", detail)
|
return CheckResult(label, "ok", detail)
|
||||||
|
|
||||||
for use in tool_uses:
|
for tool_entry in tool_entries:
|
||||||
|
use = tool_entry.get("use", "")
|
||||||
for provider, var in key_providers.get(tool_name, {}).items():
|
for provider, var in key_providers.get(tool_name, {}).items():
|
||||||
if provider in use:
|
if provider in use:
|
||||||
|
configured_key = tool_entry.get("api_key")
|
||||||
|
if isinstance(configured_key, str) and configured_key.strip():
|
||||||
|
if configured_key.startswith("$"):
|
||||||
|
ref_var = configured_key[1:]
|
||||||
|
if os.environ.get(ref_var):
|
||||||
|
return CheckResult(label, "ok", f"{provider} ({ref_var} set via api_key)")
|
||||||
|
else:
|
||||||
|
return CheckResult(label, "ok", f"{provider} (api_key configured)")
|
||||||
val = os.environ.get(var)
|
val = os.environ.get(var)
|
||||||
if val:
|
if val:
|
||||||
return CheckResult(label, "ok", f"{provider} ({var} set)")
|
return CheckResult(label, "ok", f"{provider} ({var} set)")
|
||||||
|
|||||||
@@ -510,6 +510,14 @@ SEARCH_PROVIDERS: list[SearchProvider] = [
|
|||||||
env_var="FIRECRAWL_API_KEY",
|
env_var="FIRECRAWL_API_KEY",
|
||||||
extra_config={"max_results": 5},
|
extra_config={"max_results": 5},
|
||||||
),
|
),
|
||||||
|
SearchProvider(
|
||||||
|
name="brave",
|
||||||
|
display_name="Brave Search",
|
||||||
|
description="Independent index, official API, API key required",
|
||||||
|
use="deerflow.community.brave.tools:web_search_tool",
|
||||||
|
env_var="BRAVE_SEARCH_API_KEY",
|
||||||
|
extra_config={"max_results": 5},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
WEB_FETCH_PROVIDERS: list[WebProvider] = [
|
WEB_FETCH_PROVIDERS: list[WebProvider] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user