Python: resolve string forward references nested inside list/dict/tuple annotations - #14241
Python: resolve string forward references nested inside list/dict/tuple annotations#14241ErenAta16 wants to merge 1 commit into
Conversation
…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
There was a problem hiding this comment.
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/ForwardRefarguments 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.
| try: | ||
| resolved = eval(reference, globalns, {}) |
There was a problem hiding this comment.
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_refswhich useseval()to resolve string forward references in type annotations. Theeval()operates on the same namespace (vars(sys.modules[model.__module__])) and same class of inputs (type annotation strings from source code) asget_type_hints()which already internally useseval()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 plainstrwhich 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) |
There was a problem hiding this comment.
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.
Motivation and Context
KernelJsonSchemaBuilderdrops 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.itemsschemaHolderDirect{"type": "array", "items": {"type": "object", "properties": {"value": ..., "label": ...}, "required": [...]}}HolderFwd{"type": "array", "items": {"type": "object"}}dict[str, "Inner"]additionalProperties: {"type": "object", "properties": {}}Optional["Inner"]Why:
get_type_hintsevaluates an annotation that is a string, but it does not descend into a generic alias that already exists as an object.list["Inner"]goes throughlist.__class_getitem__, which stores"Inner"verbatim — noForwardRefwrapper, so there is nothing forget_type_hintsto resolve.handle_complex_typethen callscls.build("Inner", ...), which takes theisinstance(parameter_type, str)branch at the top ofbuild;build_from_type_namehas no entry for"Inner"and returns the{"type": "object"}fallback.Optional["Inner"]behaves differently becausetyping.Optional[...]converts the string into a realForwardRef, andget_type_hintsdoes 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_refswalks a generic alias and evaluates anystrorForwardRefargument against the owning model's module globals, then rebuilds the alias. It's called once per field inbuild_model_schema, wheremodel_module_globalsis already fetched for theget_type_hintscall 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_referencetest_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_raisingStashing only
kernel_json_schema_builder.pygives 2 failures (the list and dict tests); theOptionaland unresolvable ones pass on both sides, which is the point. With the change,tests/unit/schemais 40 passed.ruff checkandruff format --checkclean.Notes for the reviewer
The
evalis againstvars(sys.modules[model.__module__]), the same namespaceget_type_hintsis already given two lines up — no new inputs reach it.Checklist