Skip to content

Commit dbda29b

Browse files
author
黑曜
committed
fix nested MCP schema conversion
Signed-off-by: 黑曜 <haotian.qht@alibaba-inc.com>
1 parent 84dfaf2 commit dbda29b

2 files changed

Lines changed: 905 additions & 31 deletions

File tree

‎agentrun/integration/utils/tool.py‎

Lines changed: 289 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,11 +1731,33 @@ def _sanitize_python_identifier(name: str) -> str:
17311731
def _json_schema_to_pydantic(
17321732
name: str,
17331733
schema: Optional[Dict[str, Any]],
1734+
root_schema: Optional[Dict[str, Any]] = None,
1735+
active_refs: Optional[Set[str]] = None,
1736+
ref_type_cache: Optional[Dict[Tuple[str, Tuple[str, ...]], type]] = None,
1737+
ref_dependency_cache: Optional[Dict[str, Set[str]]] = None,
17341738
) -> Optional[Type[BaseModel]]:
1735-
"""将 JSON Schema 转换为 Pydantic 模型"""
1739+
"""将 JSON Schema 转换为 Pydantic 模型
1740+
1741+
Args:
1742+
name: 模型名称
1743+
schema: 待转换的 JSON Schema 片段
1744+
root_schema: 顶层 schema, 用于解析深层 ``$ref`` 指向的 ``$defs``
1745+
active_refs: 当前递归栈中的 ``$ref``,用于防止自引用无限展开
1746+
ref_type_cache: 已构建的 ``$ref`` 类型缓存, 避免共享引用重复展开
1747+
ref_dependency_cache: ``$ref`` 可达引用缓存, 避免重复遍历共享 DAG
1748+
"""
17361749
if not schema or not isinstance(schema, dict):
17371750
return None
17381751

1752+
if root_schema is None:
1753+
root_schema = schema
1754+
if active_refs is None:
1755+
active_refs = set()
1756+
if ref_type_cache is None:
1757+
ref_type_cache = {}
1758+
if ref_dependency_cache is None:
1759+
ref_dependency_cache = {}
1760+
17391761
properties = schema.get("properties", {})
17401762
if not properties:
17411763
return None
@@ -1768,7 +1790,13 @@ def _json_schema_to_pydantic(
17681790
needs_populate_by_name = True
17691791

17701792
# 映射类型
1771-
field_type = _json_type_to_python(field_schema)
1793+
field_type = _json_type_to_python(
1794+
field_schema,
1795+
root_schema,
1796+
active_refs,
1797+
ref_type_cache,
1798+
ref_dependency_cache,
1799+
)
17721800
description = field_schema.get("description", "")
17731801
default = field_schema.get("default")
17741802
aliases = field_schema.get("x-aliases")
@@ -1812,38 +1840,268 @@ def _json_schema_to_pydantic(
18121840
)
18131841

18141842

1815-
def _json_type_to_python(field_schema: Dict[str, Any]) -> type:
1816-
"""映射 JSON Schema 类型到 Python 类型"""
1817-
schema_type = field_schema.get("type", "string")
1843+
def _find_schema_ref(field_schema: Dict[str, Any]) -> Optional[str]:
1844+
"""查找当前 schema 片段中用于防环的第一个 $ref"""
1845+
ref = field_schema.get("$ref")
1846+
if isinstance(ref, str):
1847+
return ref
18181848

1819-
# 处理嵌套对象和数组
1820-
if schema_type == "object":
1821-
# 如果有 properties,递归创建嵌套模型
1822-
properties = field_schema.get("properties")
1823-
if properties:
1824-
nested_model = _json_schema_to_pydantic(
1825-
"NestedObject", field_schema
1826-
)
1827-
return nested_model if nested_model else dict
1828-
return dict
1849+
for union_key in ("anyOf", "oneOf", "allOf"):
1850+
options = field_schema.get(union_key)
1851+
if not isinstance(options, list):
1852+
continue
1853+
for option in options:
1854+
if not isinstance(option, dict):
1855+
continue
1856+
ref = _find_schema_ref(option)
1857+
if ref:
1858+
return ref
1859+
1860+
return None
1861+
1862+
1863+
def _cacheable_schema_ref(field_schema: Dict[str, Any]) -> Optional[str]:
1864+
"""查找可安全复用类型缓存的单一 $ref"""
1865+
ref = field_schema.get("$ref")
1866+
if isinstance(ref, str):
1867+
return ref if set(field_schema) <= {"$ref"} else None
1868+
1869+
for union_key in ("anyOf", "oneOf"):
1870+
options = field_schema.get(union_key)
1871+
if not isinstance(options, list):
1872+
continue
1873+
1874+
refs: List[str] = []
1875+
for option in options:
1876+
if not isinstance(option, dict):
1877+
continue
1878+
if option.get("type") == "null":
1879+
continue
1880+
option_ref = _cacheable_schema_ref(option)
1881+
if not option_ref:
1882+
return None
1883+
refs.append(option_ref)
1884+
1885+
if len(refs) == 1:
1886+
return refs[0]
1887+
1888+
return None
1889+
1890+
1891+
def _collect_direct_refs(schema: Any) -> Set[str]:
1892+
"""收集 schema 子树中直接写出的 $ref"""
1893+
refs: Set[str] = set()
1894+
if not isinstance(schema, dict):
1895+
return refs
1896+
1897+
ref = schema.get("$ref")
1898+
if isinstance(ref, str):
1899+
refs.add(ref)
1900+
1901+
for key, value in schema.items():
1902+
if key == "$ref":
1903+
continue
1904+
if isinstance(value, dict):
1905+
refs.update(_collect_direct_refs(value))
1906+
elif isinstance(value, list):
1907+
for item in value:
1908+
refs.update(_collect_direct_refs(item))
18291909

1830-
if schema_type == "array":
1831-
items_schema = field_schema.get("items", {})
1832-
if isinstance(items_schema, dict):
1833-
item_type = _json_type_to_python(items_schema)
1834-
from typing import List as TypingList
1835-
1836-
return TypingList[item_type] # type: ignore
1837-
return list
1838-
1839-
# 基本类型映射
1840-
mapping = {
1841-
"string": str,
1842-
"integer": int,
1843-
"number": float,
1844-
"boolean": bool,
1910+
return refs
1911+
1912+
1913+
def _reachable_refs_for_ref(
1914+
ref: str,
1915+
root_schema: Dict[str, Any],
1916+
ref_dependency_cache: Optional[Dict[str, Set[str]]] = None,
1917+
) -> Set[str]:
1918+
"""收集某个 $ref 目标 schema 可达的引用集合"""
1919+
if ref_dependency_cache is None:
1920+
ref_dependency_cache = {}
1921+
if ref in ref_dependency_cache:
1922+
return set(ref_dependency_cache[ref])
1923+
1924+
visited: Set[str] = set()
1925+
direct_ref_map: Dict[str, Set[str]] = {}
1926+
stack = [ref]
1927+
while stack:
1928+
current_ref = stack.pop()
1929+
if current_ref in visited:
1930+
continue
1931+
visited.add(current_ref)
1932+
1933+
cached_refs = ref_dependency_cache.get(current_ref)
1934+
if cached_refs is not None:
1935+
continue
1936+
1937+
ref_schema = _resolve_ref_schema(current_ref, root_schema)
1938+
if not ref_schema:
1939+
continue
1940+
1941+
direct_refs = _collect_direct_refs(ref_schema)
1942+
direct_ref_map[current_ref] = direct_refs
1943+
for direct_ref in direct_refs:
1944+
if (
1945+
direct_ref not in visited
1946+
and direct_ref not in ref_dependency_cache
1947+
):
1948+
stack.append(direct_ref)
1949+
1950+
closures = {
1951+
current_ref: set(direct_refs)
1952+
for current_ref, direct_refs in direct_ref_map.items()
18451953
}
1846-
return mapping.get(schema_type, str)
1954+
changed = True
1955+
while changed:
1956+
changed = False
1957+
for current_ref, direct_refs in direct_ref_map.items():
1958+
expanded_refs = set(direct_refs)
1959+
for direct_ref in direct_refs:
1960+
if direct_ref in closures:
1961+
expanded_refs.update(closures[direct_ref])
1962+
else:
1963+
expanded_refs.update(
1964+
ref_dependency_cache.get(direct_ref, set())
1965+
)
1966+
if expanded_refs != closures[current_ref]:
1967+
closures[current_ref] = expanded_refs
1968+
changed = True
1969+
1970+
ref_dependency_cache.update(closures)
1971+
return set(ref_dependency_cache.get(ref, set()))
1972+
1973+
1974+
def _schema_cache_key(
1975+
ref: Optional[str],
1976+
active_refs: Set[str],
1977+
root_schema: Optional[Dict[str, Any]],
1978+
ref_dependency_cache: Optional[Dict[str, Set[str]]],
1979+
) -> Optional[Tuple[str, Tuple[str, ...]]]:
1980+
"""根据 ref 和当前递归上下文生成类型缓存键"""
1981+
if not ref:
1982+
return None
1983+
relevant_refs = active_refs
1984+
if root_schema is not None:
1985+
reachable_refs = _reachable_refs_for_ref(
1986+
ref, root_schema, ref_dependency_cache
1987+
)
1988+
relevant_refs = active_refs.intersection(reachable_refs)
1989+
return ref, tuple(sorted(relevant_refs))
1990+
1991+
1992+
def _json_type_to_python(
1993+
field_schema: Dict[str, Any],
1994+
root_schema: Optional[Dict[str, Any]] = None,
1995+
active_refs: Optional[Set[str]] = None,
1996+
ref_type_cache: Optional[Dict[Tuple[str, Tuple[str, ...]], type]] = None,
1997+
ref_dependency_cache: Optional[Dict[str, Set[str]]] = None,
1998+
) -> type:
1999+
"""映射 JSON Schema 类型到 Python 类型
2000+
2001+
Args:
2002+
field_schema: 字段 schema, 可能是裸类型或 anyOf/oneOf/$ref 包装
2003+
root_schema: 顶层 schema, 用于解析 ``$ref`` 指向的 ``$defs``
2004+
active_refs: 当前递归栈中的 ``$ref``,用于防止自引用无限展开
2005+
ref_type_cache: 已构建的 ``$ref`` 类型缓存, 避免共享引用重复展开
2006+
ref_dependency_cache: ``$ref`` 可达引用缓存, 避免重复遍历共享 DAG
2007+
"""
2008+
if active_refs is None:
2009+
active_refs = set()
2010+
if ref_type_cache is None:
2011+
ref_type_cache = {}
2012+
if ref_dependency_cache is None:
2013+
ref_dependency_cache = {}
2014+
2015+
ref = _find_schema_ref(field_schema)
2016+
if ref and ref in active_refs:
2017+
return dict
2018+
cache_ref = _cacheable_schema_ref(field_schema)
2019+
cache_key = _schema_cache_key(
2020+
cache_ref, active_refs, root_schema, ref_dependency_cache
2021+
)
2022+
if cache_key and cache_key in ref_type_cache:
2023+
return ref_type_cache[cache_key]
2024+
2025+
if root_schema is not None and (
2026+
"anyOf" in field_schema
2027+
or "oneOf" in field_schema
2028+
or "allOf" in field_schema
2029+
or "$ref" in field_schema
2030+
):
2031+
core_schema, _ = _extract_core_schema(field_schema, root_schema)
2032+
if core_schema:
2033+
field_schema = core_schema
2034+
2035+
if ref:
2036+
active_refs.add(ref)
2037+
2038+
try:
2039+
schema_type = field_schema.get("type")
2040+
if not schema_type:
2041+
if "properties" in field_schema:
2042+
schema_type = "object"
2043+
elif "items" in field_schema:
2044+
schema_type = "array"
2045+
else:
2046+
schema_type = "string"
2047+
2048+
# 处理嵌套对象和数组
2049+
if schema_type == "object":
2050+
# 如果有 properties,递归创建嵌套模型
2051+
properties = field_schema.get("properties")
2052+
if properties:
2053+
nested_model = _json_schema_to_pydantic(
2054+
"NestedObject",
2055+
field_schema,
2056+
root_schema,
2057+
active_refs,
2058+
ref_type_cache,
2059+
ref_dependency_cache,
2060+
)
2061+
python_type = nested_model if nested_model else dict
2062+
if cache_key:
2063+
ref_type_cache[cache_key] = python_type
2064+
return python_type
2065+
python_type = dict
2066+
if cache_key:
2067+
ref_type_cache[cache_key] = python_type
2068+
return python_type
2069+
2070+
if schema_type == "array":
2071+
items_schema = field_schema.get("items", {})
2072+
if isinstance(items_schema, dict):
2073+
item_type = _json_type_to_python(
2074+
items_schema,
2075+
root_schema,
2076+
active_refs,
2077+
ref_type_cache,
2078+
ref_dependency_cache,
2079+
)
2080+
from typing import List as TypingList
2081+
2082+
python_type = TypingList[item_type] # type: ignore
2083+
if cache_key:
2084+
ref_type_cache[cache_key] = python_type
2085+
return python_type
2086+
python_type = list
2087+
if cache_key:
2088+
ref_type_cache[cache_key] = python_type
2089+
return python_type
2090+
2091+
# 基本类型映射
2092+
mapping = {
2093+
"string": str,
2094+
"integer": int,
2095+
"number": float,
2096+
"boolean": bool,
2097+
}
2098+
python_type = mapping.get(schema_type, str)
2099+
if cache_key:
2100+
ref_type_cache[cache_key] = python_type
2101+
return python_type
2102+
finally:
2103+
if ref:
2104+
active_refs.discard(ref)
18472105

18482106

18492107
def _build_args_model_from_parameters(

0 commit comments

Comments
 (0)