From 2f8fde220674d2fcffb9e989f6b7d478556ecbe8 Mon Sep 17 00:00:00 2001 From: UTCP contribution draft Date: Thu, 3 Sep 2026 08:41:13 +0200 Subject: [PATCH] feat(core): skip tools with unknown call template types instead of failing the manual --- core/src/utcp/data/call_template.py | 25 ++++-- core/src/utcp/data/tool.py | 4 +- core/src/utcp/data/utcp_manual.py | 34 ++++++-- core/src/utcp/exceptions/__init__.py | 4 +- .../utcp_unknown_call_template_type_error.py | 17 ++++ .../data/test_manual_forward_compatibility.py | 85 +++++++++++++++++++ 6 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 core/src/utcp/exceptions/utcp_unknown_call_template_type_error.py create mode 100644 core/tests/data/test_manual_forward_compatibility.py diff --git a/core/src/utcp/data/call_template.py b/core/src/utcp/data/call_template.py index 718f560..14737c7 100644 --- a/core/src/utcp/data/call_template.py +++ b/core/src/utcp/data/call_template.py @@ -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 @@ -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 @@ -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: + 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"]) 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 diff --git a/core/src/utcp/data/tool.py b/core/src/utcp/data/tool.py index 73ab5b4..a46ee90 100644 --- a/core/src/utcp/data/tool.py +++ b/core/src/utcp/data/tool.py @@ -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]] @@ -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 diff --git a/core/src/utcp/data/utcp_manual.py b/core/src/utcp/data/utcp_manual.py index 562e1a6..17b69f8 100644 --- a/core/src/utcp/data/utcp_manual.py +++ b/core/src/utcp/data/utcp_manual.py @@ -6,8 +6,9 @@ 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 @@ -15,8 +16,11 @@ 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. @@ -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 @@ -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) @staticmethod def create_from_decorators(manual_version: str = "1.0.0", exclude: Optional[List[str]] = None) -> "UtcpManual": @@ -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", "") if isinstance(v, dict) else "", + e, + ) + return validated class UtcpManualSerializer(Serializer[UtcpManual]): diff --git a/core/src/utcp/exceptions/__init__.py b/core/src/utcp/exceptions/__init__.py index a33c4f6..59a86ec 100644 --- a/core/src/utcp/exceptions/__init__.py +++ b/core/src/utcp/exceptions/__init__.py @@ -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" ] diff --git a/core/src/utcp/exceptions/utcp_unknown_call_template_type_error.py b/core/src/utcp/exceptions/utcp_unknown_call_template_type_error.py new file mode 100644 index 0000000..2764152 --- /dev/null +++ b/core/src/utcp/exceptions/utcp_unknown_call_template_type_error.py @@ -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." + ) diff --git a/core/tests/data/test_manual_forward_compatibility.py b/core/tests/data/test_manual_forward_compatibility.py new file mode 100644 index 0000000..ec9cd96 --- /dev/null +++ b/core/tests/data/test_manual_forward_compatibility.py @@ -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"} + + +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)