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
25 changes: 19 additions & 6 deletions core/src/utcp/data/call_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
"""

from typing import List, Optional, Union
from pydantic import BaseModel, field_serializer, field_validator, Field
from pydantic import BaseModel, ConfigDict, field_serializer, field_validator, Field
import uuid
from utcp.interfaces.serializer import Serializer
from utcp.exceptions import UtcpSerializerValidationError
from utcp.exceptions import UtcpSerializerValidationError, UtcpUnknownCallTemplateTypeError
import traceback
from utcp.data.auth import Auth, AuthSerializer

Expand All @@ -45,8 +45,13 @@ class CallTemplate(BaseModel):
the same protocol type as the manual's call_template_type. This provides fine-grained security
control - e.g., set to ["http", "cli"] to allow both HTTP and CLI tools, or leave unset to
restrict tools to the manual's own protocol type.

Keys this client does not know are kept in `model_extra` and re-serialized unchanged,
so a manual carrying `x-` extension keys survives a load/store round trip.
"""


model_config = ConfigDict(extra="allow")

name: str = Field(default_factory=lambda: uuid.uuid4().hex)
call_template_type: str
auth: Optional[Auth] = None
Expand Down Expand Up @@ -100,10 +105,18 @@ def validate_dict(self, obj: dict) -> CallTemplate:

Returns:
The CallTemplate object converted from the dictionary.

Raises:
UtcpUnknownCallTemplateTypeError: The type is named but no serializer is
registered for it. Callers that load a whole manual skip the tool.
UtcpSerializerValidationError: The template is malformed.
"""
if "call_template_type" not in obj:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Check that obj is a dictionary before evaluating membership. Passing None currently raises a raw TypeError, which ToolSerializer wraps as an Invalid Tool error instead of the intended Invalid CallTemplate validation error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At core/src/utcp/data/call_template.py, line 114:

<comment>Check that `obj` is a dictionary before evaluating membership. Passing `None` currently raises a raw `TypeError`, which `ToolSerializer` wraps as an `Invalid Tool` error instead of the intended `Invalid CallTemplate` validation error.</comment>

<file context>
@@ -100,10 +105,18 @@ def validate_dict(self, obj: dict) -> CallTemplate:
+                registered for it. Callers that load a whole manual skip the tool.
+            UtcpSerializerValidationError: The template is malformed.
         """
+        if "call_template_type" not in obj:
+            raise UtcpSerializerValidationError("Invalid CallTemplate: missing 'call_template_type'")
+        serializer = CallTemplateSerializer.call_template_serializers.get(obj["call_template_type"])
</file context>
Suggested change
if "call_template_type" not in obj:
if not isinstance(obj, dict):
raise UtcpSerializerValidationError("Invalid CallTemplate: expected a dictionary")
if "call_template_type" not in obj:

raise UtcpSerializerValidationError("Invalid CallTemplate: missing 'call_template_type'")
serializer = CallTemplateSerializer.call_template_serializers.get(obj["call_template_type"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When call_template_type is non-string, this lookup classifies malformed templates as unknown protocols, so manual loading silently skips them instead of raising validation errors; unhashable values can also leak TypeError. Validate that the discriminator is a string before consulting the serializer registry.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At core/src/utcp/data/call_template.py, line 116:

<comment>When `call_template_type` is non-string, this lookup classifies malformed templates as unknown protocols, so manual loading silently skips them instead of raising validation errors; unhashable values can also leak `TypeError`. Validate that the discriminator is a string before consulting the serializer registry.</comment>

<file context>
@@ -100,10 +105,18 @@ def validate_dict(self, obj: dict) -> CallTemplate:
         """
+        if "call_template_type" not in obj:
+            raise UtcpSerializerValidationError("Invalid CallTemplate: missing 'call_template_type'")
+        serializer = CallTemplateSerializer.call_template_serializers.get(obj["call_template_type"])
+        if serializer is None:
+            raise UtcpUnknownCallTemplateTypeError(obj["call_template_type"])
</file context>
Suggested change
serializer = CallTemplateSerializer.call_template_serializers.get(obj["call_template_type"])
call_template_type = obj["call_template_type"]
if not isinstance(call_template_type, str):
raise UtcpSerializerValidationError(
"Invalid CallTemplate: 'call_template_type' must be a string"
)
serializer = CallTemplateSerializer.call_template_serializers.get(call_template_type)

if serializer is None:
raise UtcpUnknownCallTemplateTypeError(obj["call_template_type"])
Comment on lines +114 to +118
try:
return CallTemplateSerializer.call_template_serializers[obj["call_template_type"]].validate_dict(obj)
except KeyError:
raise ValueError(f"Invalid call template type: {obj['call_template_type']}")
return serializer.validate_dict(obj)
except Exception as e:
raise UtcpSerializerValidationError("Invalid CallTemplate: " + traceback.format_exc()) from e
4 changes: 3 additions & 1 deletion core/src/utcp/data/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from utcp.data.call_template import CallTemplate, CallTemplateSerializer
from utcp.interfaces.serializer import Serializer
from typing import Union
from utcp.exceptions import UtcpSerializerValidationError
from utcp.exceptions import UtcpSerializerValidationError, UtcpUnknownCallTemplateTypeError
import traceback

JsonType = Union[str, int, float, bool, None, Dict[str, Any], List[Any]]
Expand Down Expand Up @@ -177,5 +177,7 @@ def validate_dict(self, obj: dict) -> Tool:
"""
try:
return Tool.model_validate(obj)
except UtcpUnknownCallTemplateTypeError:
raise
except Exception as e:
raise UtcpSerializerValidationError("Invalid Tool: " + traceback.format_exc()) from e
34 changes: 29 additions & 5 deletions core/src/utcp/data/utcp_manual.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@
configurations.
"""

import logging
from typing import List, Union, Optional, Any
from pydantic import BaseModel, field_serializer, field_validator
from pydantic import BaseModel, ConfigDict, field_serializer, field_validator
from utcp.python_specific_tooling.tool_decorator import ToolContext
from utcp.python_specific_tooling.version import __version__
from utcp.data.tool import Tool
from utcp.data.tool import ToolSerializer
from utcp.interfaces.serializer import Serializer
from utcp.exceptions import UtcpSerializerValidationError
from utcp.plugins.plugin_loader import ensure_plugins_initialized
from utcp.exceptions import UtcpUnknownCallTemplateTypeError
import traceback

logger = logging.getLogger(__name__)

class UtcpManual(BaseModel):
"""REQUIRED
Standard format for tool provider responses during discovery.
Expand All @@ -33,7 +37,12 @@ class UtcpManual(BaseModel):
version: UTCP protocol version supported by the provider.
Defaults to the current library version.
tools: List of available tools with their complete configurations
including input/output schemas, descriptions, and metadata.
including input/output schemas, descriptions, and metadata. Tools whose
call template type is not registered in this client are skipped with a
warning; the remaining tools load normally.

Keys this client does not know are kept in `model_extra` and re-serialized
unchanged, so `info` and `x-` extension keys survive a load/store round trip.

Example:
```python
Expand All @@ -55,14 +64,16 @@ def tool2():
)
```
"""
model_config = ConfigDict(extra="allow")

utcp_version: str = __version__
manual_version: str = "1.0.0"
tools: List[Tool]

def __init__(self, tools: List[Tool], manual_version: str = "1.0.0", utcp_version: str = __version__):
super().__init__(utcp_version=utcp_version, manual_version=manual_version, tools=tools)
def __init__(self, **data):
"""Initializes the UtcpManual, ensuring plugins are loaded."""
ensure_plugins_initialized()
super().__init__(**data)
Comment on lines +73 to +76

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Positional construction such as UtcpManual(tools) now raises TypeError before validation, unlike the previous constructor. Keep the explicit parameters and add **data after them so extra keys remain supported.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At core/src/utcp/data/utcp_manual.py, line 73:

<comment>Positional construction such as `UtcpManual(tools)` now raises `TypeError` before validation, unlike the previous constructor. Keep the explicit parameters and add `**data` after them so extra keys remain supported.</comment>

<file context>
@@ -55,14 +64,16 @@ def tool2():
 
-    def __init__(self, tools: List[Tool], manual_version: str = "1.0.0", utcp_version: str = __version__):
-        super().__init__(utcp_version=utcp_version, manual_version=manual_version, tools=tools)
+    def __init__(self, **data):
         """Initializes the UtcpManual, ensuring plugins are loaded."""
         ensure_plugins_initialized()
</file context>
Suggested change
def __init__(self, **data):
"""Initializes the UtcpManual, ensuring plugins are loaded."""
ensure_plugins_initialized()
super().__init__(**data)
def __init__(
self,
tools: List[Tool],
manual_version: str = "1.0.0",
utcp_version: str = __version__,
**data,
):
"""Initializes the UtcpManual, ensuring plugins are loaded."""
ensure_plugins_initialized()
super().__init__(
utcp_version=utcp_version,
manual_version=manual_version,
tools=tools,
**data,
)


@staticmethod
def create_from_decorators(manual_version: str = "1.0.0", exclude: Optional[List[str]] = None) -> "UtcpManual":
Expand Down Expand Up @@ -103,7 +114,20 @@ def serialize_tools(self, tools: List[Tool]) -> List[dict]:
@field_validator("tools", mode="before")
@classmethod
def validate_tools(cls, tools: List[Union[Tool, dict]]) -> List[Tool]:
return [v if isinstance(v, Tool) else ToolSerializer().validate_dict(v) for v in tools]
validated: List[Tool] = []
for v in tools:
if isinstance(v, Tool):
validated.append(v)
continue
try:
validated.append(ToolSerializer().validate_dict(v))
except UtcpUnknownCallTemplateTypeError as e:
logger.warning(
"Skipping tool '%s' in manual: %s The rest of the manual is unaffected.",
v.get("name", "<unnamed>") if isinstance(v, dict) else "<unnamed>",
e,
)
return validated


class UtcpManualSerializer(Serializer[UtcpManual]):
Expand Down
4 changes: 3 additions & 1 deletion core/src/utcp/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from utcp.exceptions.utcp_variable_not_found_exception import UtcpVariableNotFound
from utcp.exceptions.utcp_serializer_validation_error import UtcpSerializerValidationError
from utcp.exceptions.utcp_unknown_call_template_type_error import UtcpUnknownCallTemplateTypeError

__all__ = [
"UtcpVariableNotFound",
"UtcpSerializerValidationError"
"UtcpSerializerValidationError",
"UtcpUnknownCallTemplateTypeError"
]
17 changes: 17 additions & 0 deletions core/src/utcp/exceptions/utcp_unknown_call_template_type_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class UtcpUnknownCallTemplateTypeError(Exception):
"""REQUIRED
Exception raised when a call template names a type this client has no serializer for.

Distinct from UtcpSerializerValidationError so that a manual loader can skip the
one unloadable tool and keep the rest, instead of failing the whole manual.

Attributes:
call_template_type: The unregistered `call_template_type` value.
"""

def __init__(self, call_template_type: str):
self.call_template_type = call_template_type
super().__init__(
f"Unknown call template type: '{call_template_type}'. Install the plugin that"
" registers it, or the tool will be skipped."
)
85 changes: 85 additions & 0 deletions core/tests/data/test_manual_forward_compatibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""A manual stays usable when it names things this client does not know."""

import logging

import pytest

from utcp.data.call_template import CallTemplate, CallTemplateSerializer
from utcp.data.utcp_manual import UtcpManualSerializer
from utcp.exceptions import UtcpSerializerValidationError


class _MockCallTemplate(CallTemplate):
call_template_type: str = "mock"


class _MockCallTemplateSerializer:
def to_dict(self, obj):
return obj.model_dump()

def validate_dict(self, obj):
return _MockCallTemplate.model_validate(obj)


@pytest.fixture(autouse=True)
def register_mock_protocol():
CallTemplateSerializer.call_template_serializers["mock"] = _MockCallTemplateSerializer()
yield
CallTemplateSerializer.call_template_serializers.pop("mock", None)


def _tool(name: str, call_template: dict) -> dict:
return {
"name": name,
"description": "",
"inputs": {"type": "object"},
"tool_call_template": call_template,
}


def test_unknown_call_template_type_skips_only_that_tool(caplog):
manual_dict = {
"utcp_version": "1.0.1",
"manual_version": "1.0.0",
"tools": [
_tool("known", {"call_template_type": "mock"}),
_tool("future", {"call_template_type": "quantum_teleport", "qubits": 4}),
],
}

with caplog.at_level(logging.WARNING):
manual = UtcpManualSerializer().validate_dict(manual_dict)

assert [t.name for t in manual.tools] == ["known"]
assert "future" in caplog.text
assert "quantum_teleport" in caplog.text


def test_unknown_keys_are_kept_and_round_trip():
manual_dict = {
"utcp_version": "1.0.1",
"manual_version": "1.0.0",
"info": {"title": "Weather API", "version": "1.0.0"},
"tools": [
_tool("known", {"call_template_type": "mock", "x-acme-retry": {"attempts": 3}}),
],
}

manual = UtcpManualSerializer().validate_dict(manual_dict)

assert manual.model_extra["info"] == {"title": "Weather API", "version": "1.0.0"}
assert manual.tools[0].tool_call_template.model_extra["x-acme-retry"] == {"attempts": 3}

as_dict = UtcpManualSerializer().to_dict(manual)
assert as_dict["info"] == {"title": "Weather API", "version": "1.0.0"}
Comment on lines +73 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: test_unknown_keys_are_kept_and_round_trip verifies the info key round-trips through to_dict, but for x-acme-retry it only checks model_extra on the loaded object and never asserts it is re-emitted by UtcpManualSerializer.to_dict. The PR claims x-acme-retry is 'loaded, kept, and re-emitted', so a regression in call-template extra-key re-serialization would pass this test undetected. Assert the re-emission (e.g. as_dict["tools"][0]["tool_call_template"]["x-acme-retry"] == {"attempts": 3}) alongside the info check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At core/tests/data/test_manual_forward_compatibility.py, line 74:

<comment>test_unknown_keys_are_kept_and_round_trip verifies the `info` key round-trips through `to_dict`, but for `x-acme-retry` it only checks `model_extra` on the loaded object and never asserts it is re-emitted by `UtcpManualSerializer.to_dict`. The PR claims x-acme-retry is 'loaded, kept, and re-emitted', so a regression in call-template extra-key re-serialization would pass this test undetected. Assert the re-emission (e.g. `as_dict["tools"][0]["tool_call_template"]["x-acme-retry"] == {"attempts": 3}`) alongside the `info` check.</comment>

<file context>
@@ -0,0 +1,85 @@
+    assert manual.tools[0].tool_call_template.model_extra["x-acme-retry"] == {"attempts": 3}
+
+    as_dict = UtcpManualSerializer().to_dict(manual)
+    assert as_dict["info"] == {"title": "Weather API", "version": "1.0.0"}
+
+
</file context>



def test_malformed_call_template_still_fails_loudly():
manual_dict = {
"utcp_version": "1.0.1",
"manual_version": "1.0.0",
"tools": [_tool("broken", {"url": "https://example.com"})],
}

with pytest.raises(UtcpSerializerValidationError):
UtcpManualSerializer().validate_dict(manual_dict)