Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions python/semantic_kernel/schema/kernel_json_schema_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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.

This only resolves nested forward refs for fields coming from build_model_schema. Direct kernel-function parameters still miss the fix: kernel_function_decorator.py:122-129 stores the raw annotation in type_object, and kernel_parameter_metadata.py:49-50 later calls KernelJsonSchemaBuilder.build(type_object, ...) on that value. For an input like ``@kernel_function def f(items: list["InerForward"]): ..., `build()` goes straight to `handle_complex_type` without ever hitting this line, so the function-calling schema path described in the PR rationale still silently degrades to an untyped object item.

field_description = None
if hasattr(model, "model_fields") and field_name in model.model_fields:
field_info = model.model_fields[field_name]
Expand Down Expand Up @@ -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, {})
Comment on lines +181 to +182
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
Expand Down
64 changes: 64 additions & 0 deletions python/tests/unit/schema/test_schema_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Loading