From 7eadb563397bbae48a1355992280ee8f028d3c11 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Tue, 18 Aug 2026 19:21:38 +0800 Subject: [PATCH 1/3] Enable domestic Doubao search as an agent tool Expose the Volcengine domestic global-search API through the existing typed search contract, with configurable defaults and Markdown image output for chat bubbles. Constraint: Implementation follows published API documentation because no live Doubao Search key is available. Rejected: Reuse an Ark or BytePlus endpoint | Doubao Search has separate domestic credentials and request semantics. Confidence: medium Scope-risk: moderate Directive: Do not treat global_search as an overseas endpoint or mix its key with Ark and BytePlus credentials. Tested: 68 backend search and builtin-tool tests; 108 frontend tests; frontend production build; scoped Ruff critical checks. Not-tested: Live provider request and real CDN image rendering without a Doubao Search API key. --- backend/app/services/agent_tools.py | 213 +++++++++++++++++- .../app/services/builtin_tool_definitions.py | 80 +++++++ .../test_agent_tools_typed_search_outcomes.py | 63 +++++- 3 files changed, 353 insertions(+), 3 deletions(-) diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index 9a0d0c1ee..9f08cb3a8 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -603,6 +603,7 @@ async def _get_scoped_agentbay_client( "tavily_search", "google_search", "bing_search", + "doubao_search", "search_experience", "read_experience", "propose_experience_draft", @@ -1543,7 +1544,12 @@ async def get_runtime_agent_tools_for_llm(agent_id: uuid.UUID) -> list[dict]: ready.append(tool) elif name == "import_mcp_server" and config.get("smithery_api_key"): ready.append(tool) - elif name in {"tavily_search", "google_search", "bing_search"} and ( + elif name in { + "tavily_search", + "google_search", + "bing_search", + "doubao_search", + } and ( config.get("api_key") ): ready.append(tool) @@ -4025,6 +4031,8 @@ async def execute_builtin_tool_outcome( return await _google_search_outcome(arguments, agent_id) if tool_name == "bing_search": return await _bing_search_outcome(arguments, agent_id) + if tool_name == "doubao_search": + return await _doubao_search_outcome(arguments, agent_id) if tool_name == "search_experience": from app.services.experience_retrieval import search_experience_outcome @@ -4277,6 +4285,8 @@ async def _execute_tool_direct( return await _google_search_tool(arguments, agent_id) elif tool_name == "bing_search": return await _bing_search_tool(arguments, agent_id) + elif tool_name == "doubao_search": + return await _doubao_search_tool(arguments, agent_id) elif tool_name == "send_feishu_message": return await _send_feishu_message(agent_id, arguments) elif tool_name == "query_directory": @@ -4570,6 +4580,8 @@ async def execute_tool( result = await _google_search_tool(arguments, agent_id) elif tool_name == "bing_search": result = await _bing_search_tool(arguments, agent_id) + elif tool_name == "doubao_search": + result = await _doubao_search_tool(arguments, agent_id) elif tool_name == "jina_read": result = await _jina_read(arguments, agent_id) elif tool_name == "read_webpage": @@ -6282,6 +6294,205 @@ async def _bing_search_tool( ) +async def _doubao_search_outcome( + arguments: dict, + agent_id: uuid.UUID | None = None, +) -> ToolExecutionOutcome: + """Search the public web through the Doubao Search global API.""" + query = arguments.get("query") + if not isinstance(query, str) or not query.strip(): + return _typed_failure( + "doubao_search requires query.", + "invalid_tool_arguments", + ) + query = query.strip() + config = await _get_tool_config(agent_id, "doubao_search") or {} + api_key = config.get("api_key", "") + if not isinstance(api_key, str): + return _typed_failure( + "Doubao Search API key configuration is invalid.", + "search_configuration_invalid", + ) + api_key = api_key.strip() + if not api_key: + return _typed_failure( + "Doubao Search credentials are not configured.", + "search_credentials_missing", + ) + try: + max_results = int( + arguments.get("max_results", config.get("max_results", 10)) + ) + max_snippet_length = int( + arguments.get( + "max_snippet_length", + config.get("max_snippet_length", 600), + ) + ) + max_images = int( + arguments.get("max_images", config.get("max_images", 0)) + ) + except (TypeError, ValueError): + return _typed_failure( + "doubao_search numeric arguments must be integers.", + "invalid_tool_arguments", + ) + if not 1 <= max_results <= 20: + return _typed_failure( + "doubao_search max_results must be between 1 and 20.", + "invalid_tool_arguments", + ) + if not 50 <= max_snippet_length <= 2000: + return _typed_failure( + "doubao_search max_snippet_length must be between 50 and 2000.", + "invalid_tool_arguments", + ) + if not 0 <= max_images <= 3: + return _typed_failure( + "doubao_search max_images must be between 0 and 3.", + "invalid_tool_arguments", + ) + + try: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + "https://open.feedcoopapi.com/search_api/global_search", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "query": query, + "doc_count": max_results, + "max_snippet_length": max_snippet_length, + "max_image_count_per_doc": max_images, + }, + ) + except httpx.TimeoutException: + return _typed_failure( + "Doubao Search timed out.", + "doubao_search_timeout", + retryable=True, + ) + except httpx.TransportError as exc: + return _typed_failure( + f"Doubao Search transport failed: {type(exc).__name__}.", + "doubao_search_transport_failed", + retryable=True, + ) + except Exception as exc: + return _typed_failure( + f"Doubao Search failed: {type(exc).__name__}.", + "doubao_search_failed", + ) + + if response.status_code != 200: + return _typed_failure( + f"Doubao Search returned HTTP {response.status_code}.", + "doubao_search_http_error", + retryable=_read_http_status_retryable(response.status_code), + ) + try: + data = response.json() + except Exception: + return _typed_failure( + "Doubao Search returned invalid JSON.", + "doubao_search_response_invalid", + retryable=True, + ) + if not isinstance(data, Mapping): + return _typed_failure( + "Doubao Search returned an invalid response.", + "doubao_search_response_invalid", + retryable=True, + ) + response_metadata = data.get("ResponseMetadata") + response_error = ( + response_metadata.get("Error") + if isinstance(response_metadata, Mapping) + else None + ) + if response_error: + return _typed_failure( + "Doubao Search rejected the request.", + "doubao_search_provider_error", + ) + result = data.get("Result") + documents = result.get("Documents") if isinstance(result, Mapping) else None + if not isinstance(documents, list): + return _typed_failure( + "Doubao Search returned an invalid result collection.", + "doubao_search_response_invalid", + retryable=True, + ) + documents = documents[:max_results] + if any(not isinstance(document, Mapping) for document in documents): + return _typed_failure( + "Doubao Search returned an invalid result entry.", + "doubao_search_response_invalid", + retryable=True, + ) + if not documents: + return _typed_success(f'No Doubao Search results found for "{query}".') + + formatted: list[str] = [] + for index, document in enumerate(documents, 1): + title = str(document.get("Title") or "Untitled") + url = str(document.get("Url") or "") + host_info = document.get("HostInfo") + document_info = document.get("DocumentInfo") + host = host_info.get("Hostname") if isinstance(host_info, Mapping) else "" + published = document_info.get("PublishTime") if isinstance(document_info, Mapping) else "" + token_count = document_info.get("ContentTokenCount") if isinstance(document_info, Mapping) else None + snippets = document.get("Snippet") + text_parts = [ + str(part.get("Text")).strip() + for part in snippets or [] + if isinstance(part, Mapping) and part.get("Type") == "text" and part.get("Text") + ] + image_urls = [ + str(image.get("ImageUrl")).strip() + for part in snippets or [] + if isinstance(part, Mapping) and part.get("Type") == "image" + for image in [part.get("Image")] + if isinstance(image, Mapping) and image.get("ImageUrl") + ][:max_images] + metadata = " | ".join( + str(value) + for value in ( + host, + published, + f"{token_count} tokens" if token_count else "", + ) + if value + ) + lines = [f"**{index}. {title}**"] + if metadata: + lines.append(f"Source: {metadata}") + lines.extend((url, "\n".join(text_parts))) + lines.extend( + f"![{title} image {image_index}]({image_url})" + for image_index, image_url in enumerate(image_urls, 1) + ) + formatted.append("\n".join(line for line in lines if line)) + return _typed_success( + f'Doubao Search results for "{query}" ({len(documents)} items):\n\n' + + "\n\n---\n\n".join(formatted) + ) + + +async def _doubao_search_tool( + arguments: dict, + agent_id: uuid.UUID | None = None, +) -> str: + """Legacy display adapter for typed Doubao Search.""" + outcome = await _doubao_search_outcome(arguments, agent_id) + return _legacy_tool_outcome_text( + outcome, + fallback="Doubao Search returned no summary.", + ) + + async def _send_channel_file_outcome( agent_id: uuid.UUID, ws: Path, diff --git a/backend/app/services/builtin_tool_definitions.py b/backend/app/services/builtin_tool_definitions.py index 826721928..ed89bf546 100644 --- a/backend/app/services/builtin_tool_definitions.py +++ b/backend/app/services/builtin_tool_definitions.py @@ -934,6 +934,85 @@ ] }, }, + { + "name": "doubao_search", + "display_name": "豆包搜索", + "description": ( + "使用火山引擎豆包搜索检索互联网,返回标题、网址、来源、发布时间和正文摘要。" + "需要豆包搜索 API Key。" + ), + "category": "search", + "icon": "🔎", + "is_default": False, + "parameters_schema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "搜索关键词或自然语言问题"}, + "max_results": { + "type": "integer", + "description": "返回结果数量(默认 10,最多 20)", + "default": 10, + "minimum": 1, + "maximum": 20, + }, + "max_snippet_length": { + "type": "integer", + "description": "每条结果的最大摘要长度(默认 600,最多 2000 字符)", + "default": 600, + "minimum": 50, + "maximum": 2000, + }, + "max_images": { + "type": "integer", + "description": "每条结果最多返回的图片数(默认 0,最多 3)", + "default": 0, + "minimum": 0, + "maximum": 3, + }, + }, + "required": ["query"], + }, + "config": { + "max_results": 10, + "max_snippet_length": 600, + "max_images": 0, + }, + "config_schema": { + "fields": [ + { + "key": "api_key", + "label": "豆包搜索 API Key", + "type": "password", + "default": "", + "placeholder": "从火山引擎豆包搜索控制台获取", + }, + { + "key": "max_results", + "label": "默认结果数量", + "type": "number", + "default": 10, + "min": 1, + "max": 20, + }, + { + "key": "max_snippet_length", + "label": "默认摘要长度", + "type": "number", + "default": 600, + "min": 50, + "max": 2000, + }, + { + "key": "max_images", + "label": "默认图片数量", + "type": "number", + "default": 0, + "min": 0, + "max": 3, + }, + ] + }, + }, # Plaza social tools (plaza_get_new_posts / plaza_create_post / plaza_add_comment) # were removed in the Plaza → experience library改造 (P0-1: no AI auto-posting). # Experience library — AI consumption side (hybrid pull, read-only). @@ -3846,6 +3925,7 @@ "tavily_search", "google_search", "bing_search", + "doubao_search", "jina_read", "read_webpage", "search_experience", diff --git a/backend/tests/test_agent_tools_typed_search_outcomes.py b/backend/tests/test_agent_tools_typed_search_outcomes.py index 3a0efd136..82e8ce52a 100644 --- a/backend/tests/test_agent_tools_typed_search_outcomes.py +++ b/backend/tests/test_agent_tools_typed_search_outcomes.py @@ -24,6 +24,7 @@ "tavily_search", "google_search", "bing_search", + "doubao_search", } @@ -82,10 +83,32 @@ def test_search_provider_readiness_matches_real_credential_requirements() -> Non assert builtin_readiness("web_search") == "local" assert builtin_readiness("jina_search") == "local" assert builtin_readiness("jina_read") == "local" - for name in {"exa_search", "tavily_search", "google_search", "bing_search"}: + for name in { + "exa_search", + "tavily_search", + "google_search", + "bing_search", + "doubao_search", + }: assert builtin_readiness(name) == "configured_credentials" +def test_doubao_search_optional_arguments_publish_backend_defaults() -> None: + definition = builtin_model_definition("doubao_search") + parameters = definition["function"]["parameters"] + + assert parameters["required"] == ["query"] + assert parameters["properties"]["max_results"] == { + "type": "integer", + "description": "返回结果数量(默认 10,最多 20)", + "default": 10, + "minimum": 1, + "maximum": 20, + } + assert parameters["properties"]["max_snippet_length"]["default"] == 600 + assert parameters["properties"]["max_images"]["default"] == 0 + + @pytest.mark.asyncio async def test_search_resolver_uses_only_local_configuration(monkeypatch) -> None: tools = [builtin_model_definition(name) for name in sorted(TYPED_SEARCH_TOOLS)] @@ -236,6 +259,34 @@ async def test_search_tools_return_native_typed_validation_failures( }, "", ), + ( + "doubao_search", + { + "Result": { + "Documents": [ + { + "Title": "Doubao result", + "Url": "https://example.test/doubao", + "HostInfo": {"Hostname": "example.test"}, + "DocumentInfo": { + "PublishTime": "2026-08-18T10:00:00+08:00", + "ContentTokenCount": 42, + }, + "Snippet": [ + {"Type": "text", "Text": "Doubao content"}, + { + "Type": "image", + "Image": { + "ImageUrl": "https://example.test/image.jpg" + }, + }, + ], + } + ] + } + }, + "", + ), ], ) async def test_search_tools_use_structured_success_facts( @@ -251,6 +302,7 @@ async def config(_agent_id, name): "tavily_search": {"api_key": "tavily-key"}, "google_search": {"api_key": "google-key:cx", "language": "en"}, "bing_search": {"api_key": "bing-key", "language": "en-US"}, + "doubao_search": {"api_key": "doubao-key"}, } return configs.get(name, {}) @@ -266,7 +318,10 @@ async def no_jina_key(): arguments = ( {"url": "https://example.test/page"} if tool_name == "jina_read" - else {"query": "structured fact"} + else { + "query": "structured fact", + **({"max_images": 1} if tool_name == "doubao_search" else {}), + } ) outcome = await agent_tools.execute_builtin_tool_outcome( @@ -278,6 +333,10 @@ async def no_jina_key(): assert outcome.status == "succeeded" assert outcome.error_code is None + if tool_name == "doubao_search": + assert "![Doubao result image 1](https://example.test/image.jpg)" in ( + outcome.result_summary or "" + ) @pytest.mark.asyncio From 9bf01c5aeda608a7255b94723c350eb0b7346ec5 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Tue, 18 Aug 2026 19:22:00 +0800 Subject: [PATCH 2/3] Set honest expectations for unverified Doubao search Mark the tool and credential control as a test feature in the schema-driven frontend configuration until a real provider key is available for end-to-end validation. Constraint: No live Doubao Search API key is available. Confidence: high Scope-risk: narrow Directive: Remove the test label only after a real authenticated query and image response are validated in the chat UI. Tested: 68 backend search and builtin-tool tests; scoped Ruff critical checks. Not-tested: Visual browser inspection of the rendered configuration card. --- backend/app/services/builtin_tool_definitions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/app/services/builtin_tool_definitions.py b/backend/app/services/builtin_tool_definitions.py index ed89bf546..32b5dd30c 100644 --- a/backend/app/services/builtin_tool_definitions.py +++ b/backend/app/services/builtin_tool_definitions.py @@ -936,10 +936,10 @@ }, { "name": "doubao_search", - "display_name": "豆包搜索", + "display_name": "豆包搜索(测试)", "description": ( - "使用火山引擎豆包搜索检索互联网,返回标题、网址、来源、发布时间和正文摘要。" - "需要豆包搜索 API Key。" + "测试功能:按火山引擎国内豆包搜索文档接入,尚未使用真实 API Key 完成联调。" + "可返回标题、网址、来源、发布时间、正文摘要和图片。" ), "category": "search", "icon": "🔎", @@ -981,7 +981,7 @@ "fields": [ { "key": "api_key", - "label": "豆包搜索 API Key", + "label": "豆包搜索 API Key(测试功能)", "type": "password", "default": "", "placeholder": "从火山引擎豆包搜索控制台获取", From 1c4876396ab25267addee83b36289d5a1db33df1 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Tue, 18 Aug 2026 19:22:54 +0800 Subject: [PATCH 3/3] Keep the Doubao warning clear for end users Replace implementation-detail wording with a direct notice that the test feature may fail. Confidence: high Scope-risk: narrow Tested: 68 backend search and builtin-tool tests. --- backend/app/services/builtin_tool_definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/services/builtin_tool_definitions.py b/backend/app/services/builtin_tool_definitions.py index 32b5dd30c..1e28b78bb 100644 --- a/backend/app/services/builtin_tool_definitions.py +++ b/backend/app/services/builtin_tool_definitions.py @@ -938,7 +938,7 @@ "name": "doubao_search", "display_name": "豆包搜索(测试)", "description": ( - "测试功能:按火山引擎国内豆包搜索文档接入,尚未使用真实 API Key 完成联调。" + "该功能仍在测试阶段,服务可能不稳定;如遇失败,请稍后重试。" "可返回标题、网址、来源、发布时间、正文摘要和图片。" ), "category": "search",