Skip to content

Commit 328524c

Browse files
fix: support non-object output schemas (#637)
1 parent 9f49d90 commit 328524c

4 files changed

Lines changed: 199 additions & 21 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.8.0"
3+
version = "0.8.1"
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"
@@ -18,7 +18,7 @@ dependencies = [
1818
"python-dotenv>=1.0.1",
1919
"httpx>=0.27.0",
2020
"openinference-instrumentation-langchain>=0.1.56",
21-
"jsonschema-pydantic-converter>=0.1.9",
21+
"jsonschema-pydantic-converter>=0.2.0",
2222
"jsonpath-ng>=1.7.0",
2323
"mcp==1.26.0",
2424
"langchain-mcp-adapters==0.2.1",

src/uipath_langchain/agent/react/json_utils.py

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Any, ForwardRef, Union, get_args, get_origin
33

44
from jsonpath_ng import parse # type: ignore[import-untyped]
5-
from pydantic import BaseModel
5+
from pydantic import BaseModel, RootModel
66

77

88
def get_json_paths_by_type(model: type[BaseModel], type_name: str) -> list[str]:
@@ -63,26 +63,35 @@ def _recursive_search(
6363
continue
6464

6565
if origin is list:
66-
args = get_args(annotation)
67-
if args:
68-
list_item_type = args[0]
69-
if matches_type(list_item_type):
70-
json_paths.append(f"{field_path}[*]")
71-
continue
72-
73-
if _is_pydantic_model(list_item_type):
74-
nested_paths = _recursive_search(
75-
list_item_type, f"{field_path}[*]"
76-
)
77-
json_paths.extend(nested_paths)
78-
continue
66+
inner_type, suffix = _unwrap_lists(annotation)
67+
inner_path = f"{field_path}{suffix}"
68+
if matches_type(inner_type):
69+
json_paths.append(inner_path)
70+
continue
71+
if _is_pydantic_model(inner_type):
72+
nested_paths = _recursive_search(inner_type, inner_path)
73+
json_paths.extend(nested_paths)
74+
continue
7975

8076
if _is_pydantic_model(annotation):
8177
nested_paths = _recursive_search(annotation, field_path)
8278
json_paths.extend(nested_paths)
8379

8480
return json_paths
8581

82+
# RootModel serializes without the "root" wrapper — e.g. RootModel[list[X]]
83+
# dumps as [...], not {"root": [...]}. Iterating model_fields directly would
84+
# produce wrong paths like "$.root.field". Instead we peel off the RootModel
85+
# envelope (and any Optional/list layers) so _recursive_search only ever sees
86+
# a plain BaseModel with correct JSONPath prefixes (e.g. "$[*].field").
87+
if issubclass(model, RootModel):
88+
inner = _unwrap_optional(model.model_fields["root"].annotation)
89+
inner, suffix = _unwrap_lists(inner)
90+
# Primitive or non-model root types can't contain nested typed fields.
91+
if not _is_pydantic_model(inner):
92+
return []
93+
return _recursive_search(inner, f"${suffix}" if suffix else "")
94+
8695
return _recursive_search(model, "")
8796

8897

@@ -179,5 +188,21 @@ def _unwrap_optional(annotation: Any) -> Any:
179188
return annotation
180189

181190

191+
def _unwrap_lists(annotation: Any) -> tuple[Any, str]:
192+
"""Unwrap nested list types, returning (inner_type, jsonpath_suffix).
193+
194+
Each list layer adds a "[*]" wildcard so the resulting suffix maps directly
195+
to JSONPath: list[list[X]] → (X, "[*][*]").
196+
"""
197+
suffix = ""
198+
while get_origin(annotation) is list:
199+
args = get_args(annotation)
200+
if not args:
201+
break
202+
annotation = args[0]
203+
suffix += "[*]"
204+
return annotation, suffix
205+
206+
182207
def _is_pydantic_model(annotation: Any) -> bool:
183208
return isinstance(annotation, type) and issubclass(annotation, BaseModel)
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
from typing import Optional
2+
3+
from pydantic import BaseModel, RootModel
4+
5+
from uipath_langchain.agent.react.json_utils import (
6+
extract_values_by_paths,
7+
get_json_paths_by_type,
8+
)
9+
10+
11+
class Target(BaseModel):
12+
id: str
13+
14+
15+
class Nested(BaseModel):
16+
target: Target
17+
name: str
18+
19+
20+
class WithList(BaseModel):
21+
items: list[Target]
22+
23+
24+
class WithOptional(BaseModel):
25+
target: Optional[Target] = None
26+
27+
28+
class WithNestedList(BaseModel):
29+
matrix: list[list[Target]]
30+
31+
32+
class Mixed(BaseModel):
33+
direct: Target
34+
items: list[Target]
35+
nested: Nested
36+
plain: str
37+
38+
39+
# -- get_json_paths_by_type: regular BaseModel ---------------------------------
40+
41+
42+
class TestGetJsonPathsByType:
43+
def test_direct_field(self):
44+
paths = get_json_paths_by_type(Nested, "Target")
45+
assert paths == ["$.target"]
46+
47+
def test_list_field(self):
48+
paths = get_json_paths_by_type(WithList, "Target")
49+
assert paths == ["$.items[*]"]
50+
51+
def test_optional_field(self):
52+
paths = get_json_paths_by_type(WithOptional, "Target")
53+
assert paths == ["$.target"]
54+
55+
def test_nested_list_of_lists(self):
56+
paths = get_json_paths_by_type(WithNestedList, "Target")
57+
assert paths == ["$.matrix[*][*]"]
58+
59+
def test_mixed_fields(self):
60+
paths = get_json_paths_by_type(Mixed, "Target")
61+
assert set(paths) == {"$.direct", "$.items[*]", "$.nested.target"}
62+
63+
def test_no_match(self):
64+
class Unrelated(BaseModel):
65+
name: str
66+
value: int
67+
68+
paths = get_json_paths_by_type(Unrelated, "Target")
69+
assert paths == []
70+
71+
def test_nested_model_in_list(self):
72+
class Outer(BaseModel):
73+
groups: list[Nested]
74+
75+
paths = get_json_paths_by_type(Outer, "Target")
76+
assert paths == ["$.groups[*].target"]
77+
78+
79+
# -- get_json_paths_by_type: RootModel -----------------------------------------
80+
81+
82+
class TestGetJsonPathsByTypeRootModel:
83+
def test_root_model_single_object(self):
84+
Model = RootModel[Target]
85+
paths = get_json_paths_by_type(Model, "Target")
86+
assert paths == []
87+
88+
def test_root_model_list_of_target(self):
89+
"""Target is the leaf type itself — no nested fields of type Target within it."""
90+
Model = RootModel[list[Target]]
91+
paths = get_json_paths_by_type(Model, "Target")
92+
assert paths == []
93+
94+
def test_root_model_list_of_nested(self):
95+
Model = RootModel[list[Nested]]
96+
paths = get_json_paths_by_type(Model, "Target")
97+
assert paths == ["$[*].target"]
98+
99+
def test_root_model_list_of_list(self):
100+
Model = RootModel[list[list[Nested]]]
101+
paths = get_json_paths_by_type(Model, "Target")
102+
assert paths == ["$[*][*].target"]
103+
104+
def test_root_model_primitive(self):
105+
Model = RootModel[str]
106+
paths = get_json_paths_by_type(Model, "Target")
107+
assert paths == []
108+
109+
def test_root_model_list_of_primitives(self):
110+
Model = RootModel[list[str]]
111+
paths = get_json_paths_by_type(Model, "Target")
112+
assert paths == []
113+
114+
def test_root_model_optional_list(self):
115+
Model = RootModel[Optional[list[Nested]]]
116+
paths = get_json_paths_by_type(Model, "Target")
117+
assert paths == ["$[*].target"]
118+
119+
120+
# -- extract_values_by_paths ---------------------------------------------------
121+
122+
123+
class TestExtractValuesByPaths:
124+
def test_extract_from_dict(self):
125+
data = {"target": {"id": "1"}, "name": "test"}
126+
values = extract_values_by_paths(data, ["$.target"])
127+
assert values == [{"id": "1"}]
128+
129+
def test_extract_from_list(self):
130+
data = {"items": [{"id": "1"}, {"id": "2"}]}
131+
values = extract_values_by_paths(data, ["$.items[*]"])
132+
assert values == [{"id": "1"}, {"id": "2"}]
133+
134+
def test_extract_from_pydantic_model(self):
135+
obj = Nested(target=Target(id="1"), name="test")
136+
values = extract_values_by_paths(obj, ["$.target"])
137+
assert values == [{"id": "1"}]
138+
139+
def test_extract_multiple_paths(self):
140+
data = {
141+
"direct": {"id": "1"},
142+
"items": [{"id": "2"}, {"id": "3"}],
143+
}
144+
values = extract_values_by_paths(data, ["$.direct", "$.items[*]"])
145+
assert values == [{"id": "1"}, {"id": "2"}, {"id": "3"}]
146+
147+
def test_extract_no_paths(self):
148+
values = extract_values_by_paths({"a": 1}, [])
149+
assert values == []
150+
151+
def test_extract_path_not_found(self):
152+
values = extract_values_by_paths({"a": 1}, ["$.missing"])
153+
assert values == []

uv.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)