Compare commits

...

3 Commits

Author SHA1 Message Date
Devin AI
a3a3f658fe fix: add type annotation to satisfy mypy no-any-return
Co-Authored-By: João <joao@crewai.com>
2026-04-16 08:42:55 +00:00
Devin AI
73e3fe7c86 style: fix ruff formatting (slice spacing)
Co-Authored-By: João <joao@crewai.com>
2026-04-16 08:39:03 +00:00
Devin AI
c7aaf23a6f fix: handle recursive MCP schemas in sanitization and model creation (#5490)
Two issues remained after the cyclic-schema fix in #5478:

1. resolve_refs() silently degraded recursive $ref pointers to {},
   causing sanitize_tool_params_for_anthropic_strict (and the OpenAI
   variant) to produce children.items == {} for tree-shaped schemas.
   Fix: return a type-preserving stub ({"type": "object"}) instead
   of an empty dict when a circular reference is detected.

2. create_model_from_schema() called jsonref.replace_refs() which
   throws JsonRefError on circular $ref graphs. Fix: catch the error
   and fall back to _inline_top_level_ref(), which manually resolves
   the top-level $ref while preserving $defs for inner resolution.
   The existing in_progress cycle detection in _build_model_from_schema
   then handles recursive references via ForwardRef.

Tests added for both paths, including mutual-recursion scenarios.

Co-Authored-By: João <joao@crewai.com>
2026-04-16 08:34:48 +00:00
2 changed files with 183 additions and 2 deletions

View File

@@ -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: dict[str, Any] = 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

View File

@@ -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