fix(tavily): use web_fetch credentials for extraction (#5496)

* fix(tavily): use fetch tool credentials for extraction

* test: register scoped Tavily agent guidance

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Weng Qiang 2026-09-17 09:13:40 +08:00 committed by GitHub
parent 53798b44cd
commit 7f68fa2881
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 66 additions and 3 deletions

View File

@ -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).

View File

@ -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.

View File

@ -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']}"

View File

@ -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",

View File

@ -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: