Skip to content

Commit f1c33a0

Browse files
committed
Fix issues in v1.0.0
1 parent ed55a82 commit f1c33a0

File tree

14 files changed

+126
-134
lines changed

14 files changed

+126
-134
lines changed

core/src/utcp/data/tool.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,17 @@ class JsonSchema(BaseModel):
4141
maxLength: Optional[int] = None
4242

4343
model_config = {
44-
"populate_by_name": True, # replaces allow_population_by_field_name
44+
"validate_by_name": True,
45+
"validate_by_alias": True,
46+
"serialize_by_alias": True,
4547
"extra": "allow"
4648
}
4749

4850
JsonSchema.model_rebuild() # replaces update_forward_refs()
4951

5052
class JsonSchemaSerializer(Serializer[JsonSchema]):
5153
def to_dict(self, obj: JsonSchema) -> dict:
52-
return obj.model_dump()
54+
return obj.model_dump(by_alias=True)
5355

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

9698
class ToolSerializer(Serializer[Tool]):
9799
def to_dict(self, obj: Tool) -> dict:
98-
return obj.model_dump()
100+
return obj.model_dump(by_alias=True)
99101

100102
def validate_dict(self, obj: dict) -> Tool:
101103
try:

core/src/utcp/data/utcp_client_config.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,21 @@ class UtcpClientConfig(BaseModel):
2323
3. Environment variables
2424
2525
Attributes:
26-
variables: Direct variable definitions as key-value pairs.
27-
These take precedence over other variable sources.
28-
providers_file_path: Optional path to a file containing provider
29-
configurations. Supports JSON and YAML formats.
30-
load_variables_from: List of variable loaders to use for
31-
variable resolution. Loaders are consulted in order.
26+
variables (Optional[Dict[str, str]]): A dictionary of directly-defined
27+
variables for substitution.
28+
load_variables_from (Optional[List[VariableLoader]]): A list of
29+
variable loader configurations for loading variables from external
30+
sources like .env files or remote services.
31+
tool_repository (ConcurrentToolRepository): Configuration for the tool
32+
repository, which manages the storage and retrieval of tools.
33+
Defaults to an in-memory repository.
34+
tool_search_strategy (ToolSearchStrategy): Configuration for the tool
35+
search strategy, defining how tools are looked up. Defaults to a
36+
tag and description-based search.
37+
post_processing (List[ToolPostProcessor]): A list of tool post-processor
38+
configurations to be applied after a tool call.
39+
manual_call_templates (List[CallTemplate]): A list of manually defined
40+
call templates for registering tools that don't have a provider.
3241
3342
Example:
3443
```python

core/src/utcp/implementations/in_mem_tool_repository.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,32 +76,35 @@ async def remove_tool(self, tool_name: str) -> bool:
7676

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

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

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

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

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

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

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

106109
class InMemToolRepositoryConfigSerializer(Serializer[InMemToolRepository]):
107110
def to_dict(self, obj: InMemToolRepository) -> dict:

core/src/utcp/implementations/post_processors/filter_dict_post_processor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def _filter_dict_exclude_keys(self, result: Any) -> Any:
4141
new_result = {}
4242
for key, value in result.items():
4343
if key not in self.exclude_keys:
44-
new_result[key] = self._filter_dict(value)
44+
new_result[key] = self._filter_dict_exclude_keys(value)
4545
return new_result
4646

4747
if isinstance(result, list):

core/src/utcp/implementations/tag_search.py

Lines changed: 2 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,3 @@
1-
"""Tag-based tool search strategy implementation.
2-
3-
This module provides a search strategy that ranks tools based on tag matches
4-
and description keyword matches. It implements a weighted scoring system where
5-
explicit tag matches receive higher scores than description word matches.
6-
"""
7-
81
from utcp.interfaces.tool_search_strategy import ToolSearchStrategy
92
from typing import List, Tuple, Optional, Literal
103
from utcp.data.tool import Tool
@@ -13,56 +6,11 @@
136
from utcp.interfaces.serializer import Serializer
147

158
class TagAndDescriptionWordMatchStrategy(ToolSearchStrategy):
16-
"""Tag and description word match search strategy for UTCP tools.
17-
18-
Implements a weighted scoring algorithm that matches search queries against
19-
tool tags and descriptions. Explicit tag matches receive full weight while
20-
description word matches receive reduced weight.
21-
22-
Scoring Algorithm:
23-
- Exact tag matches: Weight 1.0
24-
- Tag word matches: Weight equal to description_weight
25-
- Description word matches: Weight equal to description_weight
26-
- Only considers description words longer than 2 characters
27-
28-
Examples:
29-
>>> strategy = TagAndDescriptionWordMatchStrategy(description_weight=0.3)
30-
>>> tools = await strategy.search_tools("weather api", limit=5)
31-
>>> # Returns tools with "weather" or "api" tags/descriptions
32-
33-
Attributes:
34-
description_weight: Weight multiplier for description matches (0.0-1.0).
35-
"""
369
tool_search_strategy_type: Literal["tag_and_description_word_match"] = "tag_and_description_word_match"
3710
description_weight: float = 1
3811
tag_weight: float = 3
3912

4013
async def search_tools(self, tool_repository: ConcurrentToolRepository, query: str, limit: int = 10, any_of_tags_required: Optional[List[str]] = None) -> List[Tool]:
41-
"""Search tools using tag and description matching.
42-
43-
Implements a weighted scoring system that ranks tools based on how well
44-
their tags and descriptions match the search query. Normalizes the query
45-
and uses word-based matching with configurable weights.
46-
47-
Scoring Details:
48-
- Exact tag matches in query: +1.0 points
49-
- Individual tag words matching query words: +description_weight points
50-
- Description words matching query words: +description_weight points
51-
- Only description words > 2 characters are considered
52-
53-
Args:
54-
query: Search query string. Case-insensitive, word-based matching.
55-
limit: Maximum number of tools to return. Must be >= 0.
56-
any_of_tags_required: Optional list of tags where one of them must be present in the tool's tags
57-
for it to be considered a match.
58-
59-
Returns:
60-
List of Tool objects ranked by relevance score (highest first).
61-
Empty list if no tools match or repository is empty.
62-
63-
Raises:
64-
ValueError: If limit is negative.
65-
"""
6614
if limit < 0:
6715
raise ValueError("limit must be non-negative")
6816
# Normalize query to lowercase and split into words
@@ -74,7 +22,8 @@ async def search_tools(self, tool_repository: ConcurrentToolRepository, query: s
7422
tools: List[Tool] = await tool_repository.get_tools()
7523

7624
if any_of_tags_required is not None and len(any_of_tags_required) > 0:
77-
tools = [tool for tool in tools if any(tag in tool.tags for tag in any_of_tags_required)]
25+
any_of_tags_required = [tag.lower() for tag in any_of_tags_required]
26+
tools = [tool for tool in tools if any(tag.lower() in any_of_tags_required for tag in tool.tags)]
7827

7928
# Calculate scores for each tool
8029
tool_scores: List[Tuple[Tool, float]] = []

core/src/utcp/interfaces/variable_substitutor.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ def substitute(self, obj: dict | list | str, config: UtcpClientConfig, variable_
1717
1818
Args:
1919
obj: Object containing potential variable references to substitute.
20-
Can be dict, list, str, or any other type.
2120
config: UTCP client configuration containing variable definitions
2221
and loaders.
2322
variable_namespace: Optional variable namespace.

core/tests/client/test_utcp_client.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,12 @@ async def sample_tools():
185185
]
186186

187187

188+
@pytest.fixture
189+
def isolated_communication_protocols(monkeypatch):
190+
"""Isolates the CommunicationProtocol registry for each test."""
191+
monkeypatch.setattr(CommunicationProtocol, "communication_protocols", {})
192+
193+
188194
@pytest_asyncio.fixture
189195
async def utcp_client():
190196
"""Fixture for UtcpClient."""
@@ -245,7 +251,7 @@ async def test_create_with_utcp_config(self):
245251
assert client.config is config
246252

247253
@pytest.mark.asyncio
248-
async def test_register_manual(self, utcp_client, sample_tools):
254+
async def test_register_manual(self, utcp_client, sample_tools, isolated_communication_protocols):
249255
"""Test registering a manual."""
250256
http_call_template = HttpCallTemplate(
251257
name="test_manual",
@@ -286,7 +292,7 @@ async def test_register_manual_unsupported_type(self, utcp_client):
286292
await utcp_client.register_manual(call_template)
287293

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

308314
@pytest.mark.asyncio
309-
async def test_deregister_manual(self, utcp_client, sample_tools):
315+
async def test_deregister_manual(self, utcp_client, sample_tools, isolated_communication_protocols):
310316
"""Test deregistering a manual."""
311317
call_template = HttpCallTemplate(
312318
name="test_manual",
@@ -341,7 +347,7 @@ async def test_deregister_nonexistent_manual(self, utcp_client):
341347
assert result is False
342348

343349
@pytest.mark.asyncio
344-
async def test_call_tool(self, utcp_client, sample_tools):
350+
async def test_call_tool(self, utcp_client, sample_tools, isolated_communication_protocols):
345351
"""Test calling a tool."""
346352
client = utcp_client
347353
call_template = HttpCallTemplate(
@@ -375,7 +381,7 @@ async def test_call_tool_nonexistent_manual(self, utcp_client):
375381
await client.call_tool("nonexistent.tool", {"param": "value"})
376382

377383
@pytest.mark.asyncio
378-
async def test_call_tool_nonexistent_tool(self, utcp_client, sample_tools):
384+
async def test_call_tool_nonexistent_tool(self, utcp_client, sample_tools, isolated_communication_protocols):
379385
"""Test calling a nonexistent tool."""
380386
client = utcp_client
381387
call_template = HttpCallTemplate(
@@ -396,7 +402,7 @@ async def test_call_tool_nonexistent_tool(self, utcp_client, sample_tools):
396402
await client.call_tool("test_manual.nonexistent", {"param": "value"})
397403

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

424430
@pytest.mark.asyncio
425-
async def test_get_required_variables_for_manual_and_tools(self, utcp_client):
431+
async def test_get_required_variables_for_manual_and_tools(self, utcp_client, isolated_communication_protocols):
426432
"""Test getting required variables for a manual."""
427433
client = utcp_client
428434
call_template = HttpCallTemplate(
@@ -477,7 +483,7 @@ class TestUtcpClientManualCallTemplateLoading:
477483
"""Test call template loading functionality."""
478484

479485
@pytest.mark.asyncio
480-
async def test_load_manual_call_templates_from_file(self):
486+
async def test_load_manual_call_templates_from_file(self, isolated_communication_protocols):
481487
"""Test loading call templates from a JSON file."""
482488
config_data = {
483489
"manual_call_templates": [
@@ -536,7 +542,7 @@ async def test_load_manual_call_templates_invalid_json(self):
536542
os.unlink(temp_file)
537543

538544
@pytest.mark.asyncio
539-
async def test_load_manual_call_templates_with_variables(self):
545+
async def test_load_manual_call_templates_with_variables(self, isolated_communication_protocols):
540546
"""Test loading call templates with variable substitution."""
541547
config_data = {
542548
"variables": {
@@ -663,7 +669,7 @@ async def test_empty_call_template_file(self):
663669
os.unlink(temp_file)
664670

665671
@pytest.mark.asyncio
666-
async def test_register_manual_with_existing_name(self, utcp_client):
672+
async def test_register_manual_with_existing_name(self, utcp_client, isolated_communication_protocols):
667673
"""Test registering a manual with an existing name should raise an error."""
668674
client = utcp_client
669675
template1 = HttpCallTemplate(
@@ -717,3 +723,36 @@ async def test_load_call_templates_wrong_format(self):
717723
await UtcpClient.create(config=temp_file)
718724
finally:
719725
os.unlink(temp_file)
726+
727+
728+
class TestToolSerialization:
729+
"""Test Tool and JsonSchema serialization."""
730+
731+
def test_json_schema_serialization_by_alias(self):
732+
"""Test that JsonSchema serializes using field aliases."""
733+
schema = JsonSchema(
734+
schema_="http://json-schema.org/draft-07/schema#",
735+
id_="test_schema",
736+
type="object",
737+
properties={
738+
"param": JsonSchema(type="string")
739+
}
740+
)
741+
742+
serialized_schema = schema.model_dump()
743+
744+
assert "$schema" in serialized_schema
745+
assert "$id" in serialized_schema
746+
assert serialized_schema["$schema"] == "http://json-schema.org/draft-07/schema#"
747+
assert serialized_schema["$id"] == "test_schema"
748+
749+
def test_tool_serialization_by_alias(self, sample_tools):
750+
"""Test that Tool serializes its JsonSchema fields by alias."""
751+
tool = sample_tools[0]
752+
tool.inputs.schema_ = "http://json-schema.org/draft-07/schema#"
753+
754+
serialized_tool = tool.model_dump()
755+
756+
assert "inputs" in serialized_tool
757+
assert "$schema" in serialized_tool["inputs"]
758+
assert serialized_tool["inputs"]["$schema"] == "http://json-schema.org/draft-07/schema#"

plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import base64
2020
import re
2121
import traceback
22+
from urllib.parse import quote
2223

2324
from utcp.interfaces.communication_protocol import CommunicationProtocol
2425
from utcp.data.call_template import CallTemplate
@@ -394,7 +395,8 @@ def _build_url_with_path_params(self, url_template: str, tool_args: Dict[str, An
394395
for param_name in path_params:
395396
if param_name in tool_args:
396397
# Replace the parameter in the URL
397-
param_value = str(tool_args[param_name])
398+
# URL-encode the parameter value to prevent path injection
399+
param_value = quote(str(tool_args[param_name]))
398400
url = url.replace(f'{{{param_name}}}', param_value)
399401
# Remove the parameter from arguments so it's not used as a query parameter
400402
tool_args.pop(param_name)

plugins/communication_protocols/http/src/utcp_http/openapi_converter.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,11 @@ def __init__(self, openapi_spec: Dict[str, Any], spec_url: Optional[str] = None,
7474
self.placeholder_counter = 0
7575
# If call_template_name is None then get the first word in spec.info.title
7676
if call_template_name is None:
77-
title = openapi_spec.get("info", {}).get("title", "openapi_call_template_" + uuid.uuid4().hex)
78-
# Replace characters that are invalid for identifiers
79-
invalid_chars = " -.,!?'\"\\/()[]{}#@$%^&*+=~`|;:<>"
80-
self.call_template_name = ''.join('_' if c in invalid_chars else c for c in title)
81-
else:
82-
self.call_template_name = call_template_name
77+
call_template_name = "openapi_call_template_" + uuid.uuid4().hex
78+
title = openapi_spec.get("info", {}).get("title", call_template_name)
79+
# Replace characters that are invalid for identifiers
80+
invalid_chars = " -.,!?'\"\\/()[]{}#@$%^&*+=~`|;:<>"
81+
self.call_template_name = ''.join('_' if c in invalid_chars else c for c in title)
8382

8483
def _increment_placeholder_counter(self) -> int:
8584
"""Increments the global counter and returns the new value.
@@ -299,13 +298,11 @@ def _create_tool(self, path: str, method: str, operation: Dict[str, Any], base_u
299298
outputs = self._extract_outputs(operation)
300299
auth = self._extract_auth(operation)
301300

302-
call_template_name = self.spec.get("info", {}).get("title", "call_template_" + uuid.uuid4().hex)
303-
304301
# Combine base URL and path, ensuring no double slashes
305302
full_url = base_url.rstrip('/') + '/' + path.lstrip('/')
306303

307304
call_template = HttpCallTemplate(
308-
name=call_template_name,
305+
name=self.call_template_name,
309306
http_method=method.upper(),
310307
url=full_url,
311308
body_field=body_field if body_field else None,

0 commit comments

Comments
 (0)