You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Python: KernelJsonSchemaBuilder ignores string forward references inside list[...]/dict[...], emitting a bare {"type": "object"} #14239
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.
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'] # unchangedget_args(...) -> ('Inner',) # a str, not a typeHolderFwd.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:
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.
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.
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
get_args(list["Inner"]) yields ("Inner",) as a str.
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:
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)
forargintyping.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.
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:
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:
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.
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.