From 63fc2e7588294b346daef9dcb216273541399b9f Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Fri, 17 Apr 2026 00:06:02 +0800 Subject: [PATCH] fix: complete recursive MCP schema handling resolve_refs now returns type-preserving stubs instead of {} for circular $refs, and create_model_from_schema catches JsonRefError to fall back to lazy top-level-only inlining. --- .../crewai/utilities/pydantic_schema_utils.py | 30 ++++- .../utilities/test_pydantic_schema_utils.py | 107 ++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py b/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py index 180181fa7..a45c1635a 100644 --- a/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py +++ b/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py @@ -120,7 +120,11 @@ def resolve_refs(schema: dict[str, Any]) -> dict[str, Any]: if def_name not in defs: raise KeyError(f"Definition '{def_name}' not found in $defs.") if def_name in expanding: - return {} + def_schema = defs[def_name] + stub: dict[str, Any] = {"type": def_schema.get("type", "object")} + if "description" in def_schema: + stub["description"] = def_schema["description"] + return stub expanding.add(def_name) try: return _resolve(deepcopy(defs[def_name])) @@ -759,6 +763,25 @@ def build_rich_field_description(prop_schema: dict[str, Any]) -> str: return ". ".join(parts) if parts else "" +def _inline_top_level_ref(schema: dict[str, Any]) -> dict[str, Any]: + """Resolve only the top-level ``$ref``, preserving ``$defs`` for lazy inner resolution. + + Used as a fallback when ``jsonref.replace_refs`` fails on circular schemas. + Inner ``$ref`` pointers are left intact so that :func:`_resolve_ref` can + resolve them during model construction, with cycle detection via ``in_progress``. + """ + schema = deepcopy(schema) + ref = schema.get("$ref") + if isinstance(ref, str) and ref.startswith("#/$defs/"): + def_name = ref[len("#/$defs/") :] + defs = schema.get("$defs", {}) + if def_name in defs: + resolved: dict[str, Any] = deepcopy(defs[def_name]) + resolved.setdefault("$defs", defs) + return resolved + return schema + + def create_model_from_schema( # type: ignore[no-any-unimported] json_schema: dict[str, Any], *, @@ -813,7 +836,10 @@ def create_model_from_schema( # type: ignore[no-any-unimported] >>> person.name 'John' """ - json_schema = dict(jsonref.replace_refs(json_schema, proxies=False)) + try: + json_schema = dict(jsonref.replace_refs(json_schema, proxies=False)) + except (jsonref.JsonRefError, RecursionError): + json_schema = _inline_top_level_ref(json_schema) effective_root = root_schema or json_schema diff --git a/lib/crewai/tests/utilities/test_pydantic_schema_utils.py b/lib/crewai/tests/utilities/test_pydantic_schema_utils.py index 98a5e6aa5..70a900c7f 100644 --- a/lib/crewai/tests/utilities/test_pydantic_schema_utils.py +++ b/lib/crewai/tests/utilities/test_pydantic_schema_utils.py @@ -882,3 +882,110 @@ class TestEndToEndMCPSchema: ) assert obj.filters.date_from == datetime.date(2025, 1, 1) assert obj.filters.categories == ["news", "tech"] + + +# --------------------------------------------------------------------------- +# Recursive / circular $ref schemas (GH-5490) +# --------------------------------------------------------------------------- + +RECURSIVE_NODE_SCHEMA: dict = { + "$defs": { + "Node": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "children": { + "type": "array", + "items": {"$ref": "#/$defs/Node"}, + }, + }, + "required": ["name"], + } + }, + "$ref": "#/$defs/Node", +} + +MUTUAL_RECURSION_SCHEMA: dict = { + "$defs": { + "A": { + "type": "object", + "properties": { + "val": {"type": "string"}, + "b": {"$ref": "#/$defs/B"}, + }, + "required": ["val"], + }, + "B": { + "type": "object", + "properties": { + "val": {"type": "integer"}, + "a": {"$ref": "#/$defs/A"}, + }, + "required": ["val"], + }, + }, + "$ref": "#/$defs/A", +} + + +class TestResolveRefsRecursive: + def test_circular_ref_preserves_type(self) -> None: + from crewai.utilities.pydantic_schema_utils import resolve_refs + + resolved = resolve_refs(deepcopy(RECURSIVE_NODE_SCHEMA)) + items = resolved["properties"]["children"]["items"] + assert items != {}, "Circular ref should not degrade to {}" + assert items.get("type") == "object" + + def test_non_recursive_schema_still_resolves(self) -> None: + from crewai.utilities.pydantic_schema_utils import resolve_refs + + schema = { + "$defs": {"Foo": {"type": "object", "properties": {"x": {"type": "integer"}}}}, + "$ref": "#/$defs/Foo", + } + resolved = resolve_refs(schema) + assert resolved["properties"]["x"]["type"] == "integer" + + +class TestSanitizeRecursiveSchemas: + def test_anthropic_strict_preserves_recursive_type(self) -> None: + from crewai.utilities.pydantic_schema_utils import sanitize_tool_params_for_anthropic_strict + + san = sanitize_tool_params_for_anthropic_strict(deepcopy(RECURSIVE_NODE_SCHEMA)) + items = san["properties"]["children"]["items"] + assert items != {} + assert items.get("type") == "object" + + def test_openai_strict_preserves_recursive_type(self) -> None: + from crewai.utilities.pydantic_schema_utils import sanitize_tool_params_for_openai_strict + + san = sanitize_tool_params_for_openai_strict(deepcopy(RECURSIVE_NODE_SCHEMA)) + items = san["properties"]["children"]["items"] + assert items != {} + assert items.get("type") == "object" + + +class TestCreateModelFromSchemaRecursive: + def test_model_creation_succeeds(self) -> None: + model = create_model_from_schema(deepcopy(RECURSIVE_NODE_SCHEMA), model_name="Node") + assert model is not None + assert model.__name__ == "Node" + + def test_model_accepts_valid_recursive_data(self) -> None: + model = create_model_from_schema(deepcopy(RECURSIVE_NODE_SCHEMA), model_name="Node") + instance = model(name="root", children=[{"name": "child", "children": []}]) + assert instance.name == "root" + assert len(instance.children) == 1 + + def test_model_rejects_missing_required_field(self) -> None: + import pytest + + model = create_model_from_schema(deepcopy(RECURSIVE_NODE_SCHEMA), model_name="Node") + with pytest.raises(Exception): + model(children=[]) + + def test_mutual_recursion_schema(self) -> None: + model = create_model_from_schema(deepcopy(MUTUAL_RECURSION_SCHEMA), model_name="A") + instance = model(val="hello", b={"val": 42}) + assert instance.val == "hello"