Skip to content

Commit e7a7c33

Browse files
fix: surface actionable error for dangling $ref in tool schemas (#716)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f02cacc commit e7a7c33

6 files changed

Lines changed: 388 additions & 5 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.9.2"
3+
version = "0.9.3"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from typing import Any, Type
55

66
from jsonschema_pydantic_converter import transform_with_modules
7-
from pydantic import BaseModel
7+
from pydantic import BaseModel, PydanticUndefinedAnnotation
8+
9+
from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode
810

911
# Shared pseudo-module for all dynamically created types
1012
# This allows get_type_hints() to resolve forward references
@@ -25,7 +27,25 @@ def _get_or_create_dynamic_module() -> ModuleType:
2527
def create_model(
2628
schema: dict[str, Any],
2729
) -> Type[BaseModel]:
28-
model, namespace = transform_with_modules(schema)
30+
"""Convert a JSON schema dict to a Pydantic model.
31+
32+
Raises:
33+
AgentStartupError: If the schema contains a type that cannot be resolved.
34+
"""
35+
try:
36+
model, namespace = transform_with_modules(schema)
37+
except PydanticUndefinedAnnotation as e:
38+
# Strip the __ prefix the converter adds to forward references
39+
# so the user sees the original type name from their JSON schema.
40+
type_name = e.name.lstrip("_") if e.name else None
41+
raise AgentStartupError(
42+
code=AgentStartupErrorCode.INVALID_TOOL_CONFIG,
43+
title="Invalid schema",
44+
detail=(
45+
f"Type '{type_name}' could not be resolved. "
46+
f"Check that all $ref targets have matching entries in $defs."
47+
),
48+
) from e
2949

3050
pseudo_module = _get_or_create_dynamic_module()
3151

src/uipath_langchain/agent/tools/integration_tool.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,9 @@ def fix_types(props: dict[str, Any]) -> None:
209209
k = k.replace("[*]", "")
210210
definitions[k] = value
211211
fix_types(value)
212-
if "definitions" in fields:
212+
if "$defs" in fields:
213+
fields["$defs"] = definitions
214+
elif "definitions" in fields:
213215
fields["definitions"] = definitions
214216

215217
fix_types(fields)
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
"""Tests for jsonschema_pydantic_converter wrapper — create_model()."""
2+
3+
from typing import Any
4+
5+
import pytest
6+
7+
from uipath_langchain.agent.exceptions import AgentStartupError
8+
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
9+
10+
# --- Fixtures: reusable schema fragments ---
11+
12+
13+
@pytest.fixture()
14+
def contact_def() -> dict[str, Any]:
15+
return {
16+
"type": "object",
17+
"properties": {
18+
"fullname": {"type": "string"},
19+
"email": {"type": "string"},
20+
},
21+
}
22+
23+
24+
@pytest.fixture()
25+
def schema_with_defs(contact_def: dict[str, Any]) -> dict[str, Any]:
26+
"""Schema with properly matched $ref and $defs."""
27+
return {
28+
"type": "object",
29+
"properties": {
30+
"owner": {"$ref": "#/$defs/Contact"},
31+
},
32+
"$defs": {
33+
"Contact": contact_def,
34+
},
35+
}
36+
37+
38+
# --- 1. Dangling $ref (unresolvable type references) ---
39+
40+
41+
class TestDanglingRef:
42+
"""Schemas where $ref points to a type not in $defs."""
43+
44+
def test_ref_to_missing_defs_raises(self) -> None:
45+
schema = {
46+
"type": "object",
47+
"properties": {
48+
"owner": {"$ref": "#/$defs/Contact"},
49+
},
50+
}
51+
with pytest.raises(AgentStartupError, match=r"Contact.*could not be resolved"):
52+
create_model(schema)
53+
54+
def test_malformed_ref_path_raises(self) -> None:
55+
schema = {
56+
"type": "object",
57+
"properties": {
58+
"owner": {"$ref": "Contact"},
59+
},
60+
}
61+
with pytest.raises(AgentStartupError, match=r"Contact.*could not be resolved"):
62+
create_model(schema)
63+
64+
def test_nested_defs_with_root_relative_ref_raises(self) -> None:
65+
"""$defs inside 'items' with root-relative $ref paths.
66+
67+
When $defs are placed inside a nested object (e.g. array items)
68+
but $ref uses root-relative paths (#/$defs/...), the converter
69+
cannot reach the definitions.
70+
"""
71+
schema = {
72+
"type": "object",
73+
"properties": {
74+
"records": {
75+
"type": "array",
76+
"items": {
77+
"type": "object",
78+
"properties": {
79+
"author": {"$ref": "#/$defs/Person"},
80+
"title": {"type": "string"},
81+
},
82+
"$defs": {
83+
"Person": {
84+
"type": "object",
85+
"properties": {
86+
"Name": {"type": "string"},
87+
"Email": {"type": "string"},
88+
},
89+
},
90+
"Timestamp": {
91+
"type": "string",
92+
"format": "date-time",
93+
},
94+
},
95+
},
96+
}
97+
},
98+
}
99+
with pytest.raises(AgentStartupError, match=r"Person.*could not be resolved"):
100+
create_model(schema)
101+
102+
def test_ref_to_partial_defs_raises_for_missing_type(self) -> None:
103+
"""$defs has some types but not the one referenced."""
104+
schema = {
105+
"type": "object",
106+
"properties": {
107+
"report": {"$ref": "#/$defs/Report"},
108+
},
109+
"$defs": {
110+
"Report": {
111+
"type": "object",
112+
"properties": {
113+
"reviewer": {"$ref": "#/$defs/Contact"},
114+
},
115+
},
116+
# Contact is missing
117+
},
118+
}
119+
with pytest.raises(AgentStartupError, match=r"Contact.*could not be resolved"):
120+
create_model(schema)
121+
122+
123+
# --- 2. Valid $ref/$defs (happy paths) ---
124+
125+
126+
class TestValidDefs:
127+
"""Schemas where $ref and $defs are properly matched."""
128+
129+
def test_ref_with_matching_defs(self, schema_with_defs: dict[str, Any]) -> None:
130+
model = create_model(schema_with_defs)
131+
assert model.__pydantic_complete__
132+
assert "owner" in model.model_fields
133+
134+
def test_cross_referencing_defs(self, contact_def: dict[str, Any]) -> None:
135+
schema = {
136+
"type": "object",
137+
"properties": {
138+
"report": {"$ref": "#/$defs/Report"},
139+
},
140+
"$defs": {
141+
"Report": {
142+
"type": "object",
143+
"properties": {
144+
"name": {"type": "string"},
145+
"reviewer": {"$ref": "#/$defs/Contact"},
146+
},
147+
},
148+
"Contact": contact_def,
149+
},
150+
}
151+
model = create_model(schema)
152+
assert model.__pydantic_complete__
153+
assert "report" in model.model_fields
154+
155+
def test_definitions_keyword(self, contact_def: dict[str, Any]) -> None:
156+
"""Old-style 'definitions' keyword (not $defs)."""
157+
schema = {
158+
"type": "object",
159+
"properties": {
160+
"owner": {"$ref": "#/definitions/Contact"},
161+
},
162+
"definitions": {
163+
"Contact": contact_def,
164+
},
165+
}
166+
model = create_model(schema)
167+
assert model.__pydantic_complete__
168+
assert "owner" in model.model_fields
169+
170+
171+
# --- 3. Static args round-trip (model → JSON schema → model) ---
172+
173+
174+
class TestStaticArgsRoundTrip:
175+
"""Simulates static_args.py: model_json_schema() → modify → create_model().
176+
177+
The round-trip regenerates $defs with Pydantic-chosen keys.
178+
All $ref entries must still resolve after the round-trip.
179+
"""
180+
181+
def test_round_trip_preserves_defs(self, schema_with_defs: dict[str, Any]) -> None:
182+
model = create_model(schema_with_defs)
183+
round_tripped = model.model_json_schema()
184+
model2 = create_model(round_tripped)
185+
assert model2.__pydantic_complete__
186+
assert "owner" in model2.model_fields
187+
188+
def test_round_trip_with_cross_refs(self, contact_def: dict[str, Any]) -> None:
189+
schema = {
190+
"type": "object",
191+
"properties": {
192+
"report": {"$ref": "#/$defs/Report"},
193+
"reviewer": {"$ref": "#/$defs/Contact"},
194+
},
195+
"$defs": {
196+
"Report": {
197+
"type": "object",
198+
"properties": {
199+
"owner": {"$ref": "#/$defs/Contact"},
200+
"items": {
201+
"type": "array",
202+
"items": {"$ref": "#/$defs/Contact"},
203+
},
204+
},
205+
},
206+
"Contact": contact_def,
207+
},
208+
}
209+
model = create_model(schema)
210+
round_tripped = model.model_json_schema()
211+
assert "$defs" in round_tripped or "definitions" in round_tripped
212+
model2 = create_model(round_tripped)
213+
assert model2.__pydantic_complete__
214+
assert "report" in model2.model_fields
215+
assert "reviewer" in model2.model_fields
216+
217+
def test_round_trip_after_property_removal(
218+
self, schema_with_defs: dict[str, Any]
219+
) -> None:
220+
"""Simulates static_args removing a property from the schema."""
221+
model = create_model(schema_with_defs)
222+
round_tripped = model.model_json_schema()
223+
224+
# Add a simple field, then remove it (like static_args does)
225+
round_tripped["properties"]["extra"] = {"type": "string"}
226+
round_tripped["properties"].pop("extra")
227+
228+
model2 = create_model(round_tripped)
229+
assert model2.__pydantic_complete__
230+
assert "owner" in model2.model_fields
231+
232+
233+
# --- 4. Pseudo-module isolation across multiple create_model calls ---
234+
235+
236+
class TestPseudoModuleIsolation:
237+
"""Multiple create_model calls share one pseudo-module.
238+
239+
Each call must produce a working model regardless of prior calls.
240+
"""
241+
242+
def test_sequential_models_with_same_def_name(
243+
self, contact_def: dict[str, Any]
244+
) -> None:
245+
schema_a = {
246+
"type": "object",
247+
"properties": {"owner": {"$ref": "#/$defs/Contact"}},
248+
"$defs": {"Contact": contact_def},
249+
}
250+
schema_b = {
251+
"type": "object",
252+
"properties": {"author": {"$ref": "#/$defs/Contact"}},
253+
"$defs": {
254+
"Contact": {
255+
"type": "object",
256+
"properties": {"id": {"type": "integer"}},
257+
}
258+
},
259+
}
260+
261+
model_a = create_model(schema_a)
262+
model_b = create_model(schema_b)
263+
264+
assert model_a.__pydantic_complete__
265+
assert model_b.__pydantic_complete__
266+
assert "owner" in model_a.model_fields
267+
assert "author" in model_b.model_fields
268+
269+
def test_model_from_simple_schema_after_complex(
270+
self, contact_def: dict[str, Any]
271+
) -> None:
272+
complex_schema = {
273+
"type": "object",
274+
"properties": {
275+
"report": {"$ref": "#/$defs/Report"},
276+
},
277+
"$defs": {
278+
"Report": {
279+
"type": "object",
280+
"properties": {
281+
"owner": {"$ref": "#/$defs/Contact"},
282+
},
283+
},
284+
"Contact": contact_def,
285+
},
286+
}
287+
simple_schema = {
288+
"type": "object",
289+
"properties": {"name": {"type": "string"}},
290+
}
291+
292+
complex_model = create_model(complex_schema)
293+
model = create_model(simple_schema)
294+
295+
assert complex_model.__pydantic_complete__
296+
assert "report" in complex_model.model_fields
297+
assert model.__pydantic_complete__
298+
assert "name" in model.model_fields

0 commit comments

Comments
 (0)