Repository navigation
Description
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 eitherget_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.
Activity
amasen02 commentedon Sep 4, 2026
Excellent breakdown of the PEP 585 subscripting quirk. In Python 3.9+, builtin generics like
list["Inner"]ordict[str, "Inner"]store raw string literals in__args__rather thantyping.ForwardRefinstances, bypassing standardtyping.get_type_hints()resolution unless explicitly resolved.Root Cause Flow
get_args(list["Inner"])yields("Inner",)as astr.KernelJsonSchemaBuilder.handle_complex_typerecursively invokescls.build("Inner", ...).isinstance(parameter_type, str)is true,build()diverts tobuild_from_type_name("Inner"). Since"Inner"is not in the primitive mapping (int,str,float,bool), it hits the fallback and returns a bare{"type": "object"}without generating property schemas.Recommended Resolution
In
handle_complex_type(and withinbuild()when processing complex container arguments), unresolved strings inget_argsshould be evaluated against the parent module's globals before recursion:In
build_model_schema, you already extractmodel_module = getattr(model, "__module__", None). Propagatingmodel_moduleintohandle_complex_typeallows evaluating the string arguments prior tocls.build():This ensures
list["Inner"]resolves tolist[Inner]before schema generation, restoring full property metadata in the tool definitions sent to the LLM.ErenAta16 commentedon Sep 4, 2026
Ama Senevirathne (@amasen02) your chain is right, and I ran it rather than agreeing on paper. There
is one thing it does not cover, and it changes what a fix has to do.
The
get_argshalf is exactly as you describe, and the two spellings diverge:So PEP 585 keeps the annotation as a raw
strand hits theisinstance(parameter_type, str)branch atkernel_json_schema_builder.py:51, whiletyping.Listwraps it in aForwardRefand never reaches that branch at all.ForwardRefdoes not appearanywhere in that file.
The part worth knowing is that both are broken, and the output is identical, so
you cannot tell them apart from the schema. Run against the tree at
13e22ae6:The last row is the control: with the real class rather than a string, the
properties come through, so the loss is entirely the forward reference.
Two consequences for whoever writes the patch. Resolving only the
strcasefixes
list["Inner"]and leavesList["Inner"]exactly as broken as it is now,because that one arrives as a
ForwardRef. And a regression test written onlyagainst the PEP 585 spelling would pass while the
typingspelling stillsilently drops the properties, since today they produce byte-identical schemas
and there is nothing in the output to distinguish a fixed path from an unfixed
one. Both spellings need pinning, and
dict/Dictalongside them.Worth saying that the containing class is the piece neither branch has: resolving
"Inner"needs a namespace to resolve it in, which is whattyping.get_type_hintsnormally supplies and what is lost by the timebuild()sees a bare string or a bareForwardRef. That is the real designquestion on this issue, and it is a larger one than the branch that reads the
argument.