mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-02 05:38:12 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user