Skip to content

Commit 142771d

Browse files
MarcusMarcus
authored andcommitted
fix(core): resolve infinite recursion in _dereference_refs_helper with mixed $ref objects
Fixes infinite recursion issue in JSON schema dereferencing when objects contain both $ref and other properties (e.g., nullable, description, additionalProperties). **Problem:** - Commit fb5da83 changed the condition from `set(obj.keys()) == {"$ref"}` to `"$ref" in set(obj.keys())` - This caused objects with $ref + other properties to be treated as pure $ref nodes - Result: other properties were lost and infinite recursion occurred with complex schemas **Solution:** - Restore pure $ref detection for objects with only $ref key - Add proper handling for mixed $ref objects that preserves all properties - Merge resolved reference content with other properties - Maintain cycle detection to prevent infinite recursion **Impact:** - Fixes Apollo MCP server schema integration (#32511) - Resolves tool binding infinite recursion with complex GraphQL schemas - Preserves backward compatibility with existing functionality - No performance impact - actually improves handling of complex schemas Fixes #32511
1 parent 791d309 commit 142771d

File tree

2 files changed

+181
-3
lines changed

2 files changed

+181
-3
lines changed

libs/core/langchain_core/utils/json_schema.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ def _dereference_refs_helper(
5454
if processed_refs is None:
5555
processed_refs = set()
5656

57-
# 1) Pure $ref node?
58-
if isinstance(obj, dict) and "$ref" in set(obj.keys()):
57+
# 1) Pure $ref node (only contains $ref, no other properties)?
58+
if isinstance(obj, dict) and set(obj.keys()) == {"$ref"}:
5959
ref_path = obj["$ref"]
6060
# cycle?
6161
if ref_path in processed_refs:
@@ -73,7 +73,48 @@ def _dereference_refs_helper(
7373
processed_refs.remove(ref_path)
7474
return result
7575

76-
# 2) Not a pure-$ref: recurse, skipping any keys in skip_keys
76+
# 2) Mixed $ref node (contains $ref plus other properties)?
77+
if isinstance(obj, dict) and "$ref" in obj:
78+
ref_path = obj["$ref"]
79+
# cycle?
80+
if ref_path in processed_refs:
81+
# For mixed refs, return the non-ref properties to avoid infinite recursion
82+
other_props = {k: v for k, v in obj.items() if k != "$ref"}
83+
return _dereference_refs_helper(
84+
other_props, full_schema, processed_refs, skip_keys, shallow_refs
85+
)
86+
87+
processed_refs.add(ref_path)
88+
89+
# grab + copy the target
90+
target = deepcopy(_retrieve_ref(ref_path, full_schema))
91+
92+
# Merge the resolved reference with other properties
93+
result_dict = {}
94+
95+
# First, process the resolved reference
96+
resolved_ref = _dereference_refs_helper(
97+
target, full_schema, processed_refs, skip_keys, shallow_refs
98+
)
99+
if isinstance(resolved_ref, dict):
100+
result_dict.update(resolved_ref)
101+
102+
# Then add/override with the other properties from the original object
103+
other_props = {k: v for k, v in obj.items() if k != "$ref"}
104+
for k, v in other_props.items():
105+
if k in skip_keys:
106+
result_dict[k] = deepcopy(v)
107+
elif isinstance(v, (dict, list)):
108+
result_dict[k] = _dereference_refs_helper(
109+
v, full_schema, processed_refs, skip_keys, shallow_refs
110+
)
111+
else:
112+
result_dict[k] = v
113+
114+
processed_refs.remove(ref_path)
115+
return result_dict
116+
117+
# 3) Not a $ref: recurse, skipping any keys in skip_keys
77118
if isinstance(obj, dict):
78119
out: dict[str, Any] = {}
79120
for k, v in obj.items():

libs/core/tests/unit_tests/utils/test_json_schema.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,3 +444,140 @@ def test_dereference_refs_list_index() -> None:
444444

445445
actual_dict_key = dereference_refs(schema_dict_key)
446446
assert actual_dict_key == expected_dict_key
447+
448+
449+
def test_dereference_refs_mixed_ref_with_properties() -> None:
450+
"""Test dereferencing refs that have $ref plus other properties."""
451+
# This pattern can cause infinite recursion if not handled correctly
452+
schema = {
453+
"type": "object",
454+
"properties": {
455+
"data": {
456+
"$ref": "#/$defs/BaseType",
457+
"description": "Additional description",
458+
"example": "some example",
459+
}
460+
},
461+
"$defs": {"BaseType": {"type": "string", "minLength": 1}},
462+
}
463+
464+
expected = {
465+
"type": "object",
466+
"properties": {
467+
"data": {
468+
"type": "string",
469+
"minLength": 1,
470+
"description": "Additional description",
471+
"example": "some example",
472+
}
473+
},
474+
"$defs": {"BaseType": {"type": "string", "minLength": 1}},
475+
}
476+
477+
actual = dereference_refs(schema)
478+
assert actual == expected
479+
480+
481+
def test_dereference_refs_complex_apollo_mcp_pattern() -> None:
482+
"""Test pattern that caused infinite recursion in Apollo MCP server schemas."""
483+
# Simplified version of the problematic pattern from the issue
484+
schema = {
485+
"type": "object",
486+
"properties": {
487+
"query": {"$ref": "#/$defs/Query", "additionalProperties": False}
488+
},
489+
"$defs": {
490+
"Query": {
491+
"type": "object",
492+
"properties": {"user": {"$ref": "#/$defs/User"}},
493+
},
494+
"User": {
495+
"type": "object",
496+
"properties": {
497+
"id": {"type": "string"},
498+
"profile": {"$ref": "#/$defs/UserProfile", "nullable": True},
499+
},
500+
},
501+
"UserProfile": {
502+
"type": "object",
503+
"properties": {"bio": {"type": "string"}},
504+
},
505+
},
506+
}
507+
508+
# This should not cause infinite recursion
509+
actual = dereference_refs(schema)
510+
511+
# The mixed $ref should be properly resolved and merged
512+
expected_user_profile = {
513+
"type": "object",
514+
"properties": {"bio": {"type": "string"}},
515+
}
516+
517+
expected_user = {
518+
"type": "object",
519+
"properties": {
520+
"id": {"type": "string"},
521+
"profile": {
522+
"type": "object",
523+
"properties": {"bio": {"type": "string"}},
524+
"nullable": True,
525+
},
526+
},
527+
}
528+
529+
expected_query = {
530+
"type": "object",
531+
"properties": {"user": expected_user},
532+
"additionalProperties": False,
533+
}
534+
535+
expected = {
536+
"type": "object",
537+
"properties": {"query": expected_query},
538+
"$defs": {
539+
"Query": {
540+
"type": "object",
541+
"properties": {"user": {"$ref": "#/$defs/User"}},
542+
},
543+
"User": {
544+
"type": "object",
545+
"properties": {
546+
"id": {"type": "string"},
547+
"profile": {"$ref": "#/$defs/UserProfile", "nullable": True},
548+
},
549+
},
550+
"UserProfile": expected_user_profile,
551+
},
552+
}
553+
554+
assert actual == expected
555+
556+
557+
def test_dereference_refs_cyclical_mixed_refs() -> None:
558+
"""Test cyclical references with mixed $ref properties don't cause loops."""
559+
schema = {
560+
"type": "object",
561+
"properties": {"node": {"$ref": "#/$defs/Node"}},
562+
"$defs": {
563+
"Node": {
564+
"type": "object",
565+
"properties": {
566+
"id": {"type": "string"},
567+
"parent": {"$ref": "#/$defs/Node", "nullable": True},
568+
"children": {"type": "array", "items": {"$ref": "#/$defs/Node"}},
569+
},
570+
}
571+
},
572+
}
573+
574+
# This should handle cycles gracefully
575+
actual = dereference_refs(schema)
576+
577+
# The self-referencing should be broken with empty objects
578+
579+
# Verify the structure is correct and doesn't cause infinite recursion
580+
assert "properties" in actual
581+
assert "node" in actual["properties"]
582+
assert isinstance(actual["properties"]["node"], dict)
583+
assert "type" in actual["properties"]["node"]

0 commit comments

Comments
 (0)