Skip to content

Commit a62ab10

Browse files
committed
Merge branch 'main' of https://github.com/UiPath/uipath-langchain-python into josh/cas-interrupt
2 parents 4bec7b8 + 2126bba commit a62ab10

50 files changed

Lines changed: 3153 additions & 843 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.7.8"
3+
version = "0.8.6"
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"
77
dependencies = [
8-
"uipath>=2.9.12, <2.10.0",
8+
"uipath>=2.10.0, <2.11.0",
99
"uipath-core>=0.5.2, <0.6.0",
10-
"uipath-platform>=0.0.4, < 0.1.0",
10+
"uipath-platform>=0.0.8, <0.1.0",
1111
"uipath-runtime>=0.9.1, <0.10.0",
1212
"langgraph>=1.0.0, <2.0.0",
1313
"langchain-core>=1.2.11, <2.0.0",
@@ -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",
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from ._environment import get_execution_folder_path
12
from ._request_mixin import UiPathRequestMixin
23

3-
__all__ = ["UiPathRequestMixin"]
4+
__all__ = ["UiPathRequestMixin", "get_execution_folder_path"]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import os
2+
3+
4+
def get_execution_folder_path() -> str | None:
5+
"""Reads the agent's executing folder path from the runtime environment."""
6+
return os.environ.get("UIPATH_FOLDER_PATH")

src/uipath_langchain/_utils/_request_mixin.py

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
get_uipath_token_header,
3434
)
3535
from uipath_langchain._utils._sleep_policy import before_sleep_log
36+
from uipath_langchain.chat.http_client import build_uipath_headers, resolve_gateway_url
3637
from uipath_langchain.runtime.errors import (
3738
LangGraphErrorCode,
3839
LangGraphRuntimeError,
@@ -78,8 +79,6 @@ class UiPathRequestMixin(BaseModel):
7879

7980
default_headers: Mapping[str, str] | None = {
8081
"X-UiPath-Streaming-Enabled": "false",
81-
"X-UiPath-JobKey": os.getenv("UIPATH_JOB_KEY", ""),
82-
"X-UiPath-ProcessKey": os.getenv("UIPATH_PROCESS_KEY", ""),
8382
}
8483
model_name: str | None = Field(
8584
default_factory=lambda: os.getenv(
@@ -154,6 +153,7 @@ class UiPathRequestMixin(BaseModel):
154153
max_delay: float = 60.0
155154

156155
_url: str | None = None
156+
_is_override: bool = False
157157
_auth_headers: dict[str, str] | None = None
158158

159159
# required to instantiate AzureChatOpenAI subclasses
@@ -731,17 +731,26 @@ def _prepare_url(self, url: str) -> httpx.URL:
731731
def _build_headers(self, options, retries_taken: int = 0) -> httpx.Headers:
732732
return httpx.Headers(self.auth_headers)
733733

734+
def _resolve_url_and_override(self) -> None:
735+
"""Resolve ``_url`` and ``_is_override`` idempotently."""
736+
if self._url:
737+
return
738+
try:
739+
self._url, self._is_override = resolve_gateway_url(self.endpoint)
740+
except ValueError:
741+
self._url = (
742+
f"{self.base_url}/{self.org_id}/{self.tenant_id}/{self.endpoint}"
743+
)
744+
except NotImplementedError:
745+
pass
746+
734747
@property
735748
def url(self) -> str:
749+
self._resolve_url_and_override()
736750
if not self._url:
737-
env_uipath_url = os.getenv("UIPATH_URL")
738-
739-
if env_uipath_url:
740-
self._url = f"{env_uipath_url.rstrip('/')}/{self.endpoint}"
741-
else:
742-
self._url = (
743-
f"{self.base_url}/{self.org_id}/{self.tenant_id}/{self.endpoint}"
744-
)
751+
raise NotImplementedError(
752+
"The endpoint property is not implemented for this class."
753+
)
745754
return self._url
746755

747756
@property
@@ -753,17 +762,21 @@ def endpoint(self) -> str:
753762
@property
754763
def auth_headers(self) -> dict[str, str]:
755764
if not self._auth_headers:
765+
self._resolve_url_and_override()
756766
self._auth_headers = {
757767
**self.default_headers, # type: ignore
758-
"Authorization": f"Bearer {self.access_token}",
759-
"X-UiPath-LlmGateway-TimeoutSeconds": str(self.default_request_timeout),
760768
}
761-
if self.agenthub_config:
762-
self._auth_headers["X-UiPath-AgentHub-Config"] = self.agenthub_config
763-
if self.byo_connection_id:
764-
self._auth_headers["X-UiPath-LlmGateway-ByoIsConnectionId"] = (
765-
self.byo_connection_id
769+
self._auth_headers["Authorization"] = f"Bearer {self.access_token}"
770+
self._auth_headers.update(
771+
build_uipath_headers(
772+
agenthub_config=self.agenthub_config,
773+
byo_connection_id=self.byo_connection_id,
774+
inject_routing=self._is_override,
766775
)
776+
)
777+
self._auth_headers["X-UiPath-LlmGateway-TimeoutSeconds"] = str(
778+
self.default_request_timeout
779+
)
767780
if self.is_normalized and self.model_name:
768781
self._auth_headers["X-UiPath-LlmGateway-NormalizedApi-ModelName"] = (
769782
self.model_name

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)

src/uipath_langchain/agent/react/jsonschema_pydantic_converter.py

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import inspect
22
import sys
33
from types import ModuleType
4-
from typing import Any, Type, get_args, get_origin
4+
from typing import Any, Type
55

66
from jsonschema_pydantic_converter import transform_with_modules
77
from pydantic import BaseModel
@@ -37,53 +37,17 @@ def create_model(
3737
)
3838

3939
model, namespace = transform_with_modules(schema)
40-
corrected_namespace: dict[str, Any] = {}
41-
42-
def collect_types(annotation: Any) -> None:
43-
"""Recursively collect all BaseModel types from an annotation."""
44-
# Unwrap generic types like List, Optional, etc.
45-
origin = get_origin(annotation)
46-
if origin is not None:
47-
for arg in get_args(annotation):
48-
collect_types(arg)
49-
50-
elif inspect.isclass(annotation) and issubclass(annotation, BaseModel):
51-
# Find the original name for this type from the namespace
52-
for type_name, type_def in namespace.items():
53-
# Match by class name since rebuild may create new instances
54-
if (
55-
hasattr(annotation, "__name__")
56-
and hasattr(type_def, "__name__")
57-
and annotation.__name__ == type_def.__name__
58-
):
59-
# Store the actual annotation type, not the old namespace one
60-
annotation.__name__ = type_name
61-
corrected_namespace[type_name] = annotation
62-
break
63-
64-
# Collect all types from field annotations
65-
for field_info in model.model_fields.values():
66-
collect_types(field_info.annotation)
67-
68-
# Get the shared pseudo-module and populate it with this schema's types
69-
# This ensures that forward references can be resolved by get_type_hints()
70-
# when the model is used with external libraries (e.g., LangGraph)
40+
7141
pseudo_module = _get_or_create_dynamic_module()
7242

73-
# Populate the pseudo-module with all types from the namespace
74-
# Use the original names so forward references resolve correctly
75-
for type_name, type_def in corrected_namespace.items():
43+
for type_name, type_def in namespace.items():
7644
setattr(pseudo_module, type_name, type_def)
45+
if inspect.isclass(type_def) and issubclass(type_def, BaseModel):
46+
type_def.__module__ = _DYNAMIC_MODULE_NAME
7747

7848
setattr(pseudo_module, model.__name__, model)
79-
80-
# Update the model's __module__ to point to the shared pseudo-module
8149
model.__module__ = _DYNAMIC_MODULE_NAME
8250

83-
# Update the __module__ of all generated types in the namespace
84-
for type_def in corrected_namespace.values():
85-
if inspect.isclass(type_def) and issubclass(type_def, BaseModel):
86-
type_def.__module__ = _DYNAMIC_MODULE_NAME
8751
return model
8852

8953

src/uipath_langchain/agent/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .extraction_tool import create_ixp_extraction_tool
66
from .integration_tool import create_integration_tool
77
from .ixp_escalation_tool import create_ixp_escalation_tool
8+
from .mcp import open_mcp_tools
89
from .process_tool import create_process_tool
910
from .tool_factory import (
1011
create_tools_from_resources,
@@ -15,6 +16,7 @@
1516
"create_tools_from_resources",
1617
"create_tool_node",
1718
"create_context_tool",
19+
"open_mcp_tools",
1820
"create_process_tool",
1921
"create_integration_tool",
2022
"create_escalation_tool",

0 commit comments

Comments
 (0)