From 0aa80352a043f2339a2065001d8625860c44c426 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Mon, 4 May 2026 01:18:25 +0800 Subject: [PATCH 1/5] Prevent empty web search tool calls from crashing the agent loop Web search tools previously indexed query directly, so model-generated empty arguments raised KeyError and could trigger repeated tool-call failures. Validate query before building provider payloads and return a clear tool error for missing, null, or blank inputs. Constraint: Keep the fix scoped to built-in web search tools and their direct regression test. Rejected: Do not add a broader schema-aware argument normalizer in this PR. Directive: Treat missing, null, and blank web search query inputs as friendly tool errors. Confidence: high Scope-risk: low Reversibility: straightforward Tested: uv run pytest tests/unit/test_web_search_tools.py -q Tested: uv run ruff check astrbot/core/tools/web_search_tools.py tests/unit/test_web_search_tools.py Tested: git diff --check and git diff --cached --check Related: #7499 Co-authored-by: OmX --- astrbot/core/tools/web_search_tools.py | 32 +++++++++++++++++++++----- tests/unit/test_web_search_tools.py | 28 ++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index ebd13d0102..720ace566c 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -382,7 +382,7 @@ class TavilyWebSearchTool(FunctionTool[AstrAgentContext]): default_factory=lambda: { "type": "object", "properties": { - "query": {"type": "string", "description": "Required. Search query."}, + "query": {"type": "string", "description": "Required string: search query to execute."}, "max_results": { "type": "integer", "description": "Optional. The maximum number of results to return. Default is 7. Range is 5-20.", @@ -421,6 +421,10 @@ async def call(self, context, **kwargs) -> ToolExecResult: if not provider_settings.get("websearch_tavily_key", []): return "Error: Tavily API key is not configured in AstrBot." + query = str(kwargs.get("query") or "").strip() + if not query: + return "Error: 'query' parameter is required but was not provided." + search_depth = kwargs.get("search_depth", "basic") if search_depth not in ["basic", "advanced"]: search_depth = "basic" @@ -430,7 +434,7 @@ async def call(self, context, **kwargs) -> ToolExecResult: topic = "general" payload = { - "query": kwargs["query"], + "query": query, "max_results": kwargs.get("max_results", 7), "include_favicon": True, "search_depth": search_depth, @@ -546,8 +550,12 @@ async def call(self, context, **kwargs) -> ToolExecResult: if not provider_settings.get("websearch_bocha_key", []): return "Error: BoCha API key is not configured in AstrBot." + query = str(kwargs.get("query") or "").strip() + if not query: + return "Error: 'query' parameter is required but was not provided." + payload = { - "query": kwargs["query"], + "query": query, "count": kwargs.get("count", 10), "summary": bool(kwargs.get("summary", False)), } @@ -600,6 +608,10 @@ async def call(self, context, **kwargs) -> ToolExecResult: if not provider_settings.get("websearch_brave_key", []): return "Error: Brave API key is not configured in AstrBot." + query = str(kwargs.get("query") or "").strip() + if not query: + return "Error: 'query' parameter is required but was not provided." + count = int(kwargs.get("count", 10)) if count < 1: count = 1 @@ -607,7 +619,7 @@ async def call(self, context, **kwargs) -> ToolExecResult: count = 20 payload = { - "q": kwargs["query"], + "q": query, "count": count, "country": kwargs.get("country", "US"), "search_lang": kwargs.get("search_lang", "zh-hans"), @@ -661,8 +673,12 @@ async def call(self, context, **kwargs) -> ToolExecResult: if not provider_settings.get("websearch_firecrawl_key", []): return "Error: Firecrawl API key is not configured in AstrBot." + query = str(kwargs.get("query") or "").strip() + if not query: + return "Error: 'query' parameter is required but was not provided." + payload = { - "query": kwargs["query"], + "query": query, "limit": kwargs.get("limit", 5), "sources": ["web"], } @@ -775,6 +791,10 @@ async def call(self, context, **kwargs) -> ToolExecResult: if not provider_settings.get("websearch_baidu_app_builder_key", ""): return "Error: Baidu AI Search API key is not configured in AstrBot." + query = str(kwargs.get("query") or "").strip() + if not query: + return "Error: 'query' parameter is required but was not provided." + top_k = int(kwargs.get("top_k", 10)) if top_k < 1: top_k = 1 @@ -782,7 +802,7 @@ async def call(self, context, **kwargs) -> ToolExecResult: top_k = 50 payload = { - "messages": [{"role": "user", "content": str(kwargs["query"])[:72]}], + "messages": [{"role": "user", "content": str(query)[:72]}], "search_source": "baidu_search_v2", "resource_type_filter": [{"type": "web", "top_k": top_k}], } diff --git a/tests/unit/test_web_search_tools.py b/tests/unit/test_web_search_tools.py index c0ac3cf800..1571de00f3 100644 --- a/tests/unit/test_web_search_tools.py +++ b/tests/unit/test_web_search_tools.py @@ -371,6 +371,34 @@ def post(self, url, json, headers): return self.response +@pytest.mark.asyncio +@pytest.mark.parametrize( + "tool_cls,provider_setting,kwargs", + [ + (tools.TavilyWebSearchTool, "websearch_tavily_key", {}), + (tools.BochaWebSearchTool, "websearch_bocha_key", {"query": None}), + (tools.BraveWebSearchTool, "websearch_brave_key", {"query": " "}), + (tools.FirecrawlWebSearchTool, "websearch_firecrawl_key", {"query": ""}), + (tools.BaiduWebSearchTool, "websearch_baidu_app_builder_key", {}), + ], +) +async def test_search_tool_returns_friendly_error_when_query_missing( + tool_cls, provider_setting, kwargs +): + """Issue #7499: invalid query inputs must not crash search tools.""" + tool = tool_cls() + settings = {provider_setting: ["test-key"]} + if provider_setting == "websearch_baidu_app_builder_key": + settings = {provider_setting: "test-key"} + context = _context_with_provider_settings(settings) + + result = await tool.call(context, **kwargs) + + assert isinstance(result, str) + assert "Error:" in result + assert "query" in result.lower() + + def _context_with_provider_settings(provider_settings): config = {"provider_settings": provider_settings} agent_context = SimpleNamespace( From d91abb02a42771193c500e96a56fe2ab51f1c88b Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Mon, 4 May 2026 01:25:59 +0800 Subject: [PATCH 2/5] fix: format search query description in TavilyWebSearchTool --- astrbot/core/tools/web_search_tools.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index 720ace566c..ab53f8f8f1 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -382,7 +382,10 @@ class TavilyWebSearchTool(FunctionTool[AstrAgentContext]): default_factory=lambda: { "type": "object", "properties": { - "query": {"type": "string", "description": "Required string: search query to execute."}, + "query": { + "type": "string", + "description": "Required string: search query to execute.", + }, "max_results": { "type": "integer", "description": "Optional. The maximum number of results to return. Default is 7. Range is 5-20.", From 3d086871ea909bafb7d011e3cd690c76af3cc8e2 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Mon, 4 May 2026 01:30:18 +0800 Subject: [PATCH 3/5] fix: remove unnecessary space in query string for Baidu web search tool --- astrbot/core/tools/web_search_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index ab53f8f8f1..e148ae94bd 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -805,7 +805,7 @@ async def call(self, context, **kwargs) -> ToolExecResult: top_k = 50 payload = { - "messages": [{"role": "user", "content": str(query)[:72]}], + "messages": [{"role": "user", "content": query [:72]}], "search_source": "baidu_search_v2", "resource_type_filter": [{"type": "web", "top_k": top_k}], } From 1678a9c658307136cd2c1c9b9aa72ead5fde0bef Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Mon, 4 May 2026 01:33:39 +0800 Subject: [PATCH 4/5] format --- astrbot/core/tools/web_search_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index e148ae94bd..1b5b30646f 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -805,7 +805,7 @@ async def call(self, context, **kwargs) -> ToolExecResult: top_k = 50 payload = { - "messages": [{"role": "user", "content": query [:72]}], + "messages": [{"role": "user", "content": query[:72]}], "search_source": "baidu_search_v2", "resource_type_filter": [{"type": "web", "top_k": top_k}], } From ef1cb50f83e2a7b7df3492cfe90b42d0454952d8 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Mon, 4 May 2026 01:36:28 +0800 Subject: [PATCH 5/5] Unify web search query validation across providers The PR repeated the same query normalization block in every web search provider. Extract the shared behavior into a named helper so missing, null, and blank query inputs keep one consistent validation path and error response. Constraint: Keep the refactor local to astrbot/core/tools/web_search_tools.py. Rejected: Do not introduce a broader tool-argument normalization layer for this review comment. Directive: Reuse _validate_search_query for built-in web search providers before payload construction. Confidence: high Scope-risk: low Reversibility: straightforward Tested: uv run pytest tests/unit/test_web_search_tools.py -q Tested: uv run ruff check astrbot/core/tools/web_search_tools.py tests/unit/test_web_search_tools.py Tested: git diff --check Related: #7499 Co-authored-by: OmX --- astrbot/core/tools/web_search_tools.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index 1b5b30646f..e9485b8a41 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -117,6 +117,12 @@ def _get_runtime(context) -> tuple[dict, dict, str]: return cfg, provider_settings, event.unified_msg_origin +def _validate_search_query(kwargs: dict) -> str | None: + # Keep provider behavior aligned when the model omits or blanks the required query. + query = str(kwargs.get("query") or "").strip() + return query or None + + def _cache_favicon(url: str, favicon: str | None) -> None: if favicon: sp.temporary_cache["_ws_favicon"][url] = favicon @@ -424,7 +430,7 @@ async def call(self, context, **kwargs) -> ToolExecResult: if not provider_settings.get("websearch_tavily_key", []): return "Error: Tavily API key is not configured in AstrBot." - query = str(kwargs.get("query") or "").strip() + query = _validate_search_query(kwargs) if not query: return "Error: 'query' parameter is required but was not provided." @@ -553,7 +559,7 @@ async def call(self, context, **kwargs) -> ToolExecResult: if not provider_settings.get("websearch_bocha_key", []): return "Error: BoCha API key is not configured in AstrBot." - query = str(kwargs.get("query") or "").strip() + query = _validate_search_query(kwargs) if not query: return "Error: 'query' parameter is required but was not provided." @@ -611,7 +617,7 @@ async def call(self, context, **kwargs) -> ToolExecResult: if not provider_settings.get("websearch_brave_key", []): return "Error: Brave API key is not configured in AstrBot." - query = str(kwargs.get("query") or "").strip() + query = _validate_search_query(kwargs) if not query: return "Error: 'query' parameter is required but was not provided." @@ -676,7 +682,7 @@ async def call(self, context, **kwargs) -> ToolExecResult: if not provider_settings.get("websearch_firecrawl_key", []): return "Error: Firecrawl API key is not configured in AstrBot." - query = str(kwargs.get("query") or "").strip() + query = _validate_search_query(kwargs) if not query: return "Error: 'query' parameter is required but was not provided." @@ -794,7 +800,7 @@ async def call(self, context, **kwargs) -> ToolExecResult: if not provider_settings.get("websearch_baidu_app_builder_key", ""): return "Error: Baidu AI Search API key is not configured in AstrBot." - query = str(kwargs.get("query") or "").strip() + query = _validate_search_query(kwargs) if not query: return "Error: 'query' parameter is required but was not provided."