From d30f08d10f553dc22aa2c316ff5e81e6687fde14 Mon Sep 17 00:00:00 2001 From: serply Date: Fri, 21 Aug 2026 21:53:19 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8Feature:=20Add=20Serply=20search?= =?UTF-8?q?=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SerplySearchTool as a new open-web search provider alongside tavily/exa/linkup, using Serply's REST search API (api.serply.io). --- backend/consts/tool_labels.py | 1 + sdk/nexent/core/tools/__init__.py | 2 + sdk/nexent/core/tools/serply_search_tool.py | 134 ++++++++++++++++++ sdk/nexent/core/utils/tools_common_message.py | 2 + .../sdk/core/tools/test_serply_search_tool.py | 104 ++++++++++++++ 5 files changed, 243 insertions(+) create mode 100644 sdk/nexent/core/tools/serply_search_tool.py create mode 100644 test/sdk/core/tools/test_serply_search_tool.py diff --git a/backend/consts/tool_labels.py b/backend/consts/tool_labels.py index c8b60447e..464428e68 100644 --- a/backend/consts/tool_labels.py +++ b/backend/consts/tool_labels.py @@ -29,6 +29,7 @@ } _category_search = { "tavily_search": ["search"], "exa_search": ["search"], "linkup_search": ["search"], + "serply_search": ["search"], "search_memory": ["search"], "knowledge_base_search": ["search"], } _category_kb = { diff --git a/sdk/nexent/core/tools/__init__.py b/sdk/nexent/core/tools/__init__.py index 25c0cd0a0..4c86d0891 100644 --- a/sdk/nexent/core/tools/__init__.py +++ b/sdk/nexent/core/tools/__init__.py @@ -11,6 +11,7 @@ from ..ext_components.aidp.aidp_search_tool import AidpSearchTool from .send_email_tool import SendEmailTool from .tavily_search_tool import TavilySearchTool +from .serply_search_tool import SerplySearchTool from .linkup_search_tool import LinkupSearchTool from .create_file_tool import CreateFileTool from .read_file_tool import ReadFileTool @@ -47,6 +48,7 @@ "SendEmailTool", "GetEmailTool", "TavilySearchTool", + "SerplySearchTool", "LinkupSearchTool", "CreateFileTool", "ReadFileTool", diff --git a/sdk/nexent/core/tools/serply_search_tool.py b/sdk/nexent/core/tools/serply_search_tool.py new file mode 100644 index 000000000..f1ef34c87 --- /dev/null +++ b/sdk/nexent/core/tools/serply_search_tool.py @@ -0,0 +1,134 @@ +import json +import logging + +import httpx +from pydantic import Field +from smolagents.tools import Tool + +from ..utils.observer import MessageObserver, ProcessType +from ..utils.tools_common_message import SearchResultTextMessage, ToolCategory, ToolSign + + +# Get logger instance +logger = logging.getLogger("serply_search_tool") + +SERPLY_SEARCH_ENDPOINT = "https://api.serply.io/v1/search/" + + +class SerplySearchTool(Tool): + name = "serply_search" + description = "Performs a internet search based on your query (think a Google search) then returns the top search results. " \ + "A tool for retrieving publicly available information, news, general knowledge, or non-proprietary data from the internet. " \ + "Use this for real-time open-domain updates, broad topics, or general knowledge queries" + + description_zh = "基于你的查询词进行互联网搜索,返回最相关的搜索结果。适用于获取公开信息、新闻、通用知识或互联网上的非专有数据。特别适合实时信息更新、广泛话题或通用知识查询。" + + inputs = { + "query": { + "type": "string", + "description": "The search query to perform.", + "description_zh": "要执行的搜索查询词" + } + } + + init_param_descriptions = { + "serply_api_key": { + "description": "Serply API key", + "description_zh": "Serply API 密钥" + }, + "max_results": { + "description": "Maximum number of search results", + "description_zh": "返回搜索结果的最大数量" + } + } + output_type = "string" + category = ToolCategory.SEARCH.value + tool_sign = ToolSign.SERPLY_SEARCH.value # Used to distinguish different index sources in summary + + def __init__(self, serply_api_key: str = Field(description="Serply API key"), + observer: MessageObserver = Field(description="Message observer", default=None, exclude=True), + max_results: int = Field(description="Maximum number of search results", default=3), + ): + + super().__init__() + + self.observer = observer + self.serply_api_key = serply_api_key + self.max_results = max_results + self.record_ops = 1 # Used to record sequence number + + def forward(self, query: str) -> str: + # Perform serply search + serply_search_result = self._search_serply(query) + if len(serply_search_result) == 0: + raise Exception( + 'No results found! Try a less restrictive/shorter query.') + + # Send tool running message + if self.observer: + # Tool running chunk is emitted by the SDK tool-call bridge in + # core_agent.py so it is consistent across direct and code_action + # invocations. We only emit the search card from inside the tool. + card_content = [{"icon": "search", "text": query}] + self.observer.add_message("", ProcessType.CARD, json.dumps( + card_content, ensure_ascii=False)) + + search_results_json = [] # Format search results into a unified structure + search_results_return = [] # Format for input to the large model + for index, single_result in enumerate(serply_search_result): + search_result_message = SearchResultTextMessage( + title=single_result.get("title", ""), + url=single_result.get("link", ""), + text=single_result.get("description", ""), + published_date="", + source_type="url", + filename="", + score="", + score_details={}, + cite_index=self.record_ops + index, + search_type=self.name, + tool_sign=self.tool_sign + ) + search_results_json.append(search_result_message.to_dict()) + search_results_return.append(search_result_message.to_model_dict()) + + self.record_ops += len(search_results_return) + + # Record detailed content of this search + if self.observer: + search_results_data = json.dumps( + search_results_json, ensure_ascii=False) + self.observer.add_message( + "", ProcessType.SEARCH_CONTENT, search_results_data) + return json.dumps(search_results_return, ensure_ascii=False) + + def _search_serply(self, query: str) -> list: + """ + Perform a search on the Serply API and return the organic results. + :param query: Search query to perform. + """ + params = {"q": query, "num": self.max_results} + headers = { + "X-Api-Key": self.serply_api_key, + "Accept": "application/json", + # Serply is fronted by Cloudflare, which rejects the default + # httpx User-Agent with a 1010 error, so send an explicit one. + "User-Agent": "nexent-serply-search-tool", + } + + try: + response = httpx.get( + SERPLY_SEARCH_ENDPOINT, params=params, headers=headers, timeout=30.0) + response.raise_for_status() + result = response.json() + except httpx.RequestError as e: + raise Exception(f"Serply API request failed: {str(e)}") + except httpx.HTTPStatusError as e: + raise Exception(f"Serply API HTTP error: {str(e)}") + except json.JSONDecodeError as e: + raise Exception(f"Failed to parse Serply API response: {str(e)}") + + results = result.get("results") or [] + if not isinstance(results, list): + return [] + return results diff --git a/sdk/nexent/core/utils/tools_common_message.py b/sdk/nexent/core/utils/tools_common_message.py index cfb5f92a1..7d0ed8fb8 100644 --- a/sdk/nexent/core/utils/tools_common_message.py +++ b/sdk/nexent/core/utils/tools_common_message.py @@ -18,6 +18,7 @@ class ToolSign(Enum): AIDP_SEARCH = "j" # AIDP search tool identifier INDEPENDENT_AIDP_SEARCH = "l" # Independent AIDP search tool identifier MEMORY_OPERATION = "n" # Memory operation tool identifier + SERPLY_SEARCH = "o" # Serply search tool identifier SKILL_OPERATION = "s" # Skill script / file tool identifier TERMINAL_OPERATION = "t" # Terminal operation tool identifier MULTIMODAL_OPERATION = "m" # Multimodal operation tool identifier @@ -38,6 +39,7 @@ class ToolSign(Enum): "ragflow_search": ToolSign.RAGFLOW_SEARCH.value, "aidp_search": ToolSign.AIDP_SEARCH.value, "ind_aidp_search": ToolSign.INDEPENDENT_AIDP_SEARCH.value, + "serply_search": ToolSign.SERPLY_SEARCH.value, "file_operation": ToolSign.FILE_OPERATION.value, "terminal_operation": ToolSign.TERMINAL_OPERATION.value, "multimodal_operation": ToolSign.MULTIMODAL_OPERATION.value, diff --git a/test/sdk/core/tools/test_serply_search_tool.py b/test/sdk/core/tools/test_serply_search_tool.py new file mode 100644 index 000000000..7567546c1 --- /dev/null +++ b/test/sdk/core/tools/test_serply_search_tool.py @@ -0,0 +1,104 @@ +import json +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from sdk.nexent.core.tools.serply_search_tool import SerplySearchTool +from sdk.nexent.core.utils.observer import MessageObserver, ProcessType + + +@pytest.fixture +def mock_observer(): + observer = MagicMock(spec=MessageObserver) + observer.lang = "en" + return observer + + +@pytest.fixture +def serply_search_tool(mock_observer): + return SerplySearchTool( + serply_api_key="test_api_key", + observer=mock_observer, + max_results=3, + ) + + +def create_mock_serply_response(count=3): + """Helper method to create a mock Serply API response""" + results = [ + { + "title": f"Test Title {i}", + "link": f"https://example.com/{i}", + "description": f"This is test content {i}", + } + for i in range(count) + ] + return {"results": results} + + +def test_forward_with_results(serply_search_tool, mock_observer): + """Test forward method with search results""" + mock_response = MagicMock() + mock_response.json.return_value = create_mock_serply_response(3) + mock_response.raise_for_status.return_value = None + + with patch("sdk.nexent.core.tools.serply_search_tool.httpx.get", return_value=mock_response) as mock_get: + result = serply_search_tool.forward("test query") + + search_results = json.loads(result) + + called_headers = mock_get.call_args.kwargs["headers"] + assert called_headers["X-Api-Key"] == "test_api_key" + assert "User-Agent" in called_headers + + called_params = mock_get.call_args.kwargs["params"] + assert called_params == {"q": "test query", "num": 3} + + mock_observer.add_message.assert_any_call( + "", ProcessType.CARD, + json.dumps([{"icon": "search", "text": "test query"}], ensure_ascii=False) + ) + + assert len(search_results) == 3 + first_result = search_results[0] + assert first_result["title"] == "Test Title 0" + assert first_result["text"].startswith("This is test content") + assert isinstance(first_result["index"], str) + + +def test_forward_no_results(serply_search_tool): + """Test forward method with no search results""" + mock_response = MagicMock() + mock_response.json.return_value = {"results": []} + mock_response.raise_for_status.return_value = None + + with patch("sdk.nexent.core.tools.serply_search_tool.httpx.get", return_value=mock_response), \ + pytest.raises(Exception) as excinfo: + serply_search_tool.forward("test query") + + assert "No results found" in str(excinfo.value) + + +def test_forward_without_observer(): + """Test forward method without an observer""" + tool = SerplySearchTool(serply_api_key="test_api_key", observer=None, max_results=2) + + mock_response = MagicMock() + mock_response.json.return_value = create_mock_serply_response(2) + mock_response.raise_for_status.return_value = None + + with patch("sdk.nexent.core.tools.serply_search_tool.httpx.get", return_value=mock_response): + result = tool.forward("test query") + + search_results = json.loads(result) + assert len(search_results) == 2 + + +def test_forward_http_error(serply_search_tool): + """Test forward method when the Serply API returns an HTTP error""" + with patch("sdk.nexent.core.tools.serply_search_tool.httpx.get", side_effect=httpx.RequestError("boom")), \ + pytest.raises(Exception) as excinfo: + serply_search_tool.forward("test query") + + assert "Serply API request failed" in str(excinfo.value) From fe5d29087ddfacb494993e75870e0bf085d81214 Mon Sep 17 00:00:00 2001 From: serply Date: Wed, 26 Aug 2026 22:08:08 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=85=20Test:=20cover=20Serply=20search?= =?UTF-8?q?=20tool=20error=20branches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sdk/core/tools/test_serply_search_tool.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/sdk/core/tools/test_serply_search_tool.py b/test/sdk/core/tools/test_serply_search_tool.py index 7567546c1..6a0d0dc05 100644 --- a/test/sdk/core/tools/test_serply_search_tool.py +++ b/test/sdk/core/tools/test_serply_search_tool.py @@ -102,3 +102,37 @@ def test_forward_http_error(serply_search_tool): serply_search_tool.forward("test query") assert "Serply API request failed" in str(excinfo.value) + + +def test_forward_http_status_error(serply_search_tool): + """Test forward method when the Serply API returns a non-2xx status""" + request = httpx.Request("GET", "https://api.serply.io/v1/search/") + response = httpx.Response(401, request=request) + with patch("sdk.nexent.core.tools.serply_search_tool.httpx.get", return_value=response), \ + pytest.raises(Exception) as excinfo: + serply_search_tool.forward("test query") + + assert "Serply API HTTP error" in str(excinfo.value) + assert "401" in str(excinfo.value) + + +def test_forward_invalid_json(serply_search_tool): + """Test forward method when the Serply API returns a non-JSON body""" + request = httpx.Request("GET", "https://api.serply.io/v1/search/") + response = httpx.Response(200, content=b"not json", request=request) + with patch("sdk.nexent.core.tools.serply_search_tool.httpx.get", return_value=response), \ + pytest.raises(Exception) as excinfo: + serply_search_tool.forward("test query") + + assert "Failed to parse Serply API response" in str(excinfo.value) + + +def test_forward_results_not_a_list(serply_search_tool): + """Test forward method when the Serply API returns a malformed results field""" + mock_response = MagicMock() + mock_response.json.return_value = {"results": {"unexpected": "shape"}} + with patch("sdk.nexent.core.tools.serply_search_tool.httpx.get", return_value=mock_response), \ + pytest.raises(Exception) as excinfo: + serply_search_tool.forward("test query") + + assert "No results found" in str(excinfo.value)