feat(core): skip tools with unknown call template types instead of failing the manual - #99
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The new tests don’t fully assert the documented round-trip behavior (missing x- re-emit assertion), and CallTemplateSerializer.validate_dict should defensively handle non-dict inputs to avoid downgraded/less-specific error reporting.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR improves UTCP manual forward-compatibility by allowing manuals to load even when they contain tools whose call_template_type isn’t registered in the current client, while preserving and round-tripping unknown extension keys (info, x-...) via Pydantic extra="allow".
Changes:
- Introduces
UtcpUnknownCallTemplateTypeErrorand wires it through tool/call-template validation so unknown protocol types can be distinguished from malformed templates. - Updates
UtcpManualloading to validate tools one-by-one and skip unknown call-template types with aWARNINGrather than failing the entire manual. - Adds tests covering skip behavior, preservation of unknown keys, and continued loud failure for malformed templates.
File summaries
| File | Description |
|---|---|
| core/src/utcp/data/call_template.py | Adds extra="allow" and raises a distinct error for unregistered call_template_type. |
| core/src/utcp/data/tool.py | Re-raises UtcpUnknownCallTemplateTypeError so manual loading can detect/skip unknown protocols. |
| core/src/utcp/data/utcp_manual.py | Allows extra keys and skips unknown call-template types while warning. |
| core/src/utcp/exceptions/utcp_unknown_call_template_type_error.py | Defines new exception type used to signal “unknown protocol/plugin missing”. |
| core/src/utcp/exceptions/init.py | Exports the new exception from the exceptions package. |
| core/tests/data/test_manual_forward_compatibility.py | Adds coverage for skipping unknown types, preserving extras, and loud failure for malformed templates. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| 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"]) |
| as_dict = UtcpManualSerializer().to_dict(manual) | ||
| assert as_dict["info"] == {"title": "Weather API", "version": "1.0.0"} |
There was a problem hiding this comment.
4 issues found across 6 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="core/src/utcp/data/call_template.py">
<violation number="1" location="core/src/utcp/data/call_template.py:114">
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.</violation>
<violation number="2" location="core/src/utcp/data/call_template.py:116">
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.</violation>
</file>
<file name="core/src/utcp/data/utcp_manual.py">
<violation number="1" location="core/src/utcp/data/utcp_manual.py:73">
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.</violation>
</file>
<file name="core/tests/data/test_manual_forward_compatibility.py">
<violation number="1" location="core/tests/data/test_manual_forward_compatibility.py:74">
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.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| """ | ||
| 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"]) |
There was a problem hiding this comment.
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>
| 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) |
| def __init__(self, **data): | ||
| """Initializes the UtcpManual, ensuring plugins are loaded.""" | ||
| ensure_plugins_initialized() | ||
| super().__init__(**data) |
There was a problem hiding this comment.
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>
| 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, | |
| ) |
| registered for it. Callers that load a whole manual skip the tool. | ||
| UtcpSerializerValidationError: The template is malformed. | ||
| """ | ||
| if "call_template_type" not in obj: |
There was a problem hiding this comment.
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>
| 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: |
| 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"} |
There was a problem hiding this comment.
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>
Implements the forward-compatibility rules proposed in
universal-tool-calling-protocol/utcp-specification#64
Behaviour change, measured on this branch against
devat89a9832:httptool + one tool of an unregistered typeWARNINGnaming tool and typeinfokey fromdocs/implementation.md's own exampleTypeErrorinfokept inmodel_extraand re-emitted byto_dictx-acme-retryon anhttpcall templatecall_template_typeUtcpSerializerValidationError) — unchangedFour changes:
UtcpUnknownCallTemplateTypeError(new, inutcp.exceptions). A distincttype is what makes the skip possible: the manual loader must tell "this
client has no plugin for that protocol" apart from "this template is
malformed", and only skip the first. It derives from
Exception, notValueError, so pydantic propagates it through the nestedToolvalidatorrather than folding it into a
ValidationError.CallTemplateSerializer.validate_dictraises it for an unregisteredtype. The lookup moved out of the
tryblock: previously aKeyErrorfromthe serializer's own body was misreported as an invalid type, and the
except KeyErrorhandler itself raisedKeyErrorwhencall_template_typewas absent. A missing key is now its own expliciterror.
UtcpManualgainsextra="allow"and loads tools one at a time, warningpast the unknown ones. Its
__init__now takes**data; the previousexplicit signature was the actual reason
infowas rejected — pydanticpassed the extra key to
__init__, which had no parameter for it.CallTemplategainsextra="allow"for the same round-trip reason.ToolSerializer.validate_dictre-raises the new error instead ofwrapping it, so the manual loader can see it.
Tests:
core/tests/data/test_manual_forward_compatibility.py, three cases —the skip (asserting both the surviving tool and the warning), the round trip of
infoandx-, and that a malformed template still fails loudly.Suite:
core40 passed (37 before + 3 new). Plugin suiteshttp/text/file/cli: 226 passed, 5 skipped, 61 errors — byte-identical tothe same run on the unmodified tree; those errors are pre-existing
pytest-asynciofixture-setup failures in this environment, untouched by thischange.
What a reviewer will push back on, and the answer
"Skipping hides provider mistakes." It does not: every skip emits a
WARNINGnaming the tool and the type, and a malformed template of a knowntype still raises. Only the case a client provably cannot handle — a protocol it
has no plugin for — is downgraded from fatal to skipped.
"
extra='allow'weakens validation." It weakens it exactly where the specchange says it should be weak, and nowhere else. Every declared field keeps its
type. The alternative,
extra='ignore', would load the manual but silentlydelete
x-data on rewrite, which is the round-trip failure this PR is fixing."This is a breaking change." For callers, no:
UtcpManual(**kwargs)acceptseverything it accepted before, and the new exception type is not raised anywhere
a caller previously caught something specific — the old code raised a bare
ValueErrorwrapped inUtcpSerializerValidationError, which nothing in-treecatches by type. For manual authors, it only widens what loads.
🤖 Generated with Claude Code
https://claude.ai/code/session_01H9PKfvS16TzzLUHi3xPUV4
Summary by cubic
Makes manual loading forward-compatible by skipping tools with unregistered call template types instead of failing the entire manual. Unknown keys like
infoandx-extensions are now preserved through load/store round trips.UtcpUnknownCallTemplateTypeErrorso the loader can tell "no plugin for this protocol" apart from "malformed template".WARNINGnaming the tool and its unregistered type.UtcpSerializerValidationErroras before.UtcpManualandCallTemplatenow allow extra keys, keepinginfoandx-data intact on rewrite.Written for commit 2f8fde2. Summary will update on new commits.