Skip to content

Python: resolve string forward references nested inside list/dict/tuple annotations - #14241

Open
ErenAta16 wants to merge 1 commit into
microsoft:mainfrom
ErenAta16:fix-nested-forward-refs
Open

Python: resolve string forward references nested inside list/dict/tuple annotations#14241
ErenAta16 wants to merge 1 commit into
microsoft:mainfrom
ErenAta16:fix-nested-forward-refs

Conversation

@ErenAta16

Copy link
Copy Markdown

Motivation and Context

KernelJsonSchemaBuilder 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; list[Inner] produces the full schema. That output is the function-calling parameter definition sent to the model, so a plugin written in the forward-reference style hands the model an untyped blob for that argument, with no error anywhere.

class Inner(KernelBaseModel):
    value: int
    label: str

class HolderDirect(KernelBaseModel):
    items: list[Inner] = []

class HolderFwd(KernelBaseModel):
    items: list["Inner"] = []
model items schema
HolderDirect {"type": "array", "items": {"type": "object", "properties": {"value": ..., "label": ...}, "required": [...]}}
HolderFwd {"type": "array", "items": {"type": "object"}}
dict[str, "Inner"] additionalProperties: {"type": "object", "properties": {}}
Optional["Inner"] full schema — works

Why: get_type_hints evaluates an annotation that is a string, but it does not descend into a generic alias that already exists as an object.

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 either

list["Inner"] goes through list.__class_getitem__, which stores "Inner" verbatim — no ForwardRef wrapper, so there is nothing for get_type_hints to resolve. handle_complex_type then calls cls.build("Inner", ...), which takes the isinstance(parameter_type, str) branch at the top of build; build_from_type_name has no entry for "Inner" and returns the {"type": "object"} fallback.

Optional["Inner"] behaves differently because typing.Optional[...] converts the string into a real ForwardRef, and get_type_hints does resolve those. That asymmetry — one forward-reference spelling working and the other not — is what makes this easy to miss.

Fixes #14239. Also removes the reason for the TODO at kernel_json_schema_builder.py:80 ("add support for handling forward references, which is not currently tested", issue #6464), though I left the comment alone.

Description

_resolve_nested_forward_refs walks a generic alias and evaluates any str or ForwardRef argument against the owning model's module globals, then rebuilds the alias. It's called once per field in build_model_schema, where model_module_globals is already fetched for the get_type_hints call directly above.

It recurses, so list[dict[str, "Inner"]] resolves. A name that doesn't evaluate leaves the annotation untouched and the existing {"type": "object"} fallback applies — schema building never raises because of this.

How did you test it?

Four tests in python/tests/unit/schema/test_schema_builder.py:

  • test_build_list_with_string_forward_reference_matches_direct_reference — asserts the two schemas are equal, so it can't pass by both being empty.
  • test_build_dict_with_string_forward_reference
  • test_build_optional_with_string_forward_reference_still_works — the path that already worked, pinned so this change can't regress it.
  • test_unresolvable_forward_reference_falls_back_instead_of_raising

Stashing only kernel_json_schema_builder.py gives 2 failures (the list and dict tests); the Optional and unresolvable ones pass on both sides, which is the point. With the change, tests/unit/schema is 40 passed. ruff check and ruff format --check clean.

Notes for the reviewer

The eval is against vars(sys.modules[model.__module__]), the same namespace get_type_hints is already given two lines up — no new inputs reach it.

Checklist

…le annotations

KernelJsonSchemaBuilder emits a bare {"type": "object"} for the element type
of list["Inner"] while list[Inner] gets the full schema. That schema is what
goes to the model as a function-calling parameter definition, so a plugin
using the forward-reference style hands the model an untyped blob.

get_type_hints evaluates an annotation that *is* a string, but it does not
descend into a generic alias that already exists as an object. list["Inner"]
goes through list.__class_getitem__, which stores "Inner" verbatim -- no
ForwardRef wrapper, so nothing resolves it. build() then takes the
isinstance(parameter_type, str) branch and build_from_type_name has no entry
for "Inner", returning the {"type": "object"} fallback with no error.

Optional["Inner"] works today because typing.Optional wraps the string in a
real ForwardRef, which get_type_hints does resolve -- that asymmetry is what
makes this easy to miss.

Resolve the args against the owning model's module globals in
build_model_schema, where those globals are already fetched for the
get_type_hints call. An unresolvable name leaves the annotation untouched so
the existing fallback still applies rather than raising mid-build.

Addresses the forward-reference TODO at line 80 (issue microsoft#6464).
Closes microsoft#14239
Copilot AI review requested due to automatic review settings July 29, 2026 19:17
@ErenAta16
ErenAta16 requested a review from a team as a code owner July 29, 2026 19:17

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 Python KernelJsonSchemaBuilder so that string forward references nested inside container annotations (e.g., list["Inner"], dict[str, "Inner"]) are resolved to the referenced types, producing complete JSON schemas rather than falling back to {"type": "object"} for container elements/values.

Changes:

  • Resolve nested str/ForwardRef arguments inside generic aliases during model schema building.
  • Add unit tests covering list/dict nested string forward refs, Optional behavior, and unresolvable reference fallback.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
python/semantic_kernel/schema/kernel_json_schema_builder.py Adds _resolve_nested_forward_refs and applies it per-field in build_model_schema to resolve nested container forward refs.
python/tests/unit/schema/test_schema_builder.py Adds regression tests ensuring nested string forward refs produce the same schema as direct references and remain non-throwing when unresolvable.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +181 to +182
try:
resolved = eval(reference, globalns, {})

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

Automated Code Review

Reviewers: 5 | Confidence: 90%

✓ Correctness

The implementation correctly resolves string forward references in generic aliases (list, dict, tuple). The recursive approach, identity-based change detection, and fail-safe early return are all well-designed. There is one low-severity edge case: Literal annotations containing string values that happen to match module/builtin names (e.g., Literal["list", "dict"]) would be silently "resolved" to types via copy_with, since Literal aliases have that method. Currently this is unobservable because the schema builder doesn't handle Literal types specially anyway (both original and corrupted produce {"type": "object"}), but it could become an issue if Literal support is added later.

✓ Security Reliability

The PR introduces _resolve_nested_forward_refs which uses eval() to resolve string forward references in type annotations. The eval() operates on the same namespace (vars(sys.modules[model.__module__])) and same class of inputs (type annotation strings from source code) as get_type_hints() which already internally uses eval() two lines above at line 86. No new trust boundary is crossed — the strings are developer-written type annotations, not runtime user input. Error handling is robust: unresolvable references fall back gracefully to the existing {"type": "object"} behavior without raising. No security or reliability issues found.

✓ Test Coverage

The test suite is well-structured and covers the primary scenarios (list, dict, Optional forward refs, and unresolvable fallback). The main gap is the absence of a test for nested generics like list[dict[str, "Inner"]], which the PR rationale explicitly claims to support via recursion. The recursive branch at line 189 of the implementation is only trivially exercised (for plain str which has no type args) — true recursive resolution where an inner call actually resolves a forward ref is not tested.

✓ Failure Modes

The PR adds a well-designed _resolve_nested_forward_refs method that conservatively resolves string forward references inside generic aliases. The error handling is sound: any eval failure returns the original annotation unchanged, preserving the existing fallback behavior. The eval operates on the same namespace already used by get_type_hints (line 86), introducing no new attack surface. The recursive structure correctly handles nested generics, and the rebuild logic correctly uses Python's subscript semantics for both typing generics (copy_with) and built-in generics (origin[tuple(args)]). No concrete failure modes identified.

✓ Design Approach

The new helper fixes nested string forward references only when they appear inside model fields, but the same unresolved generic aliases still flow through the function-parameter schema path unchanged. Because the PR rationale explicitly frames this as fixing the function-calling schema sent to the model, I think the current design stops one layer too early and leaves the direct type_object=list["Inner"] case broken.


Automated review by ErenAta16's agents

hints = get_type_hints(model, globalns=model_module_globals, localns={})

for field_name, field_type in hints.items():
field_type = cls._resolve_nested_forward_refs(field_type, model_module_globals)

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.

This only resolves nested forward refs for fields coming from build_model_schema. Direct kernel-function parameters still miss the fix: kernel_function_decorator.py:122-129 stores the raw annotation in type_object, and kernel_parameter_metadata.py:49-50 later calls KernelJsonSchemaBuilder.build(type_object, ...) on that value. For an input like ``@kernel_function def f(items: list["InerForward"]): ..., `build()` goes straight to `handle_complex_type` without ever hitting this line, so the function-calling schema path described in the PR rationale still silently degrades to an untyped object item.

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