diff --git a/README.md b/README.md index d7c5d391e..bb65e2d57 100644 --- a/README.md +++ b/README.md @@ -1075,6 +1075,11 @@ Tools follow the same philosophy. DeerFlow comes with a core toolset — web sea When using Tavily for `web_fetch`, extracted pages without a title use their URL as the heading; their content remains available to the agent. +Tavily search and fetch each read `api_key` from their own tool entry in +`config.yaml`, falling back to `TAVILY_API_KEY` when omitted. Fetch does not +reuse the search entry's key, so search can use a different provider. If you +previously configured a shared Tavily key only under `web_search`, also set it +under `web_fetch` or use `TAVILY_API_KEY` for both. Advanced deployments can enable pluggable authorization with `authorization.enabled` in `config.yaml`. A configured `AuthorizationProvider` filters denied tools before they reach the model or deferred-tool catalog, then the same provider is checked again before every business-tool execution through the existing guardrail middleware. Gateway `threads:*` and `runs:*` route permissions are derived from the same provider, while existing owner checks and admin-only management gates remain in force. Every HTTP route that starts or enables a future Agent run requires `runs:create`: this includes the stateless `POST /api/runs/stream` and `POST /api/runs/wait` endpoints plus scheduled-task create, update, resume, and manual-trigger mutations. Scheduled-task mutations retain their existing `threads:write` requirement, and the stateless routes separately enforce ownership when the optional thread ID is supplied in the request body. A generated `tool_search` may bypass the second tool check only when it fronts the current build's already-filtered deferred catalog. Model access follows the same provider: the Gateway `models` list is filtered per principal, `model:use` is enforced on model detail requests and again when the runtime resolves the agent's model, and a denied default model falls back to the first remaining candidate that also passes `model:use`. The built-in RBAC provider supports per-role `tools`, `routes`, `models`, `skills`, and `sandbox` allow/deny policies and validates that `default_role` names a configured role; authorization is disabled by default. See `config.example.yaml` and the [authorization RFC](docs/plans/2026-07-10-pluggable-authorization-rfc.md). diff --git a/backend/packages/harness/deerflow/community/tavily/AGENTS.md b/backend/packages/harness/deerflow/community/tavily/AGENTS.md new file mode 100644 index 000000000..c346cc7c7 --- /dev/null +++ b/backend/packages/harness/deerflow/community/tavily/AGENTS.md @@ -0,0 +1,6 @@ +# Tavily tools + +The client helper selects credentials by tool name: search defaults to +`web_search`, fetch passes `web_fetch`, and an omitted key uses the SDK's +`TAVILY_API_KEY` fallback. Keep credential regressions in `backend/tests/test_tavily_tools.py` +on the real helper and SDK constructor, mocking only search/extract calls. diff --git a/backend/packages/harness/deerflow/community/tavily/tools.py b/backend/packages/harness/deerflow/community/tavily/tools.py index 11770db29..4b40a2801 100644 --- a/backend/packages/harness/deerflow/community/tavily/tools.py +++ b/backend/packages/harness/deerflow/community/tavily/tools.py @@ -7,8 +7,8 @@ from deerflow.community.search_time_range import SearchTimeRange from deerflow.config import get_app_config -def _get_tavily_client() -> TavilyClient: - config = get_app_config().get_tool_config("web_search") +def _get_tavily_client(tool_name: str = "web_search") -> TavilyClient: + config = get_app_config().get_tool_config(tool_name) api_key = None if config is not None and "api_key" in config.model_extra: api_key = config.model_extra.get("api_key") @@ -56,7 +56,7 @@ def web_fetch_tool(url: str) -> str: Args: url: The URL to fetch the contents of. """ - client = _get_tavily_client() + client = _get_tavily_client("web_fetch") res = client.extract([url]) if "failed_results" in res and len(res["failed_results"]) > 0: return f"Error: {res['failed_results'][0]['error']}" diff --git a/backend/tests/test_agent_guidance_check.py b/backend/tests/test_agent_guidance_check.py index 0bc9aa1c5..8dbc866a0 100644 --- a/backend/tests/test_agent_guidance_check.py +++ b/backend/tests/test_agent_guidance_check.py @@ -18,6 +18,7 @@ EXPECTED_GUIDANCE_PATHS = { "backend/packages/harness/deerflow/agents/AGENTS.md", "backend/packages/harness/deerflow/agents/middlewares/AGENTS.md", "backend/packages/harness/deerflow/agents/memory/AGENTS.md", + "backend/packages/harness/deerflow/community/tavily/AGENTS.md", "backend/packages/harness/deerflow/config/AGENTS.md", "backend/packages/harness/deerflow/extensions/AGENTS.md", "backend/packages/harness/deerflow/runtime/AGENTS.md", diff --git a/backend/tests/test_tavily_tools.py b/backend/tests/test_tavily_tools.py index 2b591e2e1..07de72bf7 100644 --- a/backend/tests/test_tavily_tools.py +++ b/backend/tests/test_tavily_tools.py @@ -4,8 +4,59 @@ import json from unittest.mock import MagicMock, patch import pytest +from tavily import TavilyClient from deerflow.community.tavily.tools import web_fetch_tool, web_search_tool +from deerflow.config.tool_config import ToolConfig + + +@pytest.mark.parametrize( + ("search_provider", "fetch_key", "expected_key"), + [ + ("serper", "fetch-key", "fetch-key"), + (None, "fetch-key", "fetch-key"), + ("tavily", "fetch-key", "fetch-key"), + ("serper", None, "env-key"), + ("tavily", None, "env-key"), + (None, None, "env-key"), + ], +) +def test_web_fetch_uses_own_credentials(monkeypatch, search_provider, fetch_key, expected_key) -> None: + monkeypatch.setenv("TAVILY_API_KEY", "env-key") + fetch_config = ToolConfig(name="web_fetch", group="web", use="deerflow.community.tavily.tools:web_fetch_tool", **({"api_key": fetch_key} if fetch_key else {})) + configs = {"web_fetch": fetch_config} + if search_provider: + configs["web_search"] = ToolConfig(name="web_search", group="web", use=f"deerflow.community.{search_provider}.tools:web_search_tool", api_key="search-key") + + with ( + patch("deerflow.community.tavily.tools.get_app_config") as mock_config, + patch.object(TavilyClient, "extract", autospec=True, return_value={"results": []}) as extract, + ): + mock_config.return_value.get_tool_config.side_effect = configs.get + web_fetch_tool.invoke({"url": "https://example.com/report"}) + + client, urls = extract.call_args.args + assert client.api_key == expected_key + assert urls == ["https://example.com/report"] + + +@pytest.mark.parametrize("search_key", ["search-key", None]) +def test_web_search_preserves_own_credentials(monkeypatch, search_key) -> None: + monkeypatch.setenv("TAVILY_API_KEY", "env-key") + configs = { + "web_search": ToolConfig(name="web_search", group="web", use="deerflow.community.tavily.tools:web_search_tool", api_key=search_key), + "web_fetch": ToolConfig(name="web_fetch", group="web", use="deerflow.community.tavily.tools:web_fetch_tool", api_key="fetch-key"), + } + with ( + patch("deerflow.community.tavily.tools.get_app_config") as mock_config, + patch.object(TavilyClient, "search", autospec=True, return_value={"results": []}) as search, + ): + mock_config.return_value.get_tool_config.side_effect = configs.get + web_search_tool.invoke({"query": "documentation"}) + + client, query = search.call_args.args + assert client.api_key == (search_key or "env-key") + assert query == "documentation" def _tavily_response() -> dict: