ComposioHQ/composio · error · JsonSchemaRefResolutionError

JSON Schema $ref chain exceeded depth cap (${MAX_REF_CHAIN_D

Error message

JSON Schema $ref chain exceeded depth cap (${MAX_REF_CHAIN_DEPTH}): ${ref}

What it means

While following a chain of $ref → $ref → $ref, walk() caps chain length at MAX_REF_CHAIN_DEPTH. A chain longer than the cap throws instead of resolving (cycles are separately broken with a sentinel, so this is about genuinely long acyclic chains).

Source

Thrown at ts/packages/core/src/utils/jsonSchema.ts:240

      }
    }
    if (!isPlainObject(node)) return node;
    if (visiting.has(node)) return { ...CYCLE_BREAK_SENTINEL };
    visiting.add(node);
    try {
      const ref = typeof node.$ref === 'string' ? node.$ref : null;
      // External refs and non-$ref nodes both pass through the same clone path.
      if (ref === null || !ref.startsWith('#')) {
        if (ref !== null) {
          // Audit signal for security-sensitive deployments: a downstream
          // resolver may fetch this and trigger SSRF or local-file disclosure.
          logger.warn(`Leaving external $ref untouched: ${ref}`);
        }
        return cloneChildren(node, visitedRefs, chainDepth, nodeDepth);
      }

      if (chainDepth >= MAX_REF_CHAIN_DEPTH) {
        throw new JsonSchemaRefResolutionError(
          `JSON Schema $ref chain exceeded depth cap (${MAX_REF_CHAIN_DEPTH}): ${ref}`,
          { meta: { ref }, possibleFixes: REF_RESOLUTION_FIXES }
        );
      }
      if (visitedRefs.has(ref)) return { ...CYCLE_BREAK_SENTINEL };

      const result = tryResolvePointer(root, ref);
      let target: unknown;
      if (result.kind === 'ok') {
        target = result.value;
      } else if (strategy === 'sentinel') {
        // Lenient mode: replace the unresolved branch with the same
        // permissive sentinel used for cycles, and notify the caller so
        // they can emit a one-shot warn at the offending tool surface.
        // The injected `description` gives the LLM an in-band signal that
        // the branch is opaque; sibling-merge below will overwrite it with
        // a caller-provided description if the original node has one.
        onReplace?.(ref, result.reason);

View on GitHub (pinned to 64b1b85502)

Solutions

  1. Inline some of the intermediate refs to shorten the chain.
  2. Merge/dedupe redundant alias definitions in the source spec.
  3. Regenerate the schema with less aggressive $ref compression.
Defensive patterns

Strategy: validation

Validate before calling

const chainLen = (s: any, seen = new Set()): number => {
  if (typeof s?.$ref !== 'string' || seen.has(s)) return 0;
  seen.add(s); return 1 + chainLen(resolve(s.$ref), seen);
};

Prevention

When it happens

Trigger: Schemas where resolving one $ref yields another $ref many times in a row — e.g. aliases of aliases (A→B→C→…→Z) or spec-minifiers that replace every inline node with a ref.

Common situations: Aggressively deduplicated/generated OpenAPI specs, or merged specs creating long indirection chains.

Related errors


AI-assisted analysis of ComposioHQ/composio@64b1b85502 (2026-08-28). Data as JSON: /api/errors/b7bc6695f510d567. Report an issue: GitHub.