Skip to content

fix(templates): avoid RecursionError in get_schema for recursive models - #2051

Open
BlueX888 wants to merge 1 commit into
dottxt-ai:mainfrom
BlueX888:fix/prep-get-schema-recursion-error-on-recursive-model
Open

BlueX888 wants to merge 1 commit into
dottxt-ai:mainfrom
BlueX888:fix/prep-get-schema-recursion-error-on-recursive-model

Conversation

@BlueX888

Copy link
Copy Markdown

Summary

get_schema renders a Pydantic model's schema for prompt examples by recursively following $refs. A field that refers back to an enclosing model (e.g. nxt: Optional["Node"]) has no base case: the anyOf branch resolves the field's $ref, the $ref branch re-enters the same definition, and the walker spins until the interpreter raises a bare RecursionError.

Track the references on the current expansion path and render the existing <name> placeholder when a reference closes a cycle, the same way container-wrapped recursive fields (e.g. Optional[list["Node"]], whose items the walker never enters) already render. Repeated non-recursive references (two fields of the same type) still expand once per field, since only the current path is tracked, not the set of visited definitions.

Why

Root cause: src/outlines/templates.py:327 (on main) — parse_pydantic_schema_value resolves every $ref unconditionally:

elif "$ref" in schema:
    refs = schema["$ref"].split("/")
    return parse_pydantic_schema(definitions[refs[2]], definitions)

For Optional["Node"], Pydantic emits anyOf: [{"$ref": "#/$defs/Node"}, {"type": "null"}], which the anyOf branch (src/outlines/templates.py:341) re-dispatches to the $ref branch, re-entering Node's definition. Each pass adds another simple_schema[name] = parse_pydantic_schema_value(...) frame (line 316) until RecursionError: maximum recursion depth exceeded — with nothing rendered at all.

Recursive models inside containers never hit this because the walker does not descend into items: children: Optional[list["Node"]] renders as "children": "<children>" on unpatched main (verified below). The fix makes the direct reference consistent with that.

Changes

  • src/outlines/templates.py — parse_pydantic_schema and parse_pydantic_schema_value thread a seen_refs tuple of the $refs on the current expansion path (default (), so existing callers are unaffected). When a $ref is already on the path, the field renders the <name> placeholder instead of re-entering the definition. The tuple is accumulated per path, so sibling fields keep their own expansion history.
  • tests/test_templates.py — test_get_schema_recursive_model covers a self-referential Optional["PydanticRecursiveClass"] field (renders {"name": "<name>", "nxt": "<nxt>"} instead of crashing) and a PydanticRepeatedClass with two fields of the same non-recursive type (both still expand), guarding against over-eager cycle detection.

Testing

Python 3.12.14, pydantic 2.13.5. New test on unpatched main (git checkout main -- src/outlines/templates.py):

PYTHONPATH=src python -m pytest tests/test_templates.py::test_get_schema_recursive_model -q -p no:cacheprovider
# E   RecursionError: maximum recursion depth exceeded
# FAILED tests/test_templates.py::test_get_schema_recursive_model - RecursionEr...
# 1 failed in 1.69s

On this branch:

PYTHONPATH=src python -m pytest tests/test_templates.py::test_get_schema_recursive_model -q -p no:cacheprovider
# 1 passed in 0.95s

PYTHONPATH=src python -m pytest tests/test_templates.py -q -p no:cacheprovider
# 18 passed in 0.96s

uvx pre-commit run --all-files
# check for merge conflicts / debug statements / fix end of files /
# trim trailing whitespace / mypy / ruff -> all Passed

Full suite (minus seven tests/models/* files that cannot even be collected in this environment — the anthropic/dottxt/gemini/lmstudio/mistral/ollama optional backends are not installed): identical results on unpatched main and this branch — 96 failed, 80 skipped, 5 errors, all in provider/cache/generator tests that need network access, model weights or missing optional dependencies. The only difference between the two runs is the new test (failed on main, passes here). The placeholder rendering of container-wrapped recursive fields (Optional[list["Node"]] → "children": "<children>") was also verified unchanged on unpatched main.

No open issue covers this exact path — the closed #330 tracks recursive schemas in the JSON-schema-to-regex converter, a different module. Happy to file a tracker issue if maintainers prefer one.

Checklist

  • The PR title describes what the PR does;
  • There is a high-level description of the changes;
  • No new feature — this is a bug fix;
  • Links to all relevant issues: none open for this path; Recursive JSON Schemas #330 is the nearest (recursive schemas, different module, closed);
  • The branch is rebased on the latest main commit (c52af84);
  • Commit messages follow the repository's guidelines; one commit, one logical change;
  • The code respects the current naming conventions;
  • Docstrings follow the numpy style guide;
  • pre-commit was run before opening the PR; all hooks pass;
  • There are tests covering the changes;
  • No documentation changes are required — get_schema is not mentioned in the docs.

A field annotated `Optional["Model"]` makes `get_schema` recurse
forever: the `anyOf` branch resolves the field's `$ref` back into the
model being expanded, until the interpreter raises a bare
RecursionError. Track the references on the current expansion path and
render a `<name>` placeholder when a reference closes a cycle, the same
way container-wrapped recursive fields (e.g. `Optional[list["Model"]]`)
already render. Repeated non-recursive references keep expanding.

This branch has not been deployed

No deployments
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.

Recursive JSON Schemas

1 participant