Repository navigation
Python: resolve string forward references nested inside list/dict/tuple annotations - #14241
ErenAta16 (ErenAta16) wants to merge 7 commits 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.
The nested string in list["Inner"] is stored verbatim, so anything it contains reaches the resolver's eval. Parse it first and require the grammar of a type expression - names, attributes, subscripts and X | Y unions - so a call, lambda or comprehension is refused and the annotation falls back to the existing untyped-object behaviour.
|
Both points are fair. The On the eval surface. It executes, confirmed: Emptying What the resolver actually needs is narrower than an expression evaluator: a forward reference names a type. So the string is now parsed first and only the grammar of a type expression is admitted —
with the canary never firing. Two tests cover it: one asserts On the kernel-function parameter path. That's right — One note on the test suite: |
|
Same note as on #14242, so a reviewer does not spend time twice. #14268 fixes the same No claim on which patch is better. If #14268 is preferred, tell me and I will close this one. The only thing worth avoiding is both being reviewed as if they were independent. |
Eduard van Valkenburg (eavanvalkenburg)
left a comment
There was a problem hiding this comment.
Review finding (warning): Forward-reference resolution substitutes non-type globals
The evaluated value is always substituted into the rebuilt alias. If a reference name collides with a module, helper function, or plain global value, downstream schema generation can emit the wrong schema or raise instead of using the previous object fallback. Please only substitute values that are types or supported typing constructs; otherwise leave the original string or ForwardRef unchanged.
A nested string reference was evaluated against the model's module
globals and the result substituted unconditionally. When the name
belongs to a module, a function or a plain value, build() then receives
that object: a module raises AttributeError, a list raises TypeError as
an unhashable mapping key, a function is rendered as a model of its
parameters, and a string value is read as another type name.
Substitute the evaluated value only when it is a class or a typing
construct recognized by get_origin. Anything else leaves the annotation
as written, so the existing {"type": "object"} fallback applies, as it
does on main.
The schema-level test is skipped from Python 3.11 on, because there
get_type_hints evaluates strings nested in list[...] itself and the
builder never sees them.
From 3.11 on, get_type_hints evaluates a string nested in list[...] itself, so list["DoesNotExistAnywhere"] raises NameError inside build_model_schema before the resolver is reached. That is how main behaves for the same annotation on those versions, and the test fails on this branch without the previous commit too. The fallback it pins only exists on 3.10, so it uses the same skip as the other 3.10-only test.
|
Reproduced on Python 3.10 and fixed in e5b2d38. Measured with plain classes, so pydantic does not resolve anything at class creation. Each row is the schema
The evaluated value is now substituted only when it is a class or a typing construct that I also need to correct two things I said earlier. First, the bug this PR fixes only exists on Python 3.10. From 3.11 on, Second, in my earlier comment I blamed pydantic for the This also means the problem you describe is already on main for 3.11+, regardless of this PR. On those versions Not blocking: On CI: every failing check in the last run stopped at the Related, since #14198 has the same finding open as a blocker: its two list-based cycle tests, |
The pre-commit job runs bandit with --all-files against python/semantic_kernel, and the eval() call in _resolve_nested_forward_refs is the only finding on this branch (B307, medium). main is bandit-clean, so the Python Code Quality Checks job fails as soon as it runs on this PR. _is_type_expression already limits a reference to names, attribute access, subscription, | unions, tuples, lists and simple literals. Those nodes are now evaluated directly, with names looked up in the module globals and then in builtins, the same order eval uses. That is the same approach as _SafeFilterEvaluator in connectors/in_memory.py. A suppression comment would also silence bandit, but it would leave eval in place where the guard already gives everything needed to avoid it. Behaviour is unchanged: a parametrized test checks that every admitted expression evaluates to the same object eval returns.
|
One more commit, 63e0178. Once the workflows on this PR are approved to run, the pre-commit job would fail: it runs bandit over |
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