Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Universal Tool Calling Protocol (UTCP) 1.0.0
# Universal Tool Calling Protocol (UTCP) 1.0.1

[![Follow Org](https://img.shields.io/github/followers/universal-tool-calling-protocol?label=Follow%20Org&logo=github)](https://github.com/universal-tool-calling-protocol)
[![PyPI Downloads](https://static.pepy.tech/badge/utcp)](https://pepy.tech/projects/utcp)
Expand Down Expand Up @@ -269,7 +269,7 @@ app = FastAPI()
def utcp_discovery():
return {
"manual_version": "1.0.0",
"utcp_version": "1.0.0",
"utcp_version": "1.0.1",
"tools": [
{
"name": "get_weather",
Expand Down
1 change: 1 addition & 0 deletions core/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include ../README.md
4 changes: 2 additions & 2 deletions core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"

[project]
name = "utcp"
version = "1.0.0"
version = "1.0.1"
authors = [
{ name = "UTCP Contributors" },
]
description = "Universal Tool Calling Protocol (UTCP) client library for Python"
readme = "README.md"
readme = "../README.md"
requires-python = ">=3.10"
dependencies = [
"pydantic>=2.0",
Expand Down
8 changes: 5 additions & 3 deletions core/src/utcp/data/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,17 @@ class JsonSchema(BaseModel):
maxLength: Optional[int] = None

model_config = {
"populate_by_name": True, # replaces allow_population_by_field_name
"validate_by_name": True,
"validate_by_alias": True,
"serialize_by_alias": True,
"extra": "allow"
}

JsonSchema.model_rebuild() # replaces update_forward_refs()

class JsonSchemaSerializer(Serializer[JsonSchema]):
def to_dict(self, obj: JsonSchema) -> dict:
return obj.model_dump()
return obj.model_dump(by_alias=True)

def validate_dict(self, obj: dict) -> JsonSchema:
try:
Expand Down Expand Up @@ -95,7 +97,7 @@ def validate_call_template(cls, v: Union[CallTemplate, dict]):

class ToolSerializer(Serializer[Tool]):
def to_dict(self, obj: Tool) -> dict:
return obj.model_dump()
return obj.model_dump(by_alias=True)

def validate_dict(self, obj: dict) -> Tool:
try:
Expand Down
21 changes: 15 additions & 6 deletions core/src/utcp/data/utcp_client_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,21 @@ class UtcpClientConfig(BaseModel):
3. Environment variables

Attributes:
variables: Direct variable definitions as key-value pairs.
These take precedence over other variable sources.
providers_file_path: Optional path to a file containing provider
configurations. Supports JSON and YAML formats.
load_variables_from: List of variable loaders to use for
variable resolution. Loaders are consulted in order.
variables (Optional[Dict[str, str]]): A dictionary of directly-defined
variables for substitution.
load_variables_from (Optional[List[VariableLoader]]): A list of
variable loader configurations for loading variables from external
sources like .env files or remote services.
tool_repository (ConcurrentToolRepository): Configuration for the tool
repository, which manages the storage and retrieval of tools.
Defaults to an in-memory repository.
tool_search_strategy (ToolSearchStrategy): Configuration for the tool
search strategy, defining how tools are looked up. Defaults to a
tag and description-based search.
post_processing (List[ToolPostProcessor]): A list of tool post-processor
configurations to be applied after a tool call.
manual_call_templates (List[CallTemplate]): A list of manually defined
call templates for registering tools that don't have a provider.

Example:
```python
Expand Down
17 changes: 10 additions & 7 deletions core/src/utcp/implementations/in_mem_tool_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,32 +76,35 @@ async def remove_tool(self, tool_name: str) -> bool:

async def get_tool(self, tool_name: str) -> Optional[Tool]:
async with self._rwlock.read():
return self._tools_by_name.get(tool_name)
tool = self._tools_by_name.get(tool_name)
return tool.model_copy(deep=True) if tool else None

async def get_tools(self) -> List[Tool]:
async with self._rwlock.read():
return list(self._tools_by_name.values())
return [t.model_copy(deep=True) for t in self._tools_by_name.values()]

async def get_tools_by_manual(self, manual_name: str) -> Optional[List[Tool]]:
async with self._rwlock.read():
manual = self._manuals.get(manual_name)
return manual.tools if manual is not None else None
return [t.model_copy(deep=True) for t in manual.tools] if manual is not None else None

async def get_manual(self, manual_name: str) -> Optional[UtcpManual]:
async with self._rwlock.read():
return self._manuals.get(manual_name)
manual = self._manuals.get(manual_name)
return manual.model_copy(deep=True) if manual else None

async def get_manuals(self) -> List[UtcpManual]:
async with self._rwlock.read():
return list(self._manuals.values())
return [m.model_copy(deep=True) for m in self._manuals.values()]

async def get_manual_call_template(self, manual_call_template_name: str) -> Optional[CallTemplate]:
async with self._rwlock.read():
return self._manual_call_templates.get(manual_call_template_name)
manual_call_template = self._manual_call_templates.get(manual_call_template_name)
return manual_call_template.model_copy(deep=True) if manual_call_template else None

async def get_manual_call_templates(self) -> List[CallTemplate]:
async with self._rwlock.read():
return list(self._manual_call_templates.values())
return [m.model_copy(deep=True) for m in self._manual_call_templates.values()]

class InMemToolRepositoryConfigSerializer(Serializer[InMemToolRepository]):
def to_dict(self, obj: InMemToolRepository) -> dict:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def _filter_dict_exclude_keys(self, result: Any) -> Any:
new_result = {}
for key, value in result.items():
if key not in self.exclude_keys:
new_result[key] = self._filter_dict(value)
new_result[key] = self._filter_dict_exclude_keys(value)
return new_result

if isinstance(result, list):
Expand Down
55 changes: 2 additions & 53 deletions core/src/utcp/implementations/tag_search.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
"""Tag-based tool search strategy implementation.

This module provides a search strategy that ranks tools based on tag matches
and description keyword matches. It implements a weighted scoring system where
explicit tag matches receive higher scores than description word matches.
"""

from utcp.interfaces.tool_search_strategy import ToolSearchStrategy
from typing import List, Tuple, Optional, Literal
from utcp.data.tool import Tool
Expand All @@ -13,56 +6,11 @@
from utcp.interfaces.serializer import Serializer

class TagAndDescriptionWordMatchStrategy(ToolSearchStrategy):
"""Tag and description word match search strategy for UTCP tools.

Implements a weighted scoring algorithm that matches search queries against
tool tags and descriptions. Explicit tag matches receive full weight while
description word matches receive reduced weight.

Scoring Algorithm:
- Exact tag matches: Weight 1.0
- Tag word matches: Weight equal to description_weight
- Description word matches: Weight equal to description_weight
- Only considers description words longer than 2 characters

Examples:
>>> strategy = TagAndDescriptionWordMatchStrategy(description_weight=0.3)
>>> tools = await strategy.search_tools("weather api", limit=5)
>>> # Returns tools with "weather" or "api" tags/descriptions

Attributes:
description_weight: Weight multiplier for description matches (0.0-1.0).
"""
tool_search_strategy_type: Literal["tag_and_description_word_match"] = "tag_and_description_word_match"
description_weight: float = 1
tag_weight: float = 3

async def search_tools(self, tool_repository: ConcurrentToolRepository, query: str, limit: int = 10, any_of_tags_required: Optional[List[str]] = None) -> List[Tool]:
"""Search tools using tag and description matching.

Implements a weighted scoring system that ranks tools based on how well
their tags and descriptions match the search query. Normalizes the query
and uses word-based matching with configurable weights.

Scoring Details:
- Exact tag matches in query: +1.0 points
- Individual tag words matching query words: +description_weight points
- Description words matching query words: +description_weight points
- Only description words > 2 characters are considered

Args:
query: Search query string. Case-insensitive, word-based matching.
limit: Maximum number of tools to return. Must be >= 0.
any_of_tags_required: Optional list of tags where one of them must be present in the tool's tags
for it to be considered a match.

Returns:
List of Tool objects ranked by relevance score (highest first).
Empty list if no tools match or repository is empty.

Raises:
ValueError: If limit is negative.
"""
if limit < 0:
raise ValueError("limit must be non-negative")
# Normalize query to lowercase and split into words
Expand All @@ -74,7 +22,8 @@ async def search_tools(self, tool_repository: ConcurrentToolRepository, query: s
tools: List[Tool] = await tool_repository.get_tools()

if any_of_tags_required is not None and len(any_of_tags_required) > 0:
tools = [tool for tool in tools if any(tag in tool.tags for tag in any_of_tags_required)]
any_of_tags_required = [tag.lower() for tag in any_of_tags_required]
tools = [tool for tool in tools if any(tag.lower() in any_of_tags_required for tag in tool.tags)]

# Calculate scores for each tool
tool_scores: List[Tuple[Tool, float]] = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ async def try_register_manual(manual_call_template=manual_call_template):
logger.error(f"Error registering manual '{manual_call_template.name}': {traceback.format_exc()}")
return RegisterManualResult(
manual_call_template=manual_call_template,
manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]),
manual=UtcpManual(manual_version="0.0.0", tools=[]),
success=False,
errors=[traceback.format_exc()]
)
Expand Down
1 change: 0 additions & 1 deletion core/src/utcp/interfaces/variable_substitutor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ def substitute(self, obj: dict | list | str, config: UtcpClientConfig, variable_

Args:
obj: Object containing potential variable references to substitute.
Can be dict, list, str, or any other type.
config: UTCP client configuration containing variable definitions
and loaders.
variable_namespace: Optional variable namespace.
Expand Down
2 changes: 1 addition & 1 deletion core/src/utcp/python_specific_tooling/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

logger = logging.getLogger(__name__)

__version__ = "1.0.0"
__version__ = "1.0.1"
try:
__version__ = version("utcp")
except PackageNotFoundError:
Expand Down
59 changes: 49 additions & 10 deletions core/tests/client/test_utcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ async def sample_tools():
]


@pytest.fixture
def isolated_communication_protocols(monkeypatch):
"""Isolates the CommunicationProtocol registry for each test."""
monkeypatch.setattr(CommunicationProtocol, "communication_protocols", {})


@pytest_asyncio.fixture
async def utcp_client():
"""Fixture for UtcpClient."""
Expand Down Expand Up @@ -245,7 +251,7 @@ async def test_create_with_utcp_config(self):
assert client.config is config

@pytest.mark.asyncio
async def test_register_manual(self, utcp_client, sample_tools):
async def test_register_manual(self, utcp_client, sample_tools, isolated_communication_protocols):
"""Test registering a manual."""
http_call_template = HttpCallTemplate(
name="test_manual",
Expand Down Expand Up @@ -286,7 +292,7 @@ async def test_register_manual_unsupported_type(self, utcp_client):
await utcp_client.register_manual(call_template)

@pytest.mark.asyncio
async def test_register_manual_name_sanitization(self, utcp_client, sample_tools):
async def test_register_manual_name_sanitization(self, utcp_client, sample_tools, isolated_communication_protocols):
"""Test that manual names are sanitized."""
call_template = HttpCallTemplate(
name="test-manual.with/special@chars",
Expand All @@ -306,7 +312,7 @@ async def test_register_manual_name_sanitization(self, utcp_client, sample_tools
assert result.manual.tools[0].name == "test_manual_with_special_chars.http_tool"

@pytest.mark.asyncio
async def test_deregister_manual(self, utcp_client, sample_tools):
async def test_deregister_manual(self, utcp_client, sample_tools, isolated_communication_protocols):
"""Test deregistering a manual."""
call_template = HttpCallTemplate(
name="test_manual",
Expand Down Expand Up @@ -341,7 +347,7 @@ async def test_deregister_nonexistent_manual(self, utcp_client):
assert result is False

@pytest.mark.asyncio
async def test_call_tool(self, utcp_client, sample_tools):
async def test_call_tool(self, utcp_client, sample_tools, isolated_communication_protocols):
"""Test calling a tool."""
client = utcp_client
call_template = HttpCallTemplate(
Expand Down Expand Up @@ -375,7 +381,7 @@ async def test_call_tool_nonexistent_manual(self, utcp_client):
await client.call_tool("nonexistent.tool", {"param": "value"})

@pytest.mark.asyncio
async def test_call_tool_nonexistent_tool(self, utcp_client, sample_tools):
async def test_call_tool_nonexistent_tool(self, utcp_client, sample_tools, isolated_communication_protocols):
"""Test calling a nonexistent tool."""
client = utcp_client
call_template = HttpCallTemplate(
Expand All @@ -396,7 +402,7 @@ async def test_call_tool_nonexistent_tool(self, utcp_client, sample_tools):
await client.call_tool("test_manual.nonexistent", {"param": "value"})

@pytest.mark.asyncio
async def test_search_tools(self, utcp_client, sample_tools):
async def test_search_tools(self, utcp_client, sample_tools, isolated_communication_protocols):
"""Test searching for tools."""
client = utcp_client
# Clear any existing manuals from other tests to ensure a clean slate
Expand All @@ -422,7 +428,7 @@ async def test_search_tools(self, utcp_client, sample_tools):
assert "http" in results[0].name.lower() or "http" in results[0].description.lower()

@pytest.mark.asyncio
async def test_get_required_variables_for_manual_and_tools(self, utcp_client):
async def test_get_required_variables_for_manual_and_tools(self, utcp_client, isolated_communication_protocols):
"""Test getting required variables for a manual."""
client = utcp_client
call_template = HttpCallTemplate(
Expand Down Expand Up @@ -477,7 +483,7 @@ class TestUtcpClientManualCallTemplateLoading:
"""Test call template loading functionality."""

@pytest.mark.asyncio
async def test_load_manual_call_templates_from_file(self):
async def test_load_manual_call_templates_from_file(self, isolated_communication_protocols):
"""Test loading call templates from a JSON file."""
config_data = {
"manual_call_templates": [
Expand Down Expand Up @@ -536,7 +542,7 @@ async def test_load_manual_call_templates_invalid_json(self):
os.unlink(temp_file)

@pytest.mark.asyncio
async def test_load_manual_call_templates_with_variables(self):
async def test_load_manual_call_templates_with_variables(self, isolated_communication_protocols):
"""Test loading call templates with variable substitution."""
config_data = {
"variables": {
Expand Down Expand Up @@ -663,7 +669,7 @@ async def test_empty_call_template_file(self):
os.unlink(temp_file)

@pytest.mark.asyncio
async def test_register_manual_with_existing_name(self, utcp_client):
async def test_register_manual_with_existing_name(self, utcp_client, isolated_communication_protocols):
"""Test registering a manual with an existing name should raise an error."""
client = utcp_client
template1 = HttpCallTemplate(
Expand Down Expand Up @@ -717,3 +723,36 @@ async def test_load_call_templates_wrong_format(self):
await UtcpClient.create(config=temp_file)
finally:
os.unlink(temp_file)


class TestToolSerialization:
"""Test Tool and JsonSchema serialization."""

def test_json_schema_serialization_by_alias(self):
"""Test that JsonSchema serializes using field aliases."""
schema = JsonSchema(
schema_="http://json-schema.org/draft-07/schema#",
id_="test_schema",
type="object",
properties={
"param": JsonSchema(type="string")
}
)

serialized_schema = schema.model_dump()

assert "$schema" in serialized_schema
assert "$id" in serialized_schema
assert serialized_schema["$schema"] == "http://json-schema.org/draft-07/schema#"
assert serialized_schema["$id"] == "test_schema"

def test_tool_serialization_by_alias(self, sample_tools):
"""Test that Tool serializes its JsonSchema fields by alias."""
tool = sample_tools[0]
tool.inputs.schema_ = "http://json-schema.org/draft-07/schema#"

serialized_tool = tool.model_dump()

assert "inputs" in serialized_tool
assert "$schema" in serialized_tool["inputs"]
assert serialized_tool["inputs"]["$schema"] == "http://json-schema.org/draft-07/schema#"
1 change: 1 addition & 0 deletions plugins/communication_protocols/cli/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include ../../../README.md
Loading
Loading