diff --git a/core/src/utcp/implementations/tag_search.py b/core/src/utcp/implementations/tag_search.py index f11bf40..7113f51 100644 --- a/core/src/utcp/implementations/tag_search.py +++ b/core/src/utcp/implementations/tag_search.py @@ -39,7 +39,7 @@ async def search_tools(self, tool_repository: ConcurrentToolRepository, query: s Args: tool_repository: The tool repository to search in. query: The query to search for. - limit: The maximum number of results to return. + limit: The maximum number of results to return. Use 0 for no limit. any_of_tags_required: A list of tags that must be present in the tool. Returns: @@ -91,8 +91,8 @@ async def search_tools(self, tool_repository: ConcurrentToolRepository, query: s # Sort tools by score in descending order sorted_tools = [tool for tool, score in sorted(tool_scores, key=lambda x: x[1], reverse=True)] - # Return up to 'limit' tools - return sorted_tools[:limit] + # Return up to 'limit' tools (0 for no limit) + return sorted_tools[:limit] if limit > 0 else sorted_tools class TagAndDescriptionWordMatchStrategyConfigSerializer(Serializer[TagAndDescriptionWordMatchStrategy]): """REQUIRED diff --git a/core/src/utcp/implementations/utcp_client_implementation.py b/core/src/utcp/implementations/utcp_client_implementation.py index e494b6e..9d62826 100644 --- a/core/src/utcp/implementations/utcp_client_implementation.py +++ b/core/src/utcp/implementations/utcp_client_implementation.py @@ -442,7 +442,7 @@ async def search_tools(self, query: str, limit: int = 10, any_of_tags_required: Args: query: The query to search for. - limit: The maximum number of results to return. + limit: The maximum number of results to return. 0 for no limit. any_of_tags_required: A list of tags that must be present in the tool. Returns: diff --git a/core/tests/implementations/test_tag_search.py b/core/tests/implementations/test_tag_search.py new file mode 100644 index 0000000..94be080 --- /dev/null +++ b/core/tests/implementations/test_tag_search.py @@ -0,0 +1,100 @@ +"""Unit tests for TagAndDescriptionWordMatchStrategy.""" + +import pytest +from utcp.data.call_template import CallTemplate +from utcp.data.tool import Tool, JsonSchema +from utcp.data.utcp_manual import UtcpManual +from utcp.implementations.in_mem_tool_repository import InMemToolRepository +from utcp.implementations.tag_search import TagAndDescriptionWordMatchStrategy + + +def get_sample_tools(): + tpl = CallTemplate(name="test_manual", call_template_type="test") + return [ + Tool( + name="add_numbers", + description="Add two numbers together in arithmetic", + inputs=JsonSchema(type="object"), + outputs=JsonSchema(type="object"), + tool_call_template=tpl, + tags=["math", "addition", "calculator"], + ), + Tool( + name="subtract_numbers", + description="Subtract two numbers in arithmetic", + inputs=JsonSchema(type="object"), + outputs=JsonSchema(type="object"), + tool_call_template=tpl, + tags=["math", "subtraction", "calculator"], + ), + Tool( + name="send_email", + description="Send an email to a recipient", + inputs=JsonSchema(type="object"), + outputs=JsonSchema(type="object"), + tool_call_template=tpl, + tags=["communication", "email"], + ), + ] + + +async def create_populated_repo(): + tools = get_sample_tools() + repo = InMemToolRepository() + tpl = CallTemplate(name="test_manual", call_template_type="test") + manual = UtcpManual(tools=tools) + await repo.save_manual(tpl, manual) + return repo, tools + + +@pytest.mark.asyncio +async def test_search_tools_limit_zero_returns_all_tools(): + """Verify limit=0 returns all matching tools without slicing to empty.""" + repo, tools = await create_populated_repo() + strategy = TagAndDescriptionWordMatchStrategy() + results = await strategy.search_tools(repo, "math", limit=0) + assert len(results) == len(tools) + + +@pytest.mark.asyncio +async def test_search_tools_positive_limit_respected(): + """Verify limit > 0 returns at most `limit` results.""" + repo, _ = await create_populated_repo() + strategy = TagAndDescriptionWordMatchStrategy() + results = await strategy.search_tools(repo, "math", limit=1) + assert len(results) == 1 + assert results[0].name in ("add_numbers", "subtract_numbers") + + +@pytest.mark.asyncio +async def test_search_tools_negative_limit_raises_error(): + """Verify negative limit raises ValueError.""" + repo, _ = await create_populated_repo() + strategy = TagAndDescriptionWordMatchStrategy() + with pytest.raises(ValueError, match="limit must be non-negative"): + await strategy.search_tools(repo, "math", limit=-1) + + +@pytest.mark.asyncio +async def test_search_tools_tag_and_description_matching(): + """Verify tools with matching tags rank highest.""" + repo, _ = await create_populated_repo() + strategy = TagAndDescriptionWordMatchStrategy() + results = await strategy.search_tools(repo, "email", limit=10) + assert len(results) > 0 + assert results[0].name == "send_email" + + +@pytest.mark.asyncio +async def test_search_tools_any_of_tags_required(): + """Verify any_of_tags_required filters out non-matching tools.""" + repo, _ = await create_populated_repo() + strategy = TagAndDescriptionWordMatchStrategy() + results = await strategy.search_tools( + repo, + "numbers", + limit=0, + any_of_tags_required=["addition"], + ) + assert len(results) == 1 + assert results[0].name == "add_numbers"