diff --git a/python/semantic_kernel/schema/kernel_json_schema_builder.py b/python/semantic_kernel/schema/kernel_json_schema_builder.py index 5ec519b5b377..809f76fb82c5 100644 --- a/python/semantic_kernel/schema/kernel_json_schema_builder.py +++ b/python/semantic_kernel/schema/kernel_json_schema_builder.py @@ -86,6 +86,7 @@ def build_model_schema( hints = get_type_hints(model, globalns=model_module_globals, localns={}) for field_name, field_type in hints.items(): + field_type = cls._resolve_nested_forward_refs(field_type, model_module_globals) field_description = None if hasattr(model, "model_fields") and field_name in model.model_fields: field_info = model.model_fields[field_name] @@ -150,6 +151,59 @@ def get_json_schema(cls, parameter_type: type) -> dict[str, Any]: type_name = TYPE_MAPPING.get(parameter_type, "object") return {"type": type_name} + @classmethod + def _resolve_nested_forward_refs(cls, annotation: Any, globalns: dict[str, Any]) -> Any: + """Resolve string forward references nested inside a generic alias. + + `get_type_hints` evaluates an annotation that *is* a string, but it does not descend into + a generic alias that already exists as an object. `list["Inner"]` goes through + `list.__class_getitem__`, which stores `"Inner"` verbatim instead of wrapping it in a + `ForwardRef`, so nothing resolves it and `build` formats the bare string rather than the + class it names. + + Args: + annotation: The annotation to resolve, typically a generic alias. + globalns: The globals of the module the owning model was defined in. + + Returns: + Any: The annotation with resolvable string arguments replaced by the types they name, + or the original annotation when nothing could be resolved. + """ + args = get_args(annotation) + if not args: + return annotation + + resolved_args = [] + changed = False + for arg in args: + reference = arg if isinstance(arg, str) else getattr(arg, "__forward_arg__", None) + if reference is not None: + try: + resolved = eval(reference, globalns, {}) + except Exception: + # Not resolvable from this module; leave the annotation alone so the existing + # fallback applies instead of raising while a schema is being built. + return annotation + changed = True + else: + resolved = cls._resolve_nested_forward_refs(arg, globalns) + changed = changed or resolved is not arg + resolved_args.append(resolved) + + if not changed: + return annotation + + copy_with = getattr(annotation, "copy_with", None) + if copy_with is not None: + return copy_with(tuple(resolved_args)) + origin = get_origin(annotation) + if origin is None: + return annotation + try: + return origin[tuple(resolved_args)] + except TypeError: + return annotation + @classmethod def handle_complex_type( cls, parameter_type: type, description: str | None = None, structured_output: bool = False diff --git a/python/tests/unit/schema/test_schema_builder.py b/python/tests/unit/schema/test_schema_builder.py index 5d24a599c96c..ed38e55ad5cb 100644 --- a/python/tests/unit/schema/test_schema_builder.py +++ b/python/tests/unit/schema/test_schema_builder.py @@ -455,3 +455,67 @@ def test_build_schema_with_nonpydantic_structured_output(): } assert structured_output_schema == expected_schema + + +class InnerForward(KernelBaseModel): + value: int + label: str + + +class HolderForwardList(KernelBaseModel): + items: list["InnerForward"] = [] + + +class HolderDirectList(KernelBaseModel): + items: list[InnerForward] = [] + + +class HolderForwardDict(KernelBaseModel): + mapping: dict[str, "InnerForward"] = {} + + +class HolderForwardOptional(KernelBaseModel): + maybe: Optional["InnerForward"] = None + + +def test_build_list_with_string_forward_reference_matches_direct_reference(): + """`list["Inner"]` and `list[Inner]` must produce the same schema.""" + forward = KernelJsonSchemaBuilder.build(HolderForwardList) + direct = KernelJsonSchemaBuilder.build(HolderDirectList) + + assert forward == direct + assert forward["properties"]["items"]["items"]["properties"] == { + "value": {"type": "integer"}, + "label": {"type": "string"}, + } + + +def test_build_dict_with_string_forward_reference(): + schema = KernelJsonSchemaBuilder.build(HolderForwardDict) + + assert schema["properties"]["mapping"]["additionalProperties"]["properties"] == { + "value": {"type": "integer"}, + "label": {"type": "string"}, + } + + +def test_build_optional_with_string_forward_reference_still_works(): + """`Optional["Inner"]` already resolved via `get_type_hints`; make sure it still does.""" + schema = KernelJsonSchemaBuilder.build(HolderForwardOptional) + + assert schema["properties"]["maybe"]["properties"] == { + "value": {"type": "integer"}, + "label": {"type": "string"}, + } + + +def test_unresolvable_forward_reference_falls_back_instead_of_raising(): + """An annotation naming something that doesn't exist must not break schema building.""" + + class HolderUnknown(KernelBaseModel): + items: list["DoesNotExistAnywhere"] = [] # noqa: F821 + + schema = KernelJsonSchemaBuilder.build(HolderUnknown) + + assert schema["properties"]["items"]["type"] == "array" + assert schema["properties"]["items"]["items"] == {"type": "object"}