feat(search): add Tencent Cloud WSA provider (#5057)

* feat(search): add Tencent Cloud WSA provider

* docs: restore README to upstream

* docs: remove README changes from WSA provider PR

* fix(doctor): validate Tencent WSA API key
This commit is contained in:
yong 2026-08-27 18:12:05 +08:00 committed by GitHub
parent 9600414002
commit 846c716523
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 572 additions and 2 deletions

View File

@ -367,7 +367,7 @@ tools:
```
**Built-in Tools**:
- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute)
- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Exa, InfoQuest, Tencent Cloud WSA, Firecrawl, fastCRW, GroundRoute)
- `web_fetch` - Fetch web pages (Jina AI, Crawl4AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute, Browserless)
- `web_capture` - Capture rendered webpage screenshots as artifacts (Browserless)
- `image_search` - Search for reference images (DuckDuckGo, InfoQuest, Serper, Brave)

View File

@ -0,0 +1 @@
"""Tencent Cloud Web Search API community provider."""

View File

@ -0,0 +1,260 @@
"""Web search tool powered by Tencent Cloud Web Search API (WSA)."""
import json
import logging
import os
from collections.abc import Mapping
from typing import Any
import httpx
from langchain.tools import tool
from deerflow.config import get_app_config
logger = logging.getLogger(__name__)
_SEARCH_ENDPOINT = "https://api.wsa.cloud.tencent.com/SearchPro"
_API_KEY_ENV = "TENCENTCLOUD_WSA_APIKEY"
_DEFAULT_MAX_RESULTS = 5
_DEFAULT_API_RESULT_COUNT = 10
_MAX_RESULTS = 50
_REQUEST_TIMEOUT_S = 30.0
_api_key_warned: set[str] = set()
def _get_tool_extras(tool_name: str) -> Mapping[str, Any]:
config = get_app_config().get_tool_config(tool_name)
if config is None or config.model_extra is None:
return {}
return config.model_extra
def _get_api_key(tool_name: str = "web_search", *, extras: Mapping[str, Any] | None = None) -> str | None:
api_key = (extras if extras is not None else _get_tool_extras(tool_name)).get("api_key")
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
env_key = os.getenv(_API_KEY_ENV)
if isinstance(env_key, str) and env_key.strip():
return env_key.strip()
return None
def _coerce_max_results(value: object, *, default: int = _DEFAULT_MAX_RESULTS) -> int:
if isinstance(value, int) and not isinstance(value, bool):
result = value
elif isinstance(value, str) and value.strip().isdigit():
result = int(value.strip())
else:
logger.warning("Invalid Tencent Cloud WSA max_results=%r; using default %s", value, default)
return default
if result <= 0:
logger.warning("Invalid Tencent Cloud WSA max_results=%r; using default %s", value, default)
return default
if result > _MAX_RESULTS:
logger.warning("Tencent Cloud WSA max_results=%s exceeds maximum %s; clamping", result, _MAX_RESULTS)
return _MAX_RESULTS
return result
def _get_mode(*, extras: Mapping[str, Any] | None = None) -> int | None:
"""Return an explicitly configured WSA result mode, if valid.
Tencent Cloud defaults to natural web results when ``Mode`` is omitted. Keep
that default so the provider does not request VR or mixed results implicitly.
"""
extras = extras if extras is not None else _get_tool_extras("web_search")
if "mode" not in extras:
return None
mode = extras["mode"]
if not isinstance(mode, int) or isinstance(mode, bool):
logger.warning("Invalid Tencent Cloud WSA mode=%r; omitting Mode", extras["mode"])
return None
if mode not in {0, 1, 2}:
logger.warning("Tencent Cloud WSA mode=%r is outside 0, 1, 2; omitting Mode", mode)
return None
return mode
def _request_count(max_results: int) -> int | None:
"""Return Tencent Cloud's supported Cnt value when one is needed.
The API's default response size is 10. Cnt is available only on Tencent
Cloud plans that support it, so omit it for requests that fit in the
default response and request the smallest supported batch otherwise.
"""
if max_results <= _DEFAULT_API_RESULT_COUNT:
return None
return ((max_results + _DEFAULT_API_RESULT_COUNT - 1) // _DEFAULT_API_RESULT_COUNT) * _DEFAULT_API_RESULT_COUNT
def _error(message: str, query: str, *, request_id: str | None = None) -> str:
result: dict[str, str] = {"error": message, "query": query}
if request_id:
result["request_id"] = request_id
return json.dumps(result, ensure_ascii=False)
def _request_id(response: Mapping[str, Any]) -> str | None:
value = response.get("RequestId")
return value if isinstance(value, str) and value else None
def _missing_key_error(query: str, tool_name: str) -> str:
if tool_name not in _api_key_warned:
_api_key_warned.add(tool_name)
logger.warning(
"Tencent Cloud WSA API key is not set for '%s'. Set %s in the environment or provide api_key in config.yaml.",
tool_name,
_API_KEY_ENV,
)
return _error(f"{_API_KEY_ENV} is not configured", query)
def _search(api_key: str, payload: dict[str, object], query: str) -> tuple[dict[str, Any] | None, str | None]:
try:
with httpx.Client(timeout=_REQUEST_TIMEOUT_S) as client:
response = client.post(
_SEARCH_ENDPOINT,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json; charset=utf-8",
},
json=payload,
)
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as exc:
logger.error("Tencent Cloud WSA API returned HTTP %s", exc.response.status_code)
return None, _error(f"Tencent Cloud WSA API error: HTTP {exc.response.status_code}", query)
except httpx.HTTPError as exc:
logger.error("Tencent Cloud WSA request failed: %s", exc)
return None, _error("Tencent Cloud WSA request failed", query)
except (TypeError, ValueError):
logger.error("Tencent Cloud WSA returned an invalid JSON response")
return None, _error("Tencent Cloud WSA returned an invalid JSON response", query)
if not isinstance(data, dict):
logger.error("Tencent Cloud WSA returned an unexpected payload type: %s", type(data).__name__)
return None, _error("Tencent Cloud WSA returned an unexpected response format", query)
return data, None
def _get_response(data: dict[str, Any], query: str) -> tuple[dict[str, Any] | None, str | None, str | None]:
response = data.get("Response")
if not isinstance(response, dict):
logger.error("Tencent Cloud WSA response did not contain a Response object")
return None, None, _error("Tencent Cloud WSA returned an unexpected response format", query)
request_id = _request_id(response)
api_error = response.get("Error")
if isinstance(api_error, dict):
code = api_error.get("Code")
code = code if isinstance(code, str) and code else "UnknownError"
logger.error("Tencent Cloud WSA API returned error code %s (request_id=%s)", code, request_id)
return None, request_id, _error(f"Tencent Cloud WSA API error: {code}", query, request_id=request_id)
return response, request_id, None
def _parse_results(response: dict[str, Any], *, max_results: int) -> list[dict[str, Any]] | None:
pages = response.get("Pages")
if pages is None:
return []
if not isinstance(pages, list):
logger.error("Tencent Cloud WSA returned non-list Pages value")
return None
results: list[dict[str, Any]] = []
for page in pages:
if isinstance(page, str):
try:
page_data = json.loads(page)
except json.JSONDecodeError:
logger.warning("Skipping malformed Tencent Cloud WSA page entry")
continue
elif isinstance(page, dict):
# The documented schema is a JSON-string array. Accepting an object
# too keeps the provider forward-compatible with a harmless API
# representation change.
page_data = page
else:
logger.warning("Skipping Tencent Cloud WSA page entry of type %s", type(page).__name__)
continue
if not isinstance(page_data, dict):
logger.warning("Skipping Tencent Cloud WSA page entry that is not an object")
continue
title = page_data.get("title")
url = page_data.get("url")
content = page_data.get("content") or page_data.get("passage") or ""
result = {
"title": title if isinstance(title, str) else "",
"url": url if isinstance(url, str) else "",
"snippet": content if isinstance(content, str) else "",
}
for field in ("date", "site", "score"):
value = page_data.get(field)
if isinstance(value, (str, int, float)) and not isinstance(value, bool):
result[field] = value
results.append(result)
if len(results) >= max_results:
break
return results
@tool("web_search", parse_docstring=True)
def web_search_tool(query: str, max_results: int = _DEFAULT_MAX_RESULTS) -> str:
"""Search the web using Tencent Cloud Web Search API.
Args:
query: Search keywords describing what you want to find.
max_results: Maximum number of search results to return. Default is 5.
"""
extras = _get_tool_extras("web_search")
if "max_results" in extras:
max_results = extras["max_results"]
max_results = _coerce_max_results(max_results)
query = query.strip()
if not query:
return _error("Search query must not be empty", query)
api_key = _get_api_key("web_search", extras=extras)
if not api_key:
return _missing_key_error(query, "web_search")
payload: dict[str, object] = {"Query": query}
mode = _get_mode(extras=extras)
if mode is not None:
payload["Mode"] = mode
request_count = _request_count(max_results)
if request_count is not None:
payload["Cnt"] = request_count
data, error_json = _search(api_key, payload, query)
if error_json is not None:
return error_json
assert data is not None
response, request_id, error_json = _get_response(data, query)
if error_json is not None:
return error_json
assert response is not None
results = _parse_results(response, max_results=max_results)
if results is None:
return _error("Tencent Cloud WSA returned an unexpected response format", query, request_id=request_id)
if not results:
return _error("No results found", query, request_id=request_id)
output: dict[str, object] = {
"query": query,
"total_results": len(results),
"results": results,
}
if request_id:
output["request_id"] = request_id
return json.dumps(output, indent=2, ensure_ascii=False)

View File

@ -28,7 +28,7 @@ Durable MCP task-management tools are added only while the process-local task su
- `browser_automation/` - Agentic browser control (stateful `navigate → observe → click/type` loop) via Playwright, distinct from the read-only `web_fetch`/`web_capture` tools. Tools: `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_get_text`, `browser_back`, `browser_screenshot`, `browser_close` (config `group: browser`). A process-local `BrowserSessionManager` owns one private, loop-affine Playwright event-loop thread (same pattern as the BoxLite provider) so a per-thread browser session survives across turns regardless of the caller's loop (Gateway / TUI / test). Each action returns a fresh page snapshot whose interactive elements are addressed by a stable numeric `[ref]` index (stamped as `data-df-ref` during snapshot), so the model acts on what it just observed instead of holding stale handles or guessing selectors. URLs are SSRF-screened via the shared `validate_public_http_url` (opt-out `allow_private_addresses` only for intentional internal targets). CDP attachment cannot install the request guard on an existing Chrome context, so `cdp_url` fails closed unless the operator explicitly sets `allow_unguarded_cdp: true` for a trusted local browser. Browser REST/Live access also requires an exact non-NULL thread owner, rather than the general legacy shared-thread policy, because retained pages may contain authenticated state. Session admission is a hard `max_sessions` cap: pinned Live/operation sessions are never evicted, and a new thread is rejected when no unpinned session can be closed; one Live viewer owns a session at a time. Optional dependency: `cd backend && uv sync --extra browser && uv run playwright install chromium`; `scripts/detect_uv_extras.py` preserves the extra when `config.yaml` enables `browser_navigate`, and Gateway startup fails fast if configured browser control cannot import Playwright. Tests: `tests/test_browser_automation.py` (mocked tools + a real-Chromium integration test guarded by `importorskip`); `tests/manual_browser_live_check.py` is a manual DeepSeek-driven end-to-end check (not collected by pytest).
Live UI input dispatch is kept independent from JPEG capture: non-move actions start a rate-limited background refresh loop, so pointer, wheel, or keyboard input stays responsive while continuous gestures still produce frames throughout the interaction.
Additional providers also live here (`boxlite`, `brave`, `browserless`, `crawl4ai`, `ddg_search`, `e2b_sandbox`, `exa`, `fastcrw`, `groundroute`, `infoquest`, `searxng`, `serper`, `tenki`); see each subpackage for specifics. E2B bootstrap is required. If it fails, the provider kills and closes the unusable remote sandbox. New sandbox creation raises an error. Warm-pool reclaim and remote discovery discard the sandbox and continue acquisition. E2B mounts remain optional.
Additional providers also live here (`boxlite`, `brave`, `browserless`, `crawl4ai`, `ddg_search`, `e2b_sandbox`, `exa`, `fastcrw`, `groundroute`, `infoquest`, `searxng`, `serper`, `tencent_wsa`, `tenki`); see each subpackage for specifics. `tencent_wsa` uses Tencent Cloud Web Search's service API key endpoint (`TENCENTCLOUD_WSA_APIKEY`), not Tencent Cloud SecretId/SecretKey signing. Its `max_results` is capped at 50; requests above 10 use Tencent's optional `Cnt` parameter, which needs a Tencent Cloud plan that supports it. E2B bootstrap is required. If it fails, the provider kills and closes the unusable remote sandbox. New sandbox creation raises an error. Warm-pool reclaim and remote discovery discard the sandbox and continue acquisition. E2B mounts remain optional.
E2B output sync records remote file versions and actual host file metadata in a thread-local manifest. The manifest binds to the remote sandbox ID. A complete output listing removes entries for deleted files. This avoids repeat downloads when the host filesystem rounds modification times. A single release-time sync pass is bounded by aggregate ceilings (`_MAX_SYNC_TOTAL_BYTES`, `_MAX_SYNC_FILES`, `_SYNC_DEADLINE_SECONDS`) on top of the per-file `_MAX_DOWNLOAD_SIZE` cap, so a pathological outputs tree cannot make release download unboundedly; a truncated pass logs what it dropped and leaves the manifest un-pruned (only entries observed in that pass are reconciled), so files it never reached are retried on the next release rather than being forgotten.

View File

@ -356,6 +356,17 @@ class TestCheckWebSearch:
assert result.status == "warn"
assert "SERPER_API_KEY" in (result.fix or "")
def test_tencent_wsa_without_key_warns(self, tmp_path, monkeypatch):
monkeypatch.delenv("TENCENTCLOUD_WSA_APIKEY", raising=False)
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.tencent_wsa.tools:web_search_tool\n")
result = doctor.check_web_search(cfg)
assert result.status == "warn"
assert "tencent_wsa configured but TENCENTCLOUD_WSA_APIKEY not set" in result.detail
assert "TENCENTCLOUD_WSA_APIKEY" in (result.fix or "")
def test_no_search_tool_warns(self, tmp_path):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools: []\n")

View File

@ -0,0 +1,285 @@
"""Unit tests for the Tencent Cloud Web Search API community provider."""
import json
from unittest.mock import MagicMock, patch
import httpx
import pytest
@pytest.fixture(autouse=True)
def reset_api_key_warned():
import deerflow.community.tencent_wsa.tools as wsa
wsa._api_key_warned = set()
yield
wsa._api_key_warned = set()
def _tool_config(extras: dict | None) -> MagicMock:
config = MagicMock()
config.model_extra = extras
return config
def _response(pages: list[object] | None = None, **extra: object) -> dict:
response: dict[str, object] = {"RequestId": "request-123"}
if pages is not None:
response["Pages"] = pages
response.update(extra)
return {"Response": response}
def _mock_http_client(response: MagicMock):
client = MagicMock()
client.post.return_value = response
context_manager = MagicMock()
context_manager.__enter__.return_value = client
context_manager.__exit__.return_value = False
return client, context_manager
class TestTencentWsaApiKey:
def test_config_key_takes_precedence_over_environment(self, monkeypatch):
monkeypatch.setenv("TENCENTCLOUD_WSA_APIKEY", "environment-key")
with patch("deerflow.community.tencent_wsa.tools.get_app_config") as get_config:
get_config.return_value.get_tool_config.return_value = _tool_config({"api_key": "config-key"})
from deerflow.community.tencent_wsa.tools import _get_api_key
assert _get_api_key() == "config-key"
def test_environment_key_is_used_as_fallback(self, monkeypatch):
monkeypatch.setenv("TENCENTCLOUD_WSA_APIKEY", "environment-key")
with patch("deerflow.community.tencent_wsa.tools.get_app_config") as get_config:
get_config.return_value.get_tool_config.return_value = _tool_config({"api_key": " "})
from deerflow.community.tencent_wsa.tools import _get_api_key
assert _get_api_key() == "environment-key"
def test_missing_key_returns_a_structured_error(self, monkeypatch):
monkeypatch.delenv("TENCENTCLOUD_WSA_APIKEY", raising=False)
with patch("deerflow.community.tencent_wsa.tools.get_app_config") as get_config:
get_config.return_value.get_tool_config.return_value = _tool_config({})
from deerflow.community.tencent_wsa.tools import web_search_tool
result = json.loads(web_search_tool.run({"query": "腾讯云"}))
assert result == {
"error": "TENCENTCLOUD_WSA_APIKEY is not configured",
"query": "腾讯云",
}
class TestTencentWsaSearch:
def test_search_normalizes_documented_pages_and_honors_config(self):
page_one = json.dumps(
{
"title": "第一条",
"url": "https://example.com/one",
"passage": "摘要一",
"date": "2026-08-27",
"site": "示例站点",
"score": 0.9,
}
)
page_two = json.dumps(
{
"title": "第二条",
"url": "https://example.com/two",
"content": "动态摘要二",
}
)
http_response = MagicMock()
http_response.json.return_value = _response([page_one, page_two])
client, context_manager = _mock_http_client(http_response)
with (
patch("deerflow.community.tencent_wsa.tools.get_app_config") as get_config,
patch("deerflow.community.tencent_wsa.tools.httpx.Client", return_value=context_manager),
):
get_config.return_value.get_tool_config.return_value = _tool_config({"api_key": "test-key", "max_results": 1, "mode": 2})
from deerflow.community.tencent_wsa.tools import web_search_tool
result = json.loads(web_search_tool.run({"query": " 腾讯云搜索 ", "max_results": 99}))
client.post.assert_called_once_with(
"https://api.wsa.cloud.tencent.com/SearchPro",
headers={
"Authorization": "Bearer test-key",
"Content-Type": "application/json; charset=utf-8",
},
json={"Query": "腾讯云搜索", "Mode": 2},
)
assert get_config.call_count == 1
assert result == {
"query": "腾讯云搜索",
"total_results": 1,
"request_id": "request-123",
"results": [
{
"title": "第一条",
"url": "https://example.com/one",
"snippet": "摘要一",
"date": "2026-08-27",
"site": "示例站点",
"score": 0.9,
}
],
}
def test_search_omits_mode_when_not_configured(self):
http_response = MagicMock()
http_response.json.return_value = _response([json.dumps({"title": "结果", "passage": "摘要"})])
client, context_manager = _mock_http_client(http_response)
with (
patch("deerflow.community.tencent_wsa.tools.get_app_config") as get_config,
patch("deerflow.community.tencent_wsa.tools.httpx.Client", return_value=context_manager),
):
get_config.return_value.get_tool_config.return_value = _tool_config({"api_key": "test-key"})
from deerflow.community.tencent_wsa.tools import web_search_tool
web_search_tool.run({"query": "腾讯云"})
assert client.post.call_args.kwargs["json"] == {"Query": "腾讯云"}
def test_search_requests_supported_cnt_for_more_than_default_results(self):
pages = [json.dumps({"title": f"结果 {index}", "passage": "摘要"}) for index in range(20)]
http_response = MagicMock()
http_response.json.return_value = _response(pages)
client, context_manager = _mock_http_client(http_response)
with (
patch("deerflow.community.tencent_wsa.tools.get_app_config") as get_config,
patch("deerflow.community.tencent_wsa.tools.httpx.Client", return_value=context_manager),
):
get_config.return_value.get_tool_config.return_value = _tool_config({"api_key": "test-key"})
from deerflow.community.tencent_wsa.tools import web_search_tool
result = json.loads(web_search_tool.run({"query": "腾讯云", "max_results": 20}))
assert client.post.call_args.kwargs["json"] == {"Query": "腾讯云", "Cnt": 20}
assert result["total_results"] == 20
def test_empty_query_does_not_call_paid_api(self):
with (
patch("deerflow.community.tencent_wsa.tools.get_app_config") as get_config,
patch("deerflow.community.tencent_wsa.tools.httpx.Client") as client,
):
get_config.return_value.get_tool_config.return_value = _tool_config({"api_key": "test-key"})
from deerflow.community.tencent_wsa.tools import web_search_tool
result = json.loads(web_search_tool.run({"query": " "}))
assert result == {"error": "Search query must not be empty", "query": ""}
client.assert_not_called()
def test_search_skips_malformed_pages_without_losing_valid_results(self):
http_response = MagicMock()
http_response.json.return_value = _response(["not-json", 42, json.dumps({"title": "有效结果", "url": "https://example.com", "passage": "摘要"})])
_, context_manager = _mock_http_client(http_response)
with (
patch("deerflow.community.tencent_wsa.tools.get_app_config") as get_config,
patch("deerflow.community.tencent_wsa.tools.httpx.Client", return_value=context_manager),
):
get_config.return_value.get_tool_config.return_value = _tool_config({"api_key": "test-key"})
from deerflow.community.tencent_wsa.tools import web_search_tool
result = json.loads(web_search_tool.run({"query": "腾讯云"}))
assert result["total_results"] == 1
assert result["results"][0]["title"] == "有效结果"
def test_response_error_is_reported_even_with_http_200(self):
http_response = MagicMock()
http_response.json.return_value = _response(Error={"Code": "RequestLimitExceeded", "Message": "do not expose this"})
_, context_manager = _mock_http_client(http_response)
with (
patch("deerflow.community.tencent_wsa.tools.get_app_config") as get_config,
patch("deerflow.community.tencent_wsa.tools.httpx.Client", return_value=context_manager),
):
get_config.return_value.get_tool_config.return_value = _tool_config({"api_key": "test-key"})
from deerflow.community.tencent_wsa.tools import web_search_tool
result = json.loads(web_search_tool.run({"query": "腾讯云"}))
assert result == {
"error": "Tencent Cloud WSA API error: RequestLimitExceeded",
"query": "腾讯云",
"request_id": "request-123",
}
def test_http_error_does_not_expose_upstream_body(self):
response = MagicMock()
response.status_code = 503
response.text = "sensitive upstream diagnostic"
http_response = MagicMock()
http_response.raise_for_status.side_effect = httpx.HTTPStatusError("unavailable", request=MagicMock(), response=response)
client, context_manager = _mock_http_client(http_response)
with (
patch("deerflow.community.tencent_wsa.tools.get_app_config") as get_config,
patch("deerflow.community.tencent_wsa.tools.httpx.Client", return_value=context_manager),
):
get_config.return_value.get_tool_config.return_value = _tool_config({"api_key": "test-key"})
from deerflow.community.tencent_wsa.tools import web_search_tool
result = json.loads(web_search_tool.run({"query": "腾讯云"}))
assert result == {"error": "Tencent Cloud WSA API error: HTTP 503", "query": "腾讯云"}
assert "sensitive" not in json.dumps(result)
client.post.assert_called_once()
def test_non_list_pages_returns_unexpected_format_error(self):
http_response = MagicMock()
http_response.json.return_value = _response("not-a-list")
_, context_manager = _mock_http_client(http_response)
with (
patch("deerflow.community.tencent_wsa.tools.get_app_config") as get_config,
patch("deerflow.community.tencent_wsa.tools.httpx.Client", return_value=context_manager),
):
get_config.return_value.get_tool_config.return_value = _tool_config({"api_key": "test-key"})
from deerflow.community.tencent_wsa.tools import web_search_tool
result = json.loads(web_search_tool.run({"query": "腾讯云"}))
assert result == {
"error": "Tencent Cloud WSA returned an unexpected response format",
"query": "腾讯云",
"request_id": "request-123",
}
class TestTencentWsaConfiguration:
@pytest.mark.parametrize(
("value", "expected"),
[(3, 3), ("7", 7), (True, 5), (2.9, 5), (0, 5), (-1, 5), (500, 50), ("bad", 5)],
)
def test_coerce_max_results(self, value, expected):
from deerflow.community.tencent_wsa.tools import _coerce_max_results
assert _coerce_max_results(value) == expected
@pytest.mark.parametrize("value", (None, "2", True, 2.0, "bad", -1, 3))
def test_invalid_mode_is_omitted(self, value):
with patch("deerflow.community.tencent_wsa.tools.get_app_config") as get_config:
extras = {} if value is None else {"mode": value}
get_config.return_value.get_tool_config.return_value = _tool_config(extras)
from deerflow.community.tencent_wsa.tools import _get_mode
assert _get_mode() is None

View File

@ -754,6 +754,18 @@ tools:
# # Used to limit the scope of search results, only returns content within the specified time range. Set to -1 to disable time filtering
# search_time_range: 10
# Web search tool (uses Tencent Cloud Web Search API, requires a service API key)
# Create a service API key in the Tencent Cloud WSA console, then set
# TENCENTCLOUD_WSA_APIKEY in the Gateway environment. Do not use Tencent Cloud
# SecretId/SecretKey for this provider.
# - name: web_search
# group: web
# use: deerflow.community.tencent_wsa.tools:web_search_tool
# max_results: 5 # 1-50; values above 10 request Tencent's Cnt option
# # (requires a Tencent plan that supports Cnt)
# # mode: 0 # Optional: 0=web, 1=VR, 2=mixed
# # api_key: $TENCENTCLOUD_WSA_APIKEY # Optional explicit env-var reference
# Web search tool (uses Exa, requires EXA_API_KEY)
# - name: web_search
# group: web

View File

@ -496,6 +496,7 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
"fastcrw": "CRW_API_KEY",
"brave": "BRAVE_SEARCH_API_KEY",
"serper": "SERPER_API_KEY",
"tencent_wsa": "TENCENTCLOUD_WSA_APIKEY",
},
"web_fetch": {
"infoquest": "INFOQUEST_API_KEY",