Python: resolve forward references nested in generic aliases when building schemas - #14310
Conversation
…lding schemas
A string forward reference inside a generic alias, such as list["Inner"] or
dict[str, "Inner"], was never resolved to the class it names. The schema built
for it was the bare {"type": "object"} placeholder with no properties and no
required list. That schema is what the model receives as a function calling
parameter definition, so a plugin written this way handed the model an untyped
blob for that argument without any error or warning. Optional["Inner"] and a
top level "Inner" already worked, which made the gap easy to miss.
Two entry points were affected.
Parameters of a kernel_function, on every supported Python version. The
decorator reads annotations through inspect.signature with eval_str=True, which
evaluates an annotation that is itself a string but leaves a string nested
inside an alias untouched. type_object therefore reached
KernelJsonSchemaBuilder.build as list['Inner'] with the bare string still in
__args__.
Fields of a KernelBaseModel, on Python 3.10 only. build_model_schema relies on
get_type_hints, which on 3.10 evaluates ForwardRef objects nested in an alias
but not bare strings, and list.__class_getitem__ stores "Inner" verbatim with
no ForwardRef wrapper. Python 3.11 changed get_type_hints to wrap those strings
itself (CPython issue 85542), so this path already works on 3.11 and later.
3.10 remains in the supported range and in the CI matrix.
KernelJsonSchemaBuilder.resolve_forward_refs resolves string and ForwardRef
arguments against a namespace and walks nested aliases. Literal members and
Annotated metadata are left alone because those strings are values rather than
names. build_model_schema applies it against the model's module globals, which
were already fetched for the get_type_hints call, and the decorator applies it
to each parameter's type_object against the function's own __globals__, the
same namespace eval_str already uses. A name that cannot be resolved is left as
it is, so the previous placeholder behaviour is kept instead of raising. On
3.11 and later the model field path receives resolved hints and the resolver
returns them unchanged.
Fixes microsoft#14239
There was a problem hiding this comment.
Pull request overview
This PR fixes a schema-generation gap in the Python SDK where string forward references nested inside generic aliases (e.g., list["Inner"], dict[str, "Inner"]) were not being resolved, causing JSON schema placeholders ({"type":"object"}) to be emitted for nested model types. The change introduces a forward-ref resolver used both in KernelJsonSchemaBuilder.build_model_schema and in the @kernel_function decorator signature parsing, plus unit tests covering the previously broken cases (notably on Python 3.10).
Changes:
- Add
KernelJsonSchemaBuilder.resolve_forward_refs(...)and apply it during model schema building. - Extend
_process_signature(...)to accept aglobalnsand resolve nested forward refs for decorator-parsed parameter types. - Add unit tests for list/dict/nested/optional forward refs and decorator signature parsing behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| python/semantic_kernel/schema/kernel_json_schema_builder.py | Adds forward-ref resolution for nested generic aliases during schema building. |
| python/semantic_kernel/functions/kernel_function_decorator.py | Passes globals into signature processing and resolves nested forward refs in parameter annotations. |
| python/tests/unit/schema/test_schema_builder.py | Adds regression tests for forward refs in list/dict/nested/optional model fields (esp. Py3.10). |
| python/tests/unit/functions/test_kernel_function_decorators.py | Adds regression tests for decorator parsing of nested forward refs (including Annotated and ` |
Suppressed comments (1)
python/semantic_kernel/functions/kernel_function_decorator.py:139
if globalns:will skip resolution if an explicit empty dict is passed. Since the function signature treatsglobalns=Noneas the opt-out, this should checkis not Noneinstead so callers can intentionally pass an empty namespace and still get deterministic behavior.
if globalns:
underlying_type = KernelJsonSchemaBuilder.resolve_forward_refs(underlying_type, globalns)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ence resolution _process_signature used a truthiness check on globalns, so a caller passing an empty dict was silently skipped even though None is the documented way to opt out. Checking for None keeps the two cases distinct and the behaviour predictable. Suggested in review.
There was a problem hiding this comment.
MAF Automated Review — Iteration 1
Result: Findings reported
Scope: full PR (1 commit(s)): 943e551d8c11
Model: claude-opus-4.8
Overview
The PR delivers its stated fix: string forward references nested inside PEP 585 generic aliases
(list["Inner"], dict[str, "Inner"], and nested/Optional forms) now resolve to their target
types instead of falling through to the untyped {"type": "object"} placeholder, both in the
``@kernel_function decorator path (all Python versions) and in `build_model_schema` (Python 3.10).
The resolution is a name lookup (`dict.get`), not an `eval`, and is well guarded for `Literal`,
`Annotated`, unresolvable names, and non-rebuildable aliases, with matching tests. The one residual
risk is that resolution substitutes whatever a colliding global names — including modules,
functions, and plain values — without confirming it is a type, which converts the previous graceful
placeholder into a crash (or a silently wrong schema) at function registration. This behavior is
Python-version-sensitive by design (a no-op on 3.11+ for the model-field path, active everywhere for
the decorator path).
Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
1 verified finding remained after source verification (1 medium) across 1 file. Details are attached to the affected lines below.
Affected areas: python/semantic_kernel/schema/kernel_json_schema_builder.py
… a type A nested reference whose name happened to match a module, a function or a plain value in the namespace was rebuilt around that object. A module then reached build_model_schema and failed on __module__ at decoration time, and a function or value produced a misleading schema. Before this change the same annotation degraded to the placeholder, so this was a regression in failure behaviour. The lookup now substitutes only when the value is a class or a typing construct and otherwise keeps the original reference, which restores the placeholder fallback. Raised in review.
|
@microsoft-github-policy-service agree |
Motivation and Context
Fixes #14239
A string forward reference inside a generic alias, such as
list["Inner"]ordict[str, "Inner"], is never resolved to the class it names. The schema built for it is the bare{"type": "object"}placeholder with nopropertiesand norequiredlist. That schema is what the model receives as a function calling parameter definition, so a plugin written this way hands the model an untyped blob for that argument without any error or warning.Optional["Inner"]and a top level"Inner"already work, which makes the gap easy to miss.Two entry points are affected, and they differ by Python version:
@kernel_functionparameteritems: list["Inner"]KernelBaseModelfielditems: list["Inner"]Why the decorator path is broken on every version.
kernel_function_decorator._process_signaturereads annotations frominspect.signature(func, eval_str=True).eval_strevaluates an annotation that is itself a string, but not a string nested inside an alias that already exists as an object.type_objecttherefore reachesKernelJsonSchemaBuilder.buildaslist['Inner']with the barestrstill in__args__.handle_complex_typethen callsbuild("Inner"), which takes theisinstance(parameter_type, str)branch and falls throughbuild_from_type_nameto the placeholder. Reproduced identically on 3.10 and 3.14:Why the model field path is 3.10 only.
build_model_schemagoes throughget_type_hints, which on 3.10 evaluatesForwardRefobjects nested inside an alias but not bare strings.list.__class_getitem__stores"Inner"verbatim with noForwardRefwrapper:Python 3.11 changed
get_type_hintsto wrap those strings itself (CPython issue 85542, listed in the 3.11 What's New under typing). 3.10 is still insiderequires-python = ">=3.10"and is a first class leg of thepython-unit-tests.ymlmatrix, so the placeholder is what 3.10 users ship today.Description
KernelJsonSchemaBuilder.resolve_forward_refs(annotation, globalns), new and public. ResolvesstrandForwardRefarguments against a namespace and walks nested aliases, solist[list["Inner"]]anddict[str, "Inner"]resolve too.Literalmembers andAnnotatedmetadata are left alone, since those strings are values rather than names. A name that cannot be resolved is left as it is, keeping the previous placeholder rather than raising.build_model_schemaapplies it against the model's module globals, which are already fetched a few lines above for theget_type_hintscall. On 3.11 and later the hints arrive resolved and the resolver returns them unchanged, so it is a no op there._process_signaturegains an optionalglobalns. The decorator passesfunc.__globals__, the same namespaceeval_str=Truealready evaluates against, and each parameter'stype_objectis resolved before it is stored. Callers that pass nothing get the previous behaviour; the existing_process_signature(func_sig)tests are untouched.Return type annotations are deliberately out of scope.
-> list[Inner]already collapses to a barelisttype_objecttoday regardless of forward references, which is a separate limitation.Relationship to #14198. Separate from the recursion work there, which is about cycles; this fires on a plain
list["Inner"]with no recursion at all. As the issue notes, it is also why two of the new tests in #14198 fail on 3.10:TreeNodeandAuthor/Bookreach their cycles throughlist[...], so the string never resolves to a class and the cycle detection never engages.Testing plan
New tests and what each one proves:
test_build_model_schema_resolves_forward_ref_in_listtest_build_model_schema_resolves_forward_ref_in_dicttest_build_model_schema_resolves_nested_forward_reftest_kernel_function_resolves_forward_ref_in_list_parametertest_process_signature_resolves_forward_refs_with_globalns(list, dict, Annotated,| None)test_process_signature_without_globalns_leaves_forward_refs_unchangedtest_build_model_schema_resolves_forward_ref_inside_optionalOptionalkeeps working)test_resolve_forward_refs_leaves_literal_values_and_annotated_metadata_aloneLiteral["X"]whereXis also a global name)test_build_model_schema_unresolvable_forward_ref_falls_backskipif >= 3.11whereget_type_hintsraisesNameErrorbefore the builder sees the nameVerified by stashing the source change: the three model field tests fail with the
{"type": "object"}placeholder, and the decorator tests fail withlist['InputObject']left unresolved.tests/unit/functionsplustests/unit/schemaproduce a byte identical failure set with and without this change on 3.10. The failures present in that run are environment gaps in my local setup (async plugins needing extras) and exist on pristinemaintoo.One thing worth flagging:
test_kernel_function_decorators.pydoes not collect on Python 3.14 on pristinemaineither (TypeError: 'member_descriptor' object is not iterablein_parse_parameterfor anyX | Noneannotation, since 3.14 unifiedUnionandUnionTypeandparam.__origin__now yields theUnionclass). That is pre existing and outside the CI matrix, so I replicated the new decorator cases on 3.14 in isolation instead:list["InputObject"],dict[str, "InputObject"]andAnnotated[list["InputObject"], ...]all resolve; the| Nonecase hits the pre existing crash before this change runs.Lint and types:
ruff checkpasses with the repo pinnedruff==0.15.17,ruff format --diffreports all four files already formatted, andmypy --config-file mypy.inireports no errors in either changed source file (the four it reports are inopenapi_runner.py,kernel_filters_extension.pyandkernel_function.py, all pre existing).Contribution Checklist