Python: KernelJsonSchemaBuilder ignores string forward references inside list[...]/dict[...], emitting a bare {"type": "object"}

Author: ErenAta16Created Jul 29, 2026Updated Sep 4, 2026

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

python
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

python
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:

python
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.

Source: microsoft/semantic-kernel