Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/consts/tool_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 2 additions & 0 deletions sdk/nexent/core/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,6 +48,7 @@
"SendEmailTool",
"GetEmailTool",
"TavilySearchTool",
"SerplySearchTool",
"LinkupSearchTool",
"CreateFileTool",
"ReadFileTool",
Expand Down
134 changes: 134 additions & 0 deletions sdk/nexent/core/tools/serply_search_tool.py
Original file line number Diff line number Diff line change
@@ -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.')

Check warning on line 65 in sdk/nexent/core/tools/serply_search_tool.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this generic exception class with a more specific one.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaApoIep-2muSVzVrMXk&open=AaApoIep-2muSVzVrMXk&pullRequest=3749

# 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)}")

Check warning on line 125 in sdk/nexent/core/tools/serply_search_tool.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this generic exception class with a more specific one.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaApoIep-2muSVzVrMXl&open=AaApoIep-2muSVzVrMXl&pullRequest=3749
except httpx.HTTPStatusError as e:
raise Exception(f"Serply API HTTP error: {str(e)}")

Check warning on line 127 in sdk/nexent/core/tools/serply_search_tool.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this generic exception class with a more specific one.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaApoIep-2muSVzVrMXm&open=AaApoIep-2muSVzVrMXm&pullRequest=3749
except json.JSONDecodeError as e:
raise Exception(f"Failed to parse Serply API response: {str(e)}")

Check warning on line 129 in sdk/nexent/core/tools/serply_search_tool.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this generic exception class with a more specific one.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaApoIep-2muSVzVrMXn&open=AaApoIep-2muSVzVrMXn&pullRequest=3749

results = result.get("results") or []
if not isinstance(results, list):
return []
return results
2 changes: 2 additions & 0 deletions sdk/nexent/core/utils/tools_common_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
138 changes: 138 additions & 0 deletions test/sdk/core/tools/test_serply_search_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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:

Check warning on line 77 in test/sdk/core/tools/test_serply_search_tool.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is too broad; use a more specific exception type or check the exception message.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaApoI0x-2muSVzVrMXo&open=AaApoI0x-2muSVzVrMXo&pullRequest=3749
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:

Check warning on line 101 in test/sdk/core/tools/test_serply_search_tool.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is too broad; use a more specific exception type or check the exception message.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaApoI0x-2muSVzVrMXp&open=AaApoI0x-2muSVzVrMXp&pullRequest=3749
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:

Check warning on line 112 in test/sdk/core/tools/test_serply_search_tool.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is too broad; use a more specific exception type or check the exception message.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaBBBtd0sZXcY31UwhCq&open=AaBBBtd0sZXcY31UwhCq&pullRequest=3749
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:

Check warning on line 124 in test/sdk/core/tools/test_serply_search_tool.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is too broad; use a more specific exception type or check the exception message.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaBBBtd1sZXcY31UwhCr&open=AaBBBtd1sZXcY31UwhCr&pullRequest=3749
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:

Check warning on line 135 in test/sdk/core/tools/test_serply_search_tool.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is too broad; use a more specific exception type or check the exception message.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaBBBtd1sZXcY31UwhCs&open=AaBBBtd1sZXcY31UwhCs&pullRequest=3749
serply_search_tool.forward("test query")

assert "No results found" in str(excinfo.value)