diff --git a/pyagentspec/src/pyagentspec/serialization/pydanticdeserializationplugin.py b/pyagentspec/src/pyagentspec/serialization/pydanticdeserializationplugin.py index dcc340c1..0be28ced 100644 --- a/pyagentspec/src/pyagentspec/serialization/pydanticdeserializationplugin.py +++ b/pyagentspec/src/pyagentspec/serialization/pydanticdeserializationplugin.py @@ -9,7 +9,7 @@ from typing import Any, Dict, List, Mapping, Tuple, Type, cast from pydantic import BaseModel, ValidationError -from pydantic_core import InitErrorDetails +from pydantic_core import InitErrorDetails, PydanticCustomError from pyagentspec.component import Component from pyagentspec.serialization.deserializationcontext import DeserializationContext @@ -55,20 +55,35 @@ def deserialize( deserialization_context=deserialization_context, ) if len(validation_errors) > 0: - line_errors = [ - InitErrorDetails( - type=e.type, - loc=e.loc, - input=(), - ) - for e in validation_errors - ] raise ValidationError.from_exception_data( title=component.__class__.__name__, - line_errors=line_errors, + line_errors=[self._to_line_error(e) for e in validation_errors], ) return cast(Component, component) + @staticmethod + def _to_line_error(e: PyAgentSpecErrorDetails) -> InitErrorDetails: + """Rebuild a ``pydantic_core`` line error that surfaces the real cause. + + A bare ``InitErrorDetails(type=e.type, ...)`` makes + ``ValidationError.from_exception_data`` re-derive each builtin type's + required ctx (``value_error`` needs ``{"error": }``; ``gt`` needs + ``gt``; …). The previous code supplied none, so a collected + ``value_error`` — any component ``model_validator`` that raises + ``ValueError`` — made ``from_exception_data`` itself raise + ``TypeError: 'error' required in context``, MASKING the real failure. + + A ``PydanticCustomError`` carries the message directly (rendered + verbatim — no ctx, no template interpolation), so it reconstructs any + collected error, of any type, without the per-type ctx dance and + preserves both the original ``type`` and ``msg``. + """ + return InitErrorDetails( + type=PydanticCustomError(e.type, e.msg), + loc=e.loc, + input=(), + ) + def _partial_deserialize( self, serialized_component: Dict[str, Any], deserialization_context: DeserializationContext ) -> Tuple[Component, List[PyAgentSpecErrorDetails]]: diff --git a/pyagentspec/tests/serialization/test_deserialization_error_surfacing.py b/pyagentspec/tests/serialization/test_deserialization_error_surfacing.py new file mode 100644 index 00000000..2f309006 --- /dev/null +++ b/pyagentspec/tests/serialization/test_deserialization_error_surfacing.py @@ -0,0 +1,92 @@ +# Copyright © 2025 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""The Pydantic deserialization plugin must SURFACE the collected validation +errors, not mask them. + +Regression: collected errors were rebuilt into ``pydantic_core.InitErrorDetails`` +with no ``ctx``. ``ValidationError.from_exception_data`` requires +``ctx={"error": }`` for ``value_error`` (and other builtins require their own +ctx keys), so a collected ``value_error`` — e.g. any component ``model_validator`` +that raises ``ValueError`` — made ``from_exception_data`` itself raise +``TypeError: 'error' required in context``, hiding the real cause. +""" + +import pytest +from pydantic import ValidationError +from pydantic_core import ValidationError as CoreValidationError + +from pyagentspec.serialization.pydanticdeserializationplugin import ( + PydanticComponentDeserializationPlugin, +) +from pyagentspec.validation_helpers import PyAgentSpecErrorDetails + + +def _raise(line_errors): + """Emulate the plugin's re-raise from a list of collected errors.""" + return CoreValidationError.from_exception_data( + title="SomeComponent", + line_errors=[ + PydanticComponentDeserializationPlugin._to_line_error(e) for e in line_errors + ], + ) + + +def test_value_error_is_surfaced_not_masked() -> None: + """A collected ``value_error`` re-raises cleanly and keeps its message.""" + collected = [ + PyAgentSpecErrorDetails( + type="value_error", + msg="The AgentNode component expected a property titled `evidence_status`.", + loc=("outputs",), + ) + ] + + # Must NOT raise TypeError("'error' required in context") while building. + err = _raise(collected) + + assert isinstance(err, (ValidationError, CoreValidationError)) + [detail] = err.errors() + assert detail["type"] == "value_error" + assert detail["loc"] == ("outputs",) + # The message is rendered verbatim — no added/doubled "Value error, " prefix. + assert detail["msg"] == collected[0].msg + + +def test_original_error_type_is_preserved() -> None: + """The reconstructed error keeps its original ``type`` (not flattened to + ``value_error``) and its message verbatim — never crashes on a ctx key it + would otherwise have to synthesise.""" + collected = [ + PyAgentSpecErrorDetails(type="missing", msg="Field required", loc=("name",)) + ] + + [detail] = _raise(collected).errors() + assert detail["type"] == "missing" + assert detail["msg"] == "Field required" + + +def test_message_with_braces_is_not_interpreted() -> None: + """Messages carrying JSON (literal ``{`` / ``}``) survive verbatim — the + message must not be treated as a format template.""" + msg = 'Invalid schema {"type": "object", "required": true} for {x}' + [detail] = _raise( + [PyAgentSpecErrorDetails(type="value_error", msg=msg, loc=("s",))] + ).errors() + assert detail["msg"] == msg + + +def test_multiple_errors_all_surface() -> None: + collected = [ + PyAgentSpecErrorDetails(type="value_error", msg="first problem", loc=("a",)), + PyAgentSpecErrorDetails(type="missing", msg="second problem", loc=("b",)), + ] + + err = _raise(collected) + + msgs = {d["msg"] for d in err.errors()} + assert msgs == {"first problem", "second problem"} + assert err.error_count() == 2