Describe the bug
KernelJsonSchemaBuilder silently drops the element schema when a container's element type is written as a string forward reference. list["Inner"] produces {"type": "object"} for the items — no properties, no required — while list[Inner] produces the full schema.
That schema is what gets sent to the model as a function-calling parameter definition, so a plugin using this annotation style hands the model an untyped blob for that argument.
To Reproduce
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.schema.kernel_json_schema_builder import KernelJsonSchemaBuilder as B
class Inner(KernelBaseModel):
value: int
label: str
class HolderDirect(KernelBaseModel):
items: list[Inner] = []
class HolderFwd(KernelBaseModel):
items: list["Inner"] = []
class HolderTop(KernelBaseModel):
one: "Inner"
| model |
items / one schema |
HolderDirect |
{"type": "array", "items": {"type": "object", "properties": {"value": ..., "label": ...}, "required": [...]}} |
HolderFwd |
{"type": "array", "items": {"type": "object"}} |
HolderTop |
full Inner schema |
dict[str, "Inner"] |
additionalProperties: {"type": "object", "properties": {}} |
HolderTop works and HolderFwd doesn't, which is the part that makes this easy to miss — forward references look supported.
Why
HolderFwd.__annotations__["items"] -> list['Inner']
get_type_hints(HolderFwd)["items"] -> list['Inner'] # unchanged
get_args(...) -> ('Inner',) # a str, not a type
HolderFwd.model_fields["items"].annotation -> list['Inner'] # pydantic doesn't resolve it either
get_type_hints evaluates an annotation that is a string. It does not descend into a generic alias that already exists as an object and evaluate strings sitting in its __args__. list["Inner"] goes through list.__class_getitem__, which stores "Inner" verbatim — no ForwardRef wrapper, so there's nothing for get_type_hints to resolve. typing.Optional["Inner"] does wrap the string in a ForwardRef, which is why the top-level and Optional forms work.
handle_complex_type then calls cls.build("Inner", ...), which takes the isinstance(parameter_type, str) branch at the top of build and lands in build_from_type_name. No entry matches "Inner", so it returns the {"type": "object"} fallback:
KernelJsonSchemaBuilder.build("Inner") # -> {'type': 'object'}
Nothing raises, and {"type": "object"} is indistinguishable from a genuinely-unknown type downstream.
Expected behavior
list["Inner"] and list[Inner] produce the same schema.
Resolving str args against the owning model's module globals inside handle_complex_type before recursing would do it — the module globals are already fetched a few lines up in build_model_schema for the get_type_hints call.
Platform
- OS: Linux
- Python: 3.10.12
- Semantic Kernel:
main @ 7d885dd (python)
Additional context
Separate from the recursion work in #14198. That PR is about cycles; this fires on a plain non-recursive list["Inner"]. It's also the reason two of #14198's new tests currently fail — TreeNode and Author/Book reach their cycles through list[...], so the string never resolves to a class and the cycle detection never engages.
Describe the bug
KernelJsonSchemaBuildersilently drops the element schema when a container's element type is written as a string forward reference.list["Inner"]produces{"type": "object"}for the items — noproperties, norequired— whilelist[Inner]produces the full schema.That schema is what gets sent to the model as a function-calling parameter definition, so a plugin using this annotation style hands the model an untyped blob for that argument.
To Reproduce
items/oneschemaHolderDirect{"type": "array", "items": {"type": "object", "properties": {"value": ..., "label": ...}, "required": [...]}}HolderFwd{"type": "array", "items": {"type": "object"}}HolderTopInnerschemadict[str, "Inner"]additionalProperties: {"type": "object", "properties": {}}HolderTopworks andHolderFwddoesn't, which is the part that makes this easy to miss — forward references look supported.Why
get_type_hintsevaluates an annotation that is a string. It does not descend into a generic alias that already exists as an object and evaluate strings sitting in its__args__.list["Inner"]goes throughlist.__class_getitem__, which stores"Inner"verbatim — noForwardRefwrapper, so there's nothing forget_type_hintsto resolve.typing.Optional["Inner"]does wrap the string in aForwardRef, which is why the top-level andOptionalforms work.handle_complex_typethen callscls.build("Inner", ...), which takes theisinstance(parameter_type, str)branch at the top ofbuildand lands inbuild_from_type_name. No entry matches"Inner", so it returns the{"type": "object"}fallback:Nothing raises, and
{"type": "object"}is indistinguishable from a genuinely-unknown type downstream.Expected behavior
list["Inner"]andlist[Inner]produce the same schema.Resolving
strargs against the owning model's module globals insidehandle_complex_typebefore recursing would do it — the module globals are already fetched a few lines up inbuild_model_schemafor theget_type_hintscall.Platform
main@ 7d885dd (python)Additional context
Separate from the recursion work in #14198. That PR is about cycles; this fires on a plain non-recursive
list["Inner"]. It's also the reason two of #14198's new tests currently fail —TreeNodeandAuthor/Bookreach their cycles throughlist[...], so the string never resolves to a class and the cycle detection never engages.