What happened?
_resolve_json_schema_reference (dspy/adapters/types/tool.py:517) expands JSON-Schema $refs with no cycle detection and a lookup that assumes every ref's last path segment exists in $defs. Three distinct failure modes follow, all reachable from public API:
1. Self-referential pydantic model as a tool argument → RecursionError
Recursive input schemas are legal JSON Schema, and pydantic emits exactly that shape for self-referencing models. Construction of the tool crashes:
import pydantic, dspy
class TreeNode(pydantic.BaseModel):
name: str
children: list["TreeNode"] = []
TreeNode.model_rebuild()
def organize(tree: TreeNode) -> str:
"""Summarize a tree."""
return "ok"
dspy.Tool(organize) # RecursionError
Same for the MCP path: convert_mcp_tool → convert_input_schema_to_tool_args → _resolve_json_schema_reference — any MCP server with a recursive input schema makes tool listing fail. Measured on main (59ce760), Python 3.11, macOS.
Note the project already solves this for prompts: _schema_to_xml (added in #10239) carries a seen frozenset to stop at cycles — the resolver should do the same.
2. Refs not shaped like #/$defs/<name> → KeyError
The resolver takes ref_path = obj["$ref"].split("/")[-1] and looks it up in schema["$defs"] only. Local pointers from OpenAPI-style or hand-written schemas crash tool construction:
from dspy.adapters.types.tool import convert_input_schema_to_tool_args
schema = {
"type": "object",
"$defs": {"Other": {"type": "string"}},
"properties": {
"node": {"$ref": "#/definitions/Node"},
"y": {"type": "object", "properties": {"z": {"$ref": "#/properties/x"}}},
},
"required": ["node"],
"definitions": {"Node": {"type": "object", "properties": {"name": {"type": "string"}}}},
}
convert_input_schema_to_tool_args(schema) # KeyError: 'Node'
Both refs above point into document locations the JSON Schema pointer syntax addresses explicitly (#/definitions/..., #/properties/...), and neither resolves: the first KeyErrors (present in definitions, looked up in $defs), the second KeyErrors ('x' is not in $defs).
3. Legacy definitions-only schema → silent degradation
When the top-level schema has definitions but no $defs, convert_input_schema_to_tool_args never attempts resolution (defs = schema.get("$defs", {}) is empty), so the {"$ref": "#/definitions/Node"} dict is passed through verbatim as the argument schema and arg_types falls back to Any. The resulting tool advertises a dangling $ref to providers (rejected by strict-schema APIs) and loses all validation. The docstring/guard in _resolve_json_schema_reference checks "$defs" not in schema and "definitions" not in schema, so definitions was clearly intended to be supported — the lookup just never implements it.
Proposed fix (happy to send PR — I have it working locally)
- Cycle-safe expansion: track ref names on the current expansion stack; when a cycle closes, emit
{} (the JSON-Schema "any" schema) instead of recursing — semantically the correct stop and universally accepted by providers. Depth is bounded by the ref graph, not the recursion limit.
- Pointer-aware lookup: resolve refs against
$defs, definitions, and local document pointers (#/… paths from the root), not just the last segment of $defs. An unresolvable ref keeps its current pass-through behavior rather than raising KeyError.
convert_input_schema_to_tool_args: seed defs from $defs or definitions so legacy schemas resolve instead of silently degrading.
All three are behavior-preserving for today's working paths (#/$defs/X refs from pydantic behave identically; only cycles and currently-crashing inputs change). Verified locally: recursive model tools construct fine, and the crash cases in #2/#3 return sensible schemas.
What happened?
_resolve_json_schema_reference(dspy/adapters/types/tool.py:517) expands JSON-Schema$refs with no cycle detection and a lookup that assumes every ref's last path segment exists in$defs. Three distinct failure modes follow, all reachable from public API:1. Self-referential pydantic model as a tool argument →
RecursionErrorRecursive input schemas are legal JSON Schema, and pydantic emits exactly that shape for self-referencing models. Construction of the tool crashes:
Same for the MCP path:
convert_mcp_tool→convert_input_schema_to_tool_args→_resolve_json_schema_reference— any MCP server with a recursive input schema makes tool listing fail. Measured onmain(59ce760), Python 3.11, macOS.Note the project already solves this for prompts:
_schema_to_xml(added in #10239) carries aseenfrozenset to stop at cycles — the resolver should do the same.2. Refs not shaped like
#/$defs/<name>→KeyErrorThe resolver takes
ref_path = obj["$ref"].split("/")[-1]and looks it up inschema["$defs"]only. Local pointers from OpenAPI-style or hand-written schemas crash tool construction:Both refs above point into document locations the JSON Schema pointer syntax addresses explicitly (
#/definitions/...,#/properties/...), and neither resolves: the first KeyErrors (present indefinitions, looked up in$defs), the second KeyErrors ('x'is not in$defs).3. Legacy
definitions-only schema → silent degradationWhen the top-level schema has
definitionsbut no$defs,convert_input_schema_to_tool_argsnever attempts resolution (defs = schema.get("$defs", {})is empty), so the{"$ref": "#/definitions/Node"}dict is passed through verbatim as the argument schema andarg_typesfalls back toAny. The resulting tool advertises a dangling$refto providers (rejected by strict-schema APIs) and loses all validation. The docstring/guard in_resolve_json_schema_referencechecks"$defs" not in schema and "definitions" not in schema, sodefinitionswas clearly intended to be supported — the lookup just never implements it.Proposed fix (happy to send PR — I have it working locally)
{}(the JSON-Schema "any" schema) instead of recursing — semantically the correct stop and universally accepted by providers. Depth is bounded by the ref graph, not the recursion limit.$defs,definitions, and local document pointers (#/…paths from the root), not just the last segment of$defs. An unresolvable ref keeps its current pass-through behavior rather than raisingKeyError.convert_input_schema_to_tool_args: seeddefsfrom$defsordefinitionsso legacy schemas resolve instead of silently degrading.All three are behavior-preserving for today's working paths (
#/$defs/Xrefs from pydantic behave identically; only cycles and currently-crashing inputs change). Verified locally: recursive model tools construct fine, and the crash cases in #2/#3 return sensible schemas.