Skip to content

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

Description

@ErenAta16

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.

Activity

  1. amasen02 commented on Sep 4, 2026

    @amasen02

    Excellent breakdown of the PEP 585 subscripting quirk. In Python 3.9+, builtin generics like list["Inner"] or dict[str, "Inner"] store raw string literals in __args__ rather than typing.ForwardRef instances, bypassing standard typing.get_type_hints() resolution unless explicitly resolved.

    Root Cause Flow

    1. get_args(list["Inner"]) yields ("Inner",) as a str.
    2. KernelJsonSchemaBuilder.handle_complex_type recursively invokes cls.build("Inner", ...).
    3. Because isinstance(parameter_type, str) is true, build() diverts to build_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 within build() when processing complex container arguments), unresolved strings in get_args should be evaluated against the parent module's globals before recursion:

    import typing
    
    def _resolve_forward_ref(type_arg: Any, module_name: str | None) -> type:
        if isinstance(type_arg, str):
            type_arg = typing.ForwardRef(type_arg)
        if isinstance(type_arg, typing.ForwardRef):
            globalns = sys.modules[module_name].__dict__ if module_name and module_name in sys.modules else {}
            try:
                # Python 3.9+ eval_type
                return typing._eval_type(type_arg, globalns, None)
            except Exception:
                return type_arg
        return type_arg

    In build_model_schema, you already extract model_module = getattr(model, "__module__", None). Propagating model_module into handle_complex_type allows evaluating the string arguments prior to cls.build():

    # In handle_complex_type:
    sub_types = [
        _resolve_forward_ref(arg, module_name)
        for arg in typing.get_args(parameter_type)
    ]

    This ensures list["Inner"] resolves to list[Inner] before schema generation, restoring full property metadata in the tool definitions sent to the LLM.

  2. ErenAta16 commented on Sep 4, 2026

    @ErenAta16
    Author

    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_args half is exactly as you describe, and the two spellings diverge:

    list["Inner"]        -> ('Inner',)              str
    dict[str, "Inner"]   -> (str, 'Inner')          type, str
    List["Inner"]        -> (ForwardRef('Inner'),)  ForwardRef
    Dict[str, "Inner"]   -> (str, ForwardRef('Inner'))
    

    So PEP 585 keeps the annotation as a raw str and hits the
    isinstance(parameter_type, str) branch at
    kernel_json_schema_builder.py:51, while typing.List wraps it in a
    ForwardRef and never reaches that branch at all. ForwardRef does not appear
    anywhere 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:

    list["Inner"]        {"type": "array",  "items": {"type": "object"}}
    List["Inner"]        {"type": "array",  "items": {"type": "object"}}
    dict[str, "Inner"]   {"type": "object", "additionalProperties": {"type": "object", "properties": {}}}
    Dict[str, "Inner"]   {"type": "object", "additionalProperties": {"type": "object", "properties": {}}}
    list[Inner]          {"type": "array",  "items": {"type": "object", "properties": {"a": {"type": "integer"}}, ...}}
    

    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 str case
    fixes list["Inner"] and leaves List["Inner"] exactly as broken as it is now,
    because that one arrives as a ForwardRef. And a regression test written only
    against the PEP 585 spelling would pass while the typing spelling still
    silently 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/Dict alongside them.

    Worth saying that the containing class is the piece neither branch has: resolving
    "Inner" needs a namespace to resolve it in, which is what
    typing.get_type_hints normally supplies and what is lost by the time
    build() sees a bare string or a bare ForwardRef. That is the real design
    question on this issue, and it is a larger one than the branch that reads the
    argument.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions