Skip to content

Python: resolve forward references nested in generic aliases when building schemas - #14310

Open
om singhal (Om-singhaI) wants to merge 3 commits into
microsoft:mainfrom
Om-singhaI:fix/schema-builder-nested-forward-refs
Open

Python: resolve forward references nested in generic aliases when building schemas#14310
om singhal (Om-singhaI) wants to merge 3 commits into
microsoft:mainfrom
Om-singhaI:fix/schema-builder-nested-forward-refs

Conversation

@Om-singhaI

@Om-singhaI om singhal (Om-singhaI) commented Aug 22, 2026

Copy link
Copy Markdown

Motivation and Context

Fixes #14239

A string forward reference inside a generic alias, such as list["Inner"] or dict[str, "Inner"], is never resolved to the class it names. The schema built for it is 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 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:

entry point 3.10 3.11 / 3.12 / 3.13
@kernel_function parameter items: list["Inner"] broken broken
KernelBaseModel field items: list["Inner"] broken works

Why the decorator path is broken on every version. kernel_function_decorator._process_signature reads annotations from inspect.signature(func, eval_str=True). eval_str evaluates an annotation that is itself a string, but not a string nested inside an alias that already exists as an object. type_object therefore reaches KernelJsonSchemaBuilder.build as list['Inner'] with the bare str still in __args__. handle_complex_type then calls build("Inner"), which takes the isinstance(parameter_type, str) branch and falls through build_from_type_name to the placeholder. Reproduced identically on 3.10 and 3.14:

class P:
    @kernel_function(name="fwd")
    def fwd(self, items: list["Inner"]) -> str: ...

KernelFunction.from_method(P().fwd).metadata.parameters[0].schema_data
# before: {"type": "array", "items": {"type": "object"}}
# after:  {"type": "array", "items": {"type": "object", "properties": {"value": ..., "label": ...}, "required": [...]}}

Why the model field path is 3.10 only. build_model_schema goes through get_type_hints, which on 3.10 evaluates ForwardRef objects nested inside an alias but not bare strings. list.__class_getitem__ stores "Inner" verbatim with no ForwardRef wrapper:

# Python 3.10
get_type_hints(HolderFwd)["items"]   # list['Inner']   the str survives
# Python 3.11 and later
get_type_hints(HolderFwd)["items"]   # list[Inner]     resolved

Python 3.11 changed get_type_hints to wrap those strings itself (CPython issue 85542, listed in the 3.11 What's New under typing). 3.10 is still inside requires-python = ">=3.10" and is a first class leg of the python-unit-tests.yml matrix, so the placeholder is what 3.10 users ship today.

Description

  • KernelJsonSchemaBuilder.resolve_forward_refs(annotation, globalns), new and public. Resolves str and ForwardRef arguments against a namespace and walks nested aliases, so list[list["Inner"]] and dict[str, "Inner"] resolve too. Literal members and Annotated metadata 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_schema applies it against the model's module globals, which are already fetched a few lines above for the get_type_hints call. On 3.11 and later the hints arrive resolved and the resolver returns them unchanged, so it is a no op there.
  • _process_signature gains an optional globalns. The decorator passes func.__globals__, the same namespace eval_str=True already evaluates against, and each parameter's type_object is 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 bare list type_object today 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: TreeNode and Author/Book reach their cycles through list[...], so the string never resolves to a class and the cycle detection never engages.

Testing plan

# Python 3.10.6
$ pytest tests/unit/schema/test_schema_builder.py tests/unit/functions/test_kernel_function_decorators.py
79 passed          (baseline before this change: 36 + 31 = 67)

# Python 3.14.3
$ pytest tests/unit/schema/test_schema_builder.py
40 passed, 1 skipped

New tests and what each one proves:

test fails without the fix on
test_build_model_schema_resolves_forward_ref_in_list 3.10
test_build_model_schema_resolves_forward_ref_in_dict 3.10
test_build_model_schema_resolves_nested_forward_ref 3.10
test_kernel_function_resolves_forward_ref_in_list_parameter all versions
test_process_signature_resolves_forward_refs_with_globalns (list, dict, Annotated, | None) all versions
test_process_signature_without_globalns_leaves_forward_refs_unchanged none (back compat guard)
test_build_model_schema_resolves_forward_ref_inside_optional none (pins that Optional keeps working)
test_resolve_forward_refs_leaves_literal_values_and_annotated_metadata_alone none (guards Literal["X"] where X is also a global name)
test_build_model_schema_unresolvable_forward_ref_falls_back 3.10 only, skipif >= 3.11 where get_type_hints raises NameError before the builder sees the name

Verified by stashing the source change: the three model field tests fail with the {"type": "object"} placeholder, and the decorator tests fail with list['InputObject'] left unresolved.

tests/unit/functions plus tests/unit/schema produce 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 pristine main too.

One thing worth flagging: test_kernel_function_decorators.py does not collect on Python 3.14 on pristine main either (TypeError: 'member_descriptor' object is not iterable in _parse_parameter for any X | None annotation, since 3.14 unified Union and UnionType and param.__origin__ now yields the Union class). 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"] and Annotated[list["InputObject"], ...] all resolve; the | None case hits the pre existing crash before this change runs.

Lint and types: ruff check passes with the repo pinned ruff==0.15.17, ruff format --diff reports all four files already formatted, and mypy --config-file mypy.ini reports no errors in either changed source file (the four it reports are in openapi_runner.py, kernel_filters_extension.py and kernel_function.py, all pre existing).

Contribution Checklist

…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
@Om-singhaI
om singhal (Om-singhaI) requested a review from a team as a code owner August 22, 2026 03:28
Copilot AI lite review requested due to automatic review settings August 22, 2026 03:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 a globalns and 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 treats globalns=None as the opt-out, this should check is not None instead 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.

Comment thread python/semantic_kernel/schema/kernel_json_schema_builder.py
Comment thread python/semantic_kernel/functions/kernel_function_decorator.py
…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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread python/semantic_kernel/schema/kernel_json_schema_builder.py Outdated
… 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.
@Om-singhaI

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants