Skip to content

Commit ded0a7b

Browse files
authored
Merge pull request #130 from 117503445/codex/fix-nested-mcp-schema-downgrade
[codex] fix nested MCP schema conversion
2 parents 72e8479 + 5e62dbd commit ded0a7b

2 files changed

Lines changed: 956 additions & 31 deletions

File tree

agentrun/integration/utils/tool.py

Lines changed: 299 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,278 @@ 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"):
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+
if option.get("type") == "null":
1857+
continue
1858+
ref = _find_schema_ref(option)
1859+
return ref
1860+
1861+
options = field_schema.get("allOf")
1862+
if isinstance(options, list):
1863+
for option in options:
1864+
if not isinstance(option, dict):
1865+
continue
1866+
ref = _find_schema_ref(option)
1867+
if ref:
1868+
return ref
1869+
1870+
return None
1871+
1872+
1873+
def _cacheable_schema_ref(field_schema: Dict[str, Any]) -> Optional[str]:
1874+
"""查找可安全复用类型缓存的单一 $ref"""
1875+
ref = field_schema.get("$ref")
1876+
if isinstance(ref, str):
1877+
return ref if set(field_schema) <= {"$ref"} else None
1878+
1879+
for union_key in ("anyOf", "oneOf"):
1880+
options = field_schema.get(union_key)
1881+
if not isinstance(options, list):
1882+
continue
1883+
1884+
refs: List[str] = []
1885+
for option in options:
1886+
if not isinstance(option, dict):
1887+
continue
1888+
if option.get("type") == "null":
1889+
continue
1890+
option_ref = _cacheable_schema_ref(option)
1891+
if not option_ref:
1892+
return None
1893+
refs.append(option_ref)
1894+
1895+
if len(refs) == 1:
1896+
return refs[0]
1897+
1898+
return None
1899+
1900+
1901+
def _collect_direct_refs(schema: Any) -> Set[str]:
1902+
"""收集 schema 子树中直接写出的 $ref"""
1903+
refs: Set[str] = set()
1904+
if not isinstance(schema, dict):
1905+
return refs
1906+
1907+
ref = schema.get("$ref")
1908+
if isinstance(ref, str):
1909+
refs.add(ref)
1910+
1911+
for key, value in schema.items():
1912+
if key == "$ref":
1913+
continue
1914+
if isinstance(value, dict):
1915+
refs.update(_collect_direct_refs(value))
1916+
elif isinstance(value, list):
1917+
for item in value:
1918+
refs.update(_collect_direct_refs(item))
1919+
1920+
return refs
1921+
1922+
1923+
def _reachable_refs_for_ref(
1924+
ref: str,
1925+
root_schema: Dict[str, Any],
1926+
ref_dependency_cache: Optional[Dict[str, Set[str]]] = None,
1927+
) -> Set[str]:
1928+
"""收集某个 $ref 目标 schema 可达的引用集合"""
1929+
if ref_dependency_cache is None:
1930+
ref_dependency_cache = {}
1931+
if ref in ref_dependency_cache:
1932+
return set(ref_dependency_cache[ref])
1933+
1934+
visited: Set[str] = set()
1935+
direct_ref_map: Dict[str, Set[str]] = {}
1936+
stack = [ref]
1937+
while stack:
1938+
current_ref = stack.pop()
1939+
if current_ref in visited:
1940+
continue
1941+
visited.add(current_ref)
1942+
1943+
cached_refs = ref_dependency_cache.get(current_ref)
1944+
if cached_refs is not None:
1945+
continue
1946+
1947+
ref_schema = _resolve_ref_schema(current_ref, root_schema)
1948+
if not ref_schema:
1949+
continue
18291950

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,
1951+
direct_refs = _collect_direct_refs(ref_schema)
1952+
direct_ref_map[current_ref] = direct_refs
1953+
for direct_ref in direct_refs:
1954+
if (
1955+
direct_ref not in visited
1956+
and direct_ref not in ref_dependency_cache
1957+
):
1958+
stack.append(direct_ref)
1959+
1960+
closures = {
1961+
current_ref: set(direct_refs)
1962+
for current_ref, direct_refs in direct_ref_map.items()
18451963
}
1846-
return mapping.get(schema_type, str)
1964+
changed = True
1965+
while changed:
1966+
changed = False
1967+
for current_ref, direct_refs in direct_ref_map.items():
1968+
expanded_refs = set(direct_refs)
1969+
for direct_ref in direct_refs:
1970+
if direct_ref in closures:
1971+
expanded_refs.update(closures[direct_ref])
1972+
else:
1973+
expanded_refs.update(
1974+
ref_dependency_cache.get(direct_ref, set())
1975+
)
1976+
if expanded_refs != closures[current_ref]:
1977+
closures[current_ref] = expanded_refs
1978+
changed = True
1979+
1980+
ref_dependency_cache.update(closures)
1981+
return set(ref_dependency_cache.get(ref, set()))
1982+
1983+
1984+
def _schema_cache_key(
1985+
ref: Optional[str],
1986+
active_refs: Set[str],
1987+
root_schema: Optional[Dict[str, Any]],
1988+
ref_dependency_cache: Optional[Dict[str, Set[str]]],
1989+
) -> Optional[Tuple[str, Tuple[str, ...]]]:
1990+
"""根据 ref 和当前递归上下文生成类型缓存键"""
1991+
if not ref:
1992+
return None
1993+
relevant_refs = active_refs
1994+
if root_schema is not None:
1995+
reachable_refs = _reachable_refs_for_ref(
1996+
ref, root_schema, ref_dependency_cache
1997+
)
1998+
relevant_refs = active_refs.intersection(reachable_refs)
1999+
return ref, tuple(sorted(relevant_refs))
2000+
2001+
2002+
def _json_type_to_python(
2003+
field_schema: Dict[str, Any],
2004+
root_schema: Optional[Dict[str, Any]] = None,
2005+
active_refs: Optional[Set[str]] = None,
2006+
ref_type_cache: Optional[Dict[Tuple[str, Tuple[str, ...]], type]] = None,
2007+
ref_dependency_cache: Optional[Dict[str, Set[str]]] = None,
2008+
) -> type:
2009+
"""映射 JSON Schema 类型到 Python 类型
2010+
2011+
Args:
2012+
field_schema: 字段 schema, 可能是裸类型或 anyOf/oneOf/$ref 包装
2013+
root_schema: 顶层 schema, 用于解析 ``$ref`` 指向的 ``$defs``
2014+
active_refs: 当前递归栈中的 ``$ref``,用于防止自引用无限展开
2015+
ref_type_cache: 已构建的 ``$ref`` 类型缓存, 避免共享引用重复展开
2016+
ref_dependency_cache: ``$ref`` 可达引用缓存, 避免重复遍历共享 DAG
2017+
"""
2018+
if active_refs is None:
2019+
active_refs = set()
2020+
if ref_type_cache is None:
2021+
ref_type_cache = {}
2022+
if ref_dependency_cache is None:
2023+
ref_dependency_cache = {}
2024+
2025+
ref = _find_schema_ref(field_schema)
2026+
if ref and ref in active_refs:
2027+
return dict
2028+
cache_ref = _cacheable_schema_ref(field_schema)
2029+
cache_key = _schema_cache_key(
2030+
cache_ref, active_refs, root_schema, ref_dependency_cache
2031+
)
2032+
if cache_key and cache_key in ref_type_cache:
2033+
return ref_type_cache[cache_key]
2034+
2035+
if root_schema is not None and (
2036+
"anyOf" in field_schema
2037+
or "oneOf" in field_schema
2038+
or "allOf" in field_schema
2039+
or "$ref" in field_schema
2040+
):
2041+
core_schema, _ = _extract_core_schema(field_schema, root_schema)
2042+
if core_schema:
2043+
field_schema = core_schema
2044+
2045+
if ref:
2046+
active_refs.add(ref)
2047+
2048+
try:
2049+
schema_type = field_schema.get("type")
2050+
if not schema_type:
2051+
if "properties" in field_schema:
2052+
schema_type = "object"
2053+
elif "items" in field_schema:
2054+
schema_type = "array"
2055+
else:
2056+
schema_type = "string"
2057+
2058+
# 处理嵌套对象和数组
2059+
if schema_type == "object":
2060+
# 如果有 properties,递归创建嵌套模型
2061+
properties = field_schema.get("properties")
2062+
if properties:
2063+
nested_model = _json_schema_to_pydantic(
2064+
"NestedObject",
2065+
field_schema,
2066+
root_schema,
2067+
active_refs,
2068+
ref_type_cache,
2069+
ref_dependency_cache,
2070+
)
2071+
python_type = nested_model if nested_model else dict
2072+
if cache_key:
2073+
ref_type_cache[cache_key] = python_type
2074+
return python_type
2075+
python_type = dict
2076+
if cache_key:
2077+
ref_type_cache[cache_key] = python_type
2078+
return python_type
2079+
2080+
if schema_type == "array":
2081+
items_schema = field_schema.get("items", {})
2082+
if isinstance(items_schema, dict):
2083+
item_type = _json_type_to_python(
2084+
items_schema,
2085+
root_schema,
2086+
active_refs,
2087+
ref_type_cache,
2088+
ref_dependency_cache,
2089+
)
2090+
from typing import List as TypingList
2091+
2092+
python_type = TypingList[item_type] # type: ignore
2093+
if cache_key:
2094+
ref_type_cache[cache_key] = python_type
2095+
return python_type
2096+
python_type = list
2097+
if cache_key:
2098+
ref_type_cache[cache_key] = python_type
2099+
return python_type
2100+
2101+
# 基本类型映射
2102+
mapping = {
2103+
"string": str,
2104+
"integer": int,
2105+
"number": float,
2106+
"boolean": bool,
2107+
}
2108+
python_type = mapping.get(schema_type, str)
2109+
if cache_key:
2110+
ref_type_cache[cache_key] = python_type
2111+
return python_type
2112+
finally:
2113+
if ref:
2114+
active_refs.discard(ref)
18472115

18482116

18492117
def _build_args_model_from_parameters(

0 commit comments

Comments
 (0)