Files
composiohq--composio/python/composio/utils/json_schema.py
T
2026-07-13 12:38:34 +08:00

258 lines
11 KiB
Python

"""Inline internal JSON Schema ``$ref`` pointers.
Python counterpart of the TypeScript SDK's ``dereferenceJsonSchema``
(``ts/packages/core/src/utils/jsonSchema.ts``). Hand-rolled schema walkers in
the SDK (e.g. the file-upload/download substitution in
:mod:`composio.core.models._files`) recurse through ``properties``/``anyOf``/
``oneOf``/``allOf``/``items`` but do not dereference ``$ref``. The Composio API
emits tool parameter schemas with ``$ref``/``$defs`` indirection (``GMAIL_GET_
ATTACHMENT`` is the canonical shape), so a ``file_uploadable``/``file_
downloadable`` flag reachable only through a reference is silently missed.
Resolving references *once* at the boundary — rather than teaching every walker
to dereference — keeps the walkers reference-agnostic and gives all of them
``$ref`` support for free, including keywords they never special-cased
(``additionalProperties``, ``patternProperties``, ``prefixItems``, ``not``, …),
because :func:`dereference_json_schema` walks containers reflectively.
"""
from __future__ import annotations
import typing as t
from composio.exceptions import JSONSchemaRefResolutionError
from composio.utils.logging import get as get_logger
logger = get_logger()
# A ``$ref`` chain longer than this, or object nesting deeper than this, is
# treated as pathological (cyclic data, a billion-laughs-style schema) and
# raises rather than exhausting the interpreter's recursion limit. The caps
# fire in both strict and lenient modes — leniency applies to *unresolvable*
# refs, not to runaway expansion.
MAX_REF_CHAIN_DEPTH = 100
MAX_NODE_DEPTH = 512
# In-band hint attached to the sentinel when lenient mode stands in for an
# unresolvable ``$ref``. Makes the degradation visible to an LLM reading the
# schema instead of leaving it a bare permissive object. Overridden by a
# caller-provided ``description`` sibling (Draft 2020-12 sibling-keyword merge).
UNRESOLVED_REF_DESCRIPTION = (
"Schema shape unresolved at the source — validate loosely. "
"See https://github.com/ComposioHQ/composio/issues/3307."
)
# Strategy when an internal ``$ref`` cannot be resolved.
# - ``"throw"`` (default): raise ``JSONSchemaRefResolutionError``. Right for
# first-party / custom-tool schemas where a dangling ``$ref`` is a bug.
# - ``"sentinel"``: replace the node with a permissive object. Right for
# schemas from an upstream service the caller cannot edit (the Composio API
# ships some ``output_parameters`` with a ``$ref`` into ``#/$defs/...`` but no
# ``$defs`` block — see https://github.com/ComposioHQ/composio/issues/3307).
UnresolvedRefStrategy = t.Literal["throw", "sentinel"]
UnresolvedRefReason = t.Literal["missing-target", "malformed-pointer"]
# Callback invoked once per replaced node in ``"sentinel"`` mode.
OnReplace = t.Callable[[str, "UnresolvedRefReason"], None]
def _cycle_break_sentinel() -> t.Dict[str, t.Any]:
"""A fresh permissive object used to break cycles / stand in for danglers."""
return {"type": "object", "additionalProperties": True}
def _is_mapping(value: t.Any) -> bool:
return isinstance(value, dict)
def _decode_pointer_segment(segment: str) -> str:
# Order matters: ``~01`` must decode to ``~1`` (literal), not ``/``.
return segment.replace("~1", "/").replace("~0", "~")
class _Resolution(t.NamedTuple):
ok: bool
value: t.Any = None
reason: t.Optional[UnresolvedRefReason] = None
failed_at: t.Optional[str] = None
def _try_resolve_pointer(root: t.Dict[str, t.Any], pointer: str) -> _Resolution:
"""Walk a local JSON Pointer (``#/a/b/0``) against ``root``.
Returns a tagged result so the caller can distinguish a malformed pointer
from a structurally valid pointer whose target is absent.
"""
if pointer in ("#", ""):
return _Resolution(ok=True, value=root)
if not pointer.startswith("#/"):
return _Resolution(ok=False, reason="malformed-pointer")
cursor: t.Any = root
for raw_segment in pointer[2:].split("/"):
segment = _decode_pointer_segment(raw_segment)
if isinstance(cursor, list):
try:
index = int(segment)
except ValueError:
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
if index < 0 or index >= len(cursor):
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
cursor = cursor[index]
elif isinstance(cursor, dict):
if segment not in cursor:
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
cursor = cursor[segment]
else:
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
return _Resolution(ok=True, value=cursor)
def _raise_resolution_error(pointer: str, resolution: _Resolution) -> t.NoReturn:
if resolution.reason == "malformed-pointer":
raise JSONSchemaRefResolutionError(
f"Unsupported $ref pointer: {pointer}",
meta={"ref": pointer},
)
meta: t.Dict[str, t.Any] = {"ref": pointer}
if resolution.failed_at is not None:
meta["failed_at"] = resolution.failed_at
raise JSONSchemaRefResolutionError(
f"Cannot resolve $ref {pointer}",
meta=meta,
)
def dereference_json_schema(
schema: t.Any,
*,
on_unresolved: UnresolvedRefStrategy = "throw",
on_replace: t.Optional[OnReplace] = None,
) -> t.Any:
"""Inline internal ``$ref`` pointers (``#/$defs/...`` and legacy
``#/definitions/...``), returning a new schema; the input is never mutated.
External refs (``http://``, ``https://``, …) are left untouched (and logged
once for audit, since a downstream resolver fetching them could enable SSRF
or local-file disclosure). Cycles — both ``$ref`` cycles and JS-object-style
identity cycles — are broken with a permissive ``{"type": "object",
"additionalProperties": True}`` sentinel. ``$defs``/``definitions`` are
stripped from the returned root once everything is inlined.
:param on_unresolved: see :data:`UnresolvedRefStrategy`.
:param on_replace: invoked once per replaced node in ``"sentinel"`` mode,
with the original pointer and the reason it could not be resolved.
:raises JSONSchemaRefResolutionError: on malformed pointers or missing
targets (strict mode only), or chains/nesting past the safety caps
(both modes).
"""
if not _is_mapping(schema):
return schema
root = t.cast(t.Dict[str, t.Any], schema)
# Identity-based cycle guard for the *current* walk path. Python doesn't
# suffer JS-style prototype pollution, so unlike the TS port there's no
# need to filter ``__proto__``/``constructor`` keys while cloning.
visiting: t.Set[int] = set()
# ``walk`` uses explicit loops (not comprehensions) so each level of schema
# nesting costs exactly one Python stack frame. That keeps ``MAX_NODE_DEPTH``
# the binding limit under the interpreter's default recursion ceiling, so a
# pathologically deep schema fails with our typed error rather than a bare
# ``RecursionError`` (which is also caught at the call site as a backstop).
def walk(
node: t.Any,
visited_refs: t.FrozenSet[str],
chain_depth: int,
node_depth: int,
) -> t.Any:
if node_depth >= MAX_NODE_DEPTH:
raise JSONSchemaRefResolutionError(
f"JSON Schema node depth exceeded cap ({MAX_NODE_DEPTH})"
)
if isinstance(node, list):
node_id = id(node)
if node_id in visiting:
return _cycle_break_sentinel()
visiting.add(node_id)
try:
cloned_list: t.List[t.Any] = []
for item in node:
cloned_list.append(
walk(item, visited_refs, chain_depth, node_depth + 1)
)
return cloned_list
finally:
visiting.discard(node_id)
if not _is_mapping(node):
return node
node_id = id(node)
if node_id in visiting:
return _cycle_break_sentinel()
visiting.add(node_id)
try:
ref = node.get("$ref")
ref = ref if isinstance(ref, str) else None
# External refs and non-``$ref`` nodes both pass through the same
# reflective clone path.
if ref is None or not ref.startswith("#"):
if ref is not None:
logger.warning("Leaving external $ref untouched: %s", ref)
cloned: t.Dict[str, t.Any] = {}
for key, value in node.items():
cloned[key] = walk(value, visited_refs, chain_depth, node_depth + 1)
return cloned
if chain_depth >= MAX_REF_CHAIN_DEPTH:
raise JSONSchemaRefResolutionError(
f"JSON Schema $ref chain exceeded depth cap "
f"({MAX_REF_CHAIN_DEPTH}): {ref}",
meta={"ref": ref},
)
if ref in visited_refs:
return _cycle_break_sentinel()
resolution = _try_resolve_pointer(root, ref)
if resolution.ok:
target: t.Any = resolution.value
elif on_unresolved == "sentinel":
if on_replace is not None and resolution.reason is not None:
on_replace(ref, resolution.reason)
target = {
**_cycle_break_sentinel(),
"description": UNRESOLVED_REF_DESCRIPTION,
}
else:
_raise_resolution_error(ref, resolution)
next_refs = visited_refs | {ref}
resolved = walk(target, next_refs, chain_depth + 1, node_depth + 1)
# Shallow-merge sibling keywords next to ``$ref`` (Draft 2020-12:
# siblings win on collision). Draft 7 ignores them, but the Composio
# tool surface admits both, so we honor siblings for safety.
siblings = {key: value for key, value in node.items() if key != "$ref"}
if not siblings or not _is_mapping(resolved):
return resolved
merged = dict(resolved)
for key, value in siblings.items():
merged[key] = walk(value, visited_refs, chain_depth, node_depth + 1)
return merged
finally:
visiting.discard(node_id)
try:
out = walk(root, frozenset(), 0, 0)
except RecursionError as exc: # pragma: no cover - depth cap normally fires first
raise JSONSchemaRefResolutionError(
"JSON Schema nesting too deep to dereference"
) from exc
if _is_mapping(out):
out.pop("$defs", None)
out.pop("definitions", None)
return out