Python: Fix RecursionError in KernelJsonSchemaBuilder for self-referential/cyclic models (#6464) - #14198
Python: Fix RecursionError in KernelJsonSchemaBuilder for self-referential/cyclic models (#6464)#14198grvnmttl wants to merge 2 commits into
Conversation
dotnet-ci: Windows job was globbing *Tests.csproj which included integration and conformance tests requiring secrets not present in the fork. Changed to *UnitTests.csproj to match the Docker job. python-integration-tests: The scheduled run matrix included windows-latest and macos-latest, but the job defines Docker service containers (Redis, Weaviate) that only work on Linux runners. Restricted os matrix to ubuntu-latest only. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ntial/cyclic models KernelJsonSchemaBuilder.build_model_schema carried a TODO claiming forward references were untested (microsoft#6464). Plain forward references already resolve correctly; the real bug is that a self-referential or mutually-recursive KernelBaseModel (e.g. a tree node, or two models referencing each other) causes infinite recursion and crashes with RecursionError. This affects any tool/function-calling or structured-output schema path that includes such a model (OpenAIResponsesAgent, OpenAIAssistantAgent, AzureAIInferenceChatCompletion, the OpenAI structured-output path, KernelParameterMetadata). Detects when a model is already being built further up the call stack and emits a JSON Schema $defs/$ref pair instead of recursing again. Only activates for models actually part of a cycle, so non-recursive models keep today's fully inlined schema output. Closes microsoft#6464. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes infinite recursion in the Python KernelJsonSchemaBuilder when building JSON Schemas for self-referential or mutually-recursive KernelBaseModel types by emitting $ref/$defs for cyclic references, and adds unit tests covering forward refs and cycles. It also updates CI workflows related to Python integration tests and .NET test project discovery.
Changes:
- Add cycle detection to
KernelJsonSchemaBuilderso recursive models produce$refinto$defsinstead of recursing untilRecursionError. - Fix nullable-handling for
Optional[T]whenTresolves to a$refschema (wrap withanyOfinstead of assuming a"type"key). - Add unit tests for forward references, self-references, mutual recursion, list-based recursion, and
structured_output=Truebehavior; adjust CI workflow matrices/discovery.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
python/semantic_kernel/schema/kernel_json_schema_builder.py |
Introduces recursion tracking and $ref/$defs emission for cyclic KernelBaseModel schemas, plus nullable $ref handling. |
python/tests/unit/schema/test_schema_builder.py |
Adds new test models and assertions covering forward refs and cyclic schemas (including structured output constraints). |
.github/workflows/python-integration-tests.yml |
Restricts scheduled Python integration-test OS matrix to Ubuntu (aligning with service-container support). |
.github/workflows/dotnet-ci.yml |
Changes how .NET test projects are discovered (pattern update), but needs quoting fix to avoid breaking discovery. |
Comments suppressed due to low confidence (1)
python/tests/unit/schema/test_schema_builder.py:380
- Avoid using a mutable default (
[]) for a Pydantic/KernalBaseModel field; it can be shared across instances if the model is ever instantiated. The default value isn’t needed for these schema-builder tests.
class Author(KernelBaseModel):
name: str
books: list["Book"] = []
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| - name: Find test projects | ||
| shell: bash | ||
| run: echo "testprojects=$(find ./dotnet -type f -name "*Tests.csproj" | tr '\n' ' ')" >> $GITHUB_ENV | ||
| run: echo "testprojects=$(find ./dotnet -type f -name "*UnitTests.csproj" | tr '\n' ' ')" >> $GITHUB_ENV | ||
|
|
| if model_type in _ref_needed: | ||
| _defs[model_name] = schema | ||
| if is_root: | ||
| # The root of the document is the cycle's entry point: return it expanded | ||
| # (with $defs attached) rather than as a dangling, unresolvable $ref. | ||
| return {**schema, "$defs": _defs} | ||
| return {"$ref": f"#/$defs/{model_name}"} |
| class TreeNode(KernelBaseModel): | ||
| value: int | ||
| children: list["TreeNode"] = [] | ||
|
|
ErenAta16
left a comment
There was a problem hiding this comment.
Two of the three new tests fail on a clean checkout with only the Python part of this branch applied:
FAILED tests/unit/schema/test_schema_builder.py::test_build_with_self_referential_model_via_list
FAILED tests/unit/schema/test_schema_builder.py::test_build_with_mutually_referential_models
Optional["Category"] works, list["TreeNode"] doesn't:
| model | cycle via | $defs |
$ref |
children/books items |
|---|---|---|---|---|
Category |
Optional["Category"] |
yes | yes | — |
TreeNode |
list["TreeNode"] |
no | no | {"type": "object"} |
Author |
list["Book"] |
no | no | {"type": "object"} |
The string never gets resolved, so build() never sees a class and the cycle machinery never engages:
TreeNode.__annotations__["children"] -> list['TreeNode']
get_type_hints(TreeNode)["children"] -> list['TreeNode'] # unchanged
get_args(...) -> ('TreeNode',) # a str, not a type
TreeNode.model_fields["children"].annotation -> list['TreeNode'] # pydantic doesn't eitherget_type_hints evaluates an annotation that is a string; it does not descend into a generic that already exists as an object and evaluate strings sitting in its __args__. list["TreeNode"] goes through list.__class_getitem__, which stores "TreeNode" verbatim — no ForwardRef wrapper, nothing for get_type_hints to hook.
So handle_complex_type calls cls.build("TreeNode", ...), that hits the isinstance(parameter_type, str) branch at the top of build, and build_from_type_name has no entry for TreeNode:
KernelJsonSchemaBuilder.build("TreeNode") # -> {'type': 'object'}{"type": "object"} also isn't distinguishable from a legitimately-unknown type downstream, which is why it fails silently rather than raising.
Optional["Category"] behaves differently because typing.Optional[...] converts the string to a real ForwardRef, and get_type_hints does resolve those — hence the one passing test.
Resolving the args explicitly against the model's module globals before recursing would cover both, e.g. typing.ForwardRef(arg)._evaluate(model_module_globals, {}, frozenset()) for str args in handle_complex_type, or pydantic.TypeAdapter. The _defs / _in_progress / _ref_needed plumbing itself is fine — Category proves it works once a real class reaches build_model_schema.
Also flagging that the branch carries two unrelated CI changes: dotnet-ci.yml narrowing *Tests.csproj to *UnitTests.csproj, and python-integration-tests.yml dropping windows-latest / macos-latest from the matrix.
Ran on main @ 7d885dd with python/semantic_kernel/schema/kernel_json_schema_builder.py and python/tests/unit/schema/test_schema_builder.py from this PR applied, Python 3.10.12.
Motivation and Context
KernelJsonSchemaBuilder.build_model_schemacarried a TODO claiming forward references were untested (#6464). Reproducing it showed plain forward references already work; the actual, previously-undiscovered bug is that a self-referential or mutually-recursiveKernelBaseModelcauses infinite recursion and crashes withRecursionError. This affects any tool/function-calling or structured-output schema path that includes such a model —OpenAIResponsesAgent,OpenAIAssistantAgent,AzureAIInferenceChatCompletion, the OpenAI structured-output path, andKernelParameterMetadata.Closes #6464.
Description
{"$ref": "#/$defs/<Name>"}instead of recursing.Optional[T]nullable-handling assumed a"type"key that a$refresult doesn't have.tests/unit/schema/test_schema_builder.py(simple forward ref, self-reference, mutual cycle, list-based cycle,structured_output=Truecycle).Contribution Checklist
🤖 Generated with Claude Code