Skip to content

feat(core): skip tools with unknown call template types instead of failing the manual - #99

Open
nightscape wants to merge 1 commit into
universal-tool-calling-protocol:devfrom
nightscape:feature/forward-compatible-manual-loading
Open

feat(core): skip tools with unknown call template types instead of failing the manual#99
nightscape wants to merge 1 commit into
universal-tool-calling-protocol:devfrom
nightscape:feature/forward-compatible-manual-loading

Conversation

@nightscape

@nightscape nightscape commented Sep 3, 2026

Copy link
Copy Markdown

Implements the forward-compatibility rules proposed in
universal-tool-calling-protocol/utcp-specification#64

Behaviour change, measured on this branch against dev at 89a9832:

Manual content Before After
one http tool + one tool of an unregistered type 0 tools, raises 1 tool, one WARNING naming tool and type
the info key from docs/implementation.md's own example raises TypeError loads; info kept in model_extra and re-emitted by to_dict
x-acme-retry on an http call template loads, key dropped loads, key kept and re-emitted
call template with no call_template_type raises raises (UtcpSerializerValidationError) — unchanged

Four changes:

  1. UtcpUnknownCallTemplateTypeError (new, in utcp.exceptions). A distinct
    type 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, not
    ValueError, so pydantic propagates it through the nested Tool validator
    rather than folding it into a ValidationError.
  2. CallTemplateSerializer.validate_dict raises it for an unregistered
    type. The lookup moved out of the try block: previously a KeyError from
    the serializer's own body was misreported as an invalid type, and the
    except KeyError handler itself raised KeyError when
    call_template_type was absent. A missing key is now its own explicit
    error.
  3. UtcpManual gains extra="allow" and loads tools one at a time, warning
    past the unknown ones. Its __init__ now takes **data; the previous
    explicit signature was the actual reason info was rejected — pydantic
    passed the extra key to __init__, which had no parameter for it.
    CallTemplate gains extra="allow" for the same round-trip reason.
  4. ToolSerializer.validate_dict re-raises the new error instead of
    wrapping 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
info and x-, and that a malformed template still fails loudly.

Suite: core 40 passed (37 before + 3 new). Plugin suites
http/text/file/cli: 226 passed, 5 skipped, 61 errors — byte-identical to
the same run on the unmodified tree; those errors are pre-existing
pytest-asyncio fixture-setup failures in this environment, untouched by this
change.

What a reviewer will push back on, and the answer

"Skipping hides provider mistakes." It does not: every skip emits a
WARNING naming the tool and the type, and a malformed template of a known
type 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 spec
change says it should be weak, and nowhere else. Every declared field keeps its
type. The alternative, extra='ignore', would load the manual but silently
delete x- data on rewrite, which is the round-trip failure this PR is fixing.

"This is a breaking change." For callers, no: UtcpManual(**kwargs) accepts
everything it accepted before, and the new exception type is not raised anywhere
a caller previously caught something specific — the old code raised a bare
ValueError wrapped in UtcpSerializerValidationError, which nothing in-tree
catches 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 info and x- extensions are now preserved through load/store round trips.

  • Adds UtcpUnknownCallTemplateTypeError so the loader can tell "no plugin for this protocol" apart from "malformed template".
  • Each skipped tool logs a WARNING naming the tool and its unregistered type.
  • Malformed templates of known types still raise UtcpSerializerValidationError as before.
  • UtcpManual and CallTemplate now allow extra keys, keeping info and x- data intact on rewrite.

Written for commit 2f8fde2. Summary will update on new commits.

Review in cubic

Copilot AI left a comment

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.

🟡 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 UtcpUnknownCallTemplateTypeError and wires it through tool/call-template validation so unknown protocol types can be distinguished from malformed templates.
  • Updates UtcpManual loading to validate tools one-by-one and skip unknown call-template types with a WARNING rather 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.

Comment on lines +114 to +118
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"])
Comment on lines +73 to +74
as_dict = UtcpManualSerializer().to_dict(manual)
assert as_dict["info"] == {"title": "Weather API", "version": "1.0.0"}

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

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"])

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)

Comment on lines +73 to +76
def __init__(self, **data):
"""Initializes the UtcpManual, ensuring plugins are loaded."""
ensure_plugins_initialized()
super().__init__(**data)

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,
)

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:

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"}

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>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants