diff --git a/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py b/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py index 180181fa7..226d4b244 100644 --- a/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py +++ b/lib/crewai/src/crewai/utilities/pydantic_schema_utils.py @@ -120,7 +120,12 @@ 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 {} + # Preserve the type information instead of degrading to {} + 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 +764,32 @@ 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 a top-level ``$ref`` while preserving ``$defs`` for nested resolution. + + When ``jsonref.replace_refs`` fails on circular schemas, this helper + manually inlines the top-level ``$ref`` so that + :func:`_build_model_from_schema` receives a concrete object schema. + Inner ``$ref`` pointers are left intact — they are resolved lazily by + :func:`_resolve_ref` during model construction, and cycles are caught + by the ``in_progress`` map. + + The returned dict deliberately shares sub-objects with the original + ``$defs`` entries so that ``id()``-based cycle detection works. + """ + 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 = defs[def_name] + # Attach $defs so effective_root can resolve inner $refs. + 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 +844,13 @@ 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: + # Circular $ref schemas cannot be fully inlined by jsonref. + # Manually resolve the top-level $ref (if present) and let + # _build_model_from_schema handle cycles via its in_progress map. + 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..65e96ea03 100644 --- a/lib/crewai/tests/utilities/test_pydantic_schema_utils.py +++ b/lib/crewai/tests/utilities/test_pydantic_schema_utils.py @@ -26,6 +26,8 @@ from crewai.utilities.pydantic_schema_utils import ( ensure_type_in_schemas, force_additional_properties_false, resolve_refs, + sanitize_tool_params_for_anthropic_strict, + sanitize_tool_params_for_openai_strict, strip_null_from_types, strip_unsupported_formats, ) @@ -882,3 +884,145 @@ class TestEndToEndMCPSchema: ) assert obj.filters.date_from == datetime.date(2025, 1, 1) assert obj.filters.categories == ["news", "tech"] + + +# --------------------------------------------------------------------------- +# Recursive / circular $ref schemas (issue #5490) +# --------------------------------------------------------------------------- + +RECURSIVE_NODE_SCHEMA: dict[str, Any] = { + "$defs": { + "Node": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "children": { + "type": "array", + "items": {"$ref": "#/$defs/Node"}, + }, + }, + "required": ["name"], + } + }, + "$ref": "#/$defs/Node", +} + + +class TestResolveRefsRecursive: + """resolve_refs must not silently degrade recursive $refs to {}.""" + + def test_circular_ref_preserves_type(self) -> None: + result = resolve_refs(deepcopy(RECURSIVE_NODE_SCHEMA)) + items = result["properties"]["children"]["items"] + # Before the fix this was {} — now it should carry type info. + assert items != {}, "Circular $ref must not degrade to {}" + assert items.get("type") == "object" + + def test_non_recursive_schema_still_resolves(self) -> None: + schema: dict[str, Any] = { + "$defs": { + "Address": { + "type": "object", + "properties": {"city": {"type": "string"}}, + } + }, + "type": "object", + "properties": { + "home": {"$ref": "#/$defs/Address"}, + }, + } + result = resolve_refs(deepcopy(schema)) + assert result["properties"]["home"]["type"] == "object" + assert "city" in result["properties"]["home"]["properties"] + + +class TestSanitizeRecursiveSchemas: + """Sanitization pipelines must not silently degrade recursive schemas.""" + + def test_anthropic_strict_preserves_recursive_type(self) -> None: + san = sanitize_tool_params_for_anthropic_strict(deepcopy(RECURSIVE_NODE_SCHEMA)) + items = san["properties"]["children"]["items"] + assert items != {}, "Circular $ref must not degrade to {}" + assert items.get("type") == "object" + + def test_openai_strict_preserves_recursive_type(self) -> None: + san = sanitize_tool_params_for_openai_strict(deepcopy(RECURSIVE_NODE_SCHEMA)) + items = san["properties"]["children"]["items"] + assert items != {}, "Circular $ref must not degrade to {}" + assert items.get("type") == "object" + + +class TestCreateModelFromSchemaRecursive: + """create_model_from_schema must handle recursive $ref schemas.""" + + def test_model_creation_succeeds(self) -> None: + Model = create_model_from_schema( + deepcopy(RECURSIVE_NODE_SCHEMA), model_name="NodeModel" + ) + assert Model is not None + assert issubclass(Model, BaseModel) + assert "name" in Model.model_fields + assert "children" in Model.model_fields + + def test_model_accepts_valid_recursive_data(self) -> None: + Model = create_model_from_schema( + deepcopy(RECURSIVE_NODE_SCHEMA), model_name="NodeModel" + ) + obj = Model( + name="root", + children=[ + {"name": "child1", "children": []}, + { + "name": "child2", + "children": [{"name": "grandchild", "children": []}], + }, + ], + ) + assert obj.name == "root" + assert len(obj.children) == 2 + + def test_model_rejects_missing_required_field(self) -> None: + Model = create_model_from_schema( + deepcopy(RECURSIVE_NODE_SCHEMA), model_name="NodeModel" + ) + with pytest.raises(Exception): + Model(children=[]) # "name" is required + + def test_model_with_enrich_descriptions(self) -> None: + Model = create_model_from_schema( + deepcopy(RECURSIVE_NODE_SCHEMA), + model_name="NodeModel", + enrich_descriptions=True, + ) + assert Model is not None + assert "name" in Model.model_fields + + def test_mutual_recursion_schema(self) -> None: + """Two definitions that reference each other.""" + schema: dict[str, Any] = { + "$defs": { + "Person": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "pets": { + "type": "array", + "items": {"$ref": "#/$defs/Pet"}, + }, + }, + "required": ["name"], + }, + "Pet": { + "type": "object", + "properties": { + "species": {"type": "string"}, + "owner": {"$ref": "#/$defs/Person"}, + }, + "required": ["species"], + }, + }, + "$ref": "#/$defs/Person", + } + Model = create_model_from_schema(deepcopy(schema), model_name="PersonModel") + assert "name" in Model.model_fields + assert "pets" in Model.model_fields