-
Notifications
You must be signed in to change notification settings - Fork 47
feat(core): skip tools with unknown call template types instead of failing the manual #99
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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"]) | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When Prompt for AI agents
Suggested change
|
||||||||||||||||
| 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 | ||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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. | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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) | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+73
to
+76
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Positional construction such as Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| @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", "<unnamed>") if isinstance(v, dict) else "<unnamed>", | ||||||||||||||||||||||||||||||||||||||||
| e, | ||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
| return validated | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| class UtcpManualSerializer(Serializer[UtcpManual]): | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| 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" | ||
| ] |
| 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." | ||
| ) |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: test_unknown_keys_are_kept_and_round_trip verifies the Prompt for AI agents |
||
|
|
||
|
|
||
| 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) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Check that
objis a dictionary before evaluating membership. PassingNonecurrently raises a rawTypeError, whichToolSerializerwraps as anInvalid Toolerror instead of the intendedInvalid CallTemplatevalidation error.Prompt for AI agents