Skip to content

Commit 123c597

Browse files
feat: use execution folder instead of dummy folder paths (#653)
1 parent 7dad0d7 commit 123c597

12 files changed

Lines changed: 159 additions & 15 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.7.16"
3+
version = "0.7.17"
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"
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/agent/tools/context_tool.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
)
2222
from uipath.runtime.errors import UiPathErrorCategory
2323

24+
from uipath_langchain._utils import get_execution_folder_path
2425
from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode
2526
from uipath_langchain.agent.react.jsonschema_pydantic_converter import (
2627
create_model as create_model_from_schema,
@@ -68,7 +69,7 @@ def handle_semantic_search(
6869

6970
retriever = ContextGroundingRetriever(
7071
index_name=resource.index_name,
71-
folder_path=resource.folder_path,
72+
folder_path=get_execution_folder_path(),
7273
number_of_results=resource.settings.result_count,
7374
)
7475

@@ -204,7 +205,7 @@ async def create_deep_rag():
204205
index_name=index_name,
205206
prompt=actual_prompt,
206207
citation_mode=citation_mode,
207-
index_folder_path=resource.folder_path,
208+
index_folder_path=get_execution_folder_path(),
208209
glob_pattern=glob_pattern,
209210
)
210211

@@ -234,7 +235,7 @@ def handle_batch_transform(
234235
assert resource.settings.query.variant is not None
235236

236237
index_name = resource.index_name
237-
index_folder_path = resource.folder_path
238+
index_folder_path = get_execution_folder_path()
238239
if not resource.settings.web_search_grounding:
239240
raise AgentStartupError(
240241
code=AgentStartupErrorCode.INVALID_TOOL_CONFIG,

src/uipath_langchain/agent/tools/escalation_tool.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from uipath.platform.common import WaitEscalation
2121
from uipath.runtime.errors import UiPathErrorCategory
2222

23+
from uipath_langchain._utils import get_execution_folder_path
2324
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
2425
from uipath_langchain.agent.tools.static_args import (
2526
handle_static_args,
@@ -51,7 +52,7 @@ async def resolve_recipient_value(
5152
) -> TaskRecipient | None:
5253
"""Resolve recipient value based on recipient type."""
5354
if isinstance(recipient, AssetRecipient):
54-
value = await resolve_asset(recipient.asset_name, recipient.folder_path)
55+
value = await resolve_asset(recipient.asset_name, get_execution_folder_path())
5556
type = None
5657
if recipient.type == AgentEscalationRecipientType.ASSET_USER_EMAIL:
5758
type = TaskRecipientType.EMAIL
@@ -70,7 +71,7 @@ async def resolve_recipient_value(
7071
return None
7172

7273

73-
async def resolve_asset(asset_name: str, folder_path: str) -> str | None:
74+
async def resolve_asset(asset_name: str, folder_path: str | None) -> str | None:
7475
"""Retrieve asset value."""
7576
try:
7677
client = UiPath()
@@ -162,6 +163,7 @@ async def escalation_tool_fn(**kwargs: Any) -> dict[str, Any]:
162163
if channel.recipients
163164
else None
164165
)
166+
folder_path = get_execution_folder_path()
165167

166168
task_title = "Escalation Task"
167169
if tool.metadata is not None:
@@ -186,7 +188,7 @@ async def create_escalation_task():
186188
title=task_title,
187189
data=serialized_data,
188190
app_name=channel.properties.app_name,
189-
app_folder_path=channel.properties.folder_name or "",
191+
app_folder_path=folder_path,
190192
recipient=recipient,
191193
priority=channel.priority,
192194
labels=channel.labels,
@@ -199,7 +201,7 @@ async def create_escalation_task():
199201

200202
return WaitEscalation(
201203
action=created_task,
202-
app_folder_path=channel.properties.folder_name,
204+
app_folder_path=folder_path,
203205
app_name=channel.properties.app_name,
204206
recipient=recipient,
205207
)

src/uipath_langchain/agent/tools/mcp/mcp_client.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from uipath._utils._ssl_context import get_httpx_client_kwargs
2020
from uipath.runtime.base import UiPathDisposableProtocol
2121

22+
from uipath_langchain._utils import get_execution_folder_path
23+
2224
from .streamable_http import SessionInfo, streamable_http_client
2325

2426
if TYPE_CHECKING:
@@ -137,9 +139,10 @@ async def _initialize_client(self) -> None:
137139
138140
Then calls _initialize_session() to complete the MCP handshake.
139141
"""
142+
folder_path = get_execution_folder_path()
140143
logger.debug(
141144
f"Initializing MCP client for '{self._config.slug}' "
142-
f"in folder '{self._config.folder_path}'"
145+
f"in folder '{folder_path}'"
143146
)
144147

145148
# Lazy import to improve cold start time
@@ -148,7 +151,7 @@ async def _initialize_client(self) -> None:
148151
# Retrieve MCP server URL from SDK
149152
sdk = UiPath()
150153
mcp_server = await sdk.mcp.retrieve_async(
151-
slug=self._config.slug, folder_path=self._config.folder_path
154+
slug=self._config.slug, folder_path=folder_path
152155
)
153156

154157
if mcp_server.mcp_url is None:

src/uipath_langchain/agent/tools/process_tool.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from uipath.platform.common import WaitJobRaw
1313
from uipath.platform.orchestrator import JobState
1414

15+
from uipath_langchain._utils import get_execution_folder_path
1516
from uipath_langchain.agent.react.job_attachments import get_job_attachments
1617
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
1718
from uipath_langchain.agent.react.types import AgentGraphState
@@ -34,7 +35,7 @@ def create_process_tool(resource: AgentProcessToolResourceConfig) -> StructuredT
3435

3536
tool_name: str = sanitize_tool_name(resource.name)
3637
process_name = resource.properties.process_name
37-
folder_path = resource.properties.folder_path
38+
folder_path = get_execution_folder_path()
3839

3940
input_model: Any = create_model(resource.input_schema)
4041
output_model: Any = create_model(resource.output_schema)

tests/agent/tools/test_context_tool.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for context_tool.py module."""
22

3-
from unittest.mock import AsyncMock, patch
3+
import os
4+
from unittest.mock import AsyncMock, MagicMock, patch
45

56
import pytest
67
from langchain_core.documents import Document
@@ -271,6 +272,27 @@ async def test_dynamic_query_uses_provided_query(self, base_resource_config):
271272
call_args = mock_interrupt.call_args[0][0]
272273
assert call_args.prompt == "runtime provided query"
273274

275+
@pytest.mark.asyncio
276+
@patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Shared/TestFolder"})
277+
async def test_deep_rag_uses_execution_folder_path(self, base_resource_config):
278+
"""Test that CreateDeepRag receives index_folder_path from the execution environment."""
279+
resource = base_resource_config(
280+
query_variant="static",
281+
query_value="test query",
282+
citation_mode_value=AgentContextValueSetting(value="Inline"),
283+
)
284+
tool = handle_deep_rag("test_tool", resource)
285+
286+
with patch(
287+
"uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt"
288+
) as mock_interrupt:
289+
mock_interrupt.return_value = {"mocked": "response"}
290+
assert tool.coroutine is not None
291+
await tool.coroutine()
292+
293+
deep_rag_arg = mock_interrupt.call_args[0][0]
294+
assert deep_rag_arg.index_folder_path == "/Shared/TestFolder"
295+
274296

275297
class TestCreateContextTool:
276298
"""Test cases for create_context_tool function."""
@@ -490,6 +512,17 @@ async def test_static_query_uses_predefined_query(self):
490512
assert "documents" in result
491513
assert len(result["documents"]) == 1
492514

515+
@patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Shared/TestFolder"})
516+
def test_semantic_search_uses_execution_folder_path(self, semantic_config):
517+
"""Test that ContextGroundingRetriever receives folder_path from the execution environment."""
518+
with patch(
519+
"uipath_langchain.agent.tools.context_tool.ContextGroundingRetriever"
520+
) as mock_retriever_class:
521+
handle_semantic_search("semantic_tool", semantic_config)
522+
523+
call_kwargs = mock_retriever_class.call_args[1]
524+
assert call_kwargs["folder_path"] == "/Shared/TestFolder"
525+
493526

494527
class TestHandleBatchTransform:
495528
"""Test cases for handle_batch_transform function."""
@@ -771,6 +804,32 @@ async def test_dynamic_query_batch_transform_uses_default_destination_path(self)
771804
assert call_args.prompt == "runtime provided query"
772805
assert call_args.destination_path == "output.csv"
773806

807+
@pytest.mark.asyncio
808+
@patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Shared/TestFolder"})
809+
async def test_batch_transform_uses_execution_folder_path(
810+
self, batch_transform_config
811+
):
812+
"""Test that CreateBatchTransform receives index_folder_path from the execution environment."""
813+
tool = handle_batch_transform("batch_transform_tool", batch_transform_config)
814+
815+
mock_uipath = MagicMock()
816+
mock_uipath.jobs.create_attachment_async = AsyncMock(return_value="att-id")
817+
with (
818+
patch(
819+
"uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt"
820+
) as mock_interrupt,
821+
patch(
822+
"uipath_langchain.agent.tools.context_tool.UiPath",
823+
return_value=mock_uipath,
824+
),
825+
):
826+
mock_interrupt.return_value = MagicMock()
827+
assert tool.coroutine is not None
828+
await tool.coroutine(destination_path="output.csv")
829+
830+
batch_transform_arg = mock_interrupt.call_args[0][0]
831+
assert batch_transform_arg.index_folder_path == "/Shared/TestFolder"
832+
774833

775834
class TestBuildGlobPattern:
776835
"""Test cases for build_glob_pattern function."""

tests/agent/tools/test_escalation_tool.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for escalation_tool.py metadata."""
22

3+
import os
34
from unittest.mock import AsyncMock, MagicMock, patch
45

56
import pytest
@@ -111,6 +112,7 @@ class TestResolveRecipientValue:
111112
"""Test the resolve_recipient_value function."""
112113

113114
@pytest.mark.asyncio
115+
@patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Test/Folder"})
114116
@patch("uipath_langchain.agent.tools.escalation_tool.resolve_asset")
115117
async def test_resolve_recipient_asset_user_email(self, mock_resolve_asset):
116118
"""Test ASSET_USER_EMAIL type calls resolve_asset."""
@@ -132,6 +134,7 @@ async def test_resolve_recipient_asset_user_email(self, mock_resolve_asset):
132134
mock_resolve_asset.assert_called_once_with("email_asset", "/Test/Folder")
133135

134136
@pytest.mark.asyncio
137+
@patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Test/Folder"})
135138
@patch("uipath_langchain.agent.tools.escalation_tool.resolve_asset")
136139
async def test_resolve_recipient_asset_group_name(self, mock_resolve_asset):
137140
"""Test ASSET_GROUP_NAME type calls resolve_asset."""
@@ -834,6 +837,34 @@ async def test_creates_task_then_interrupts_with_wait_escalation(
834837
assert isinstance(interrupt_arg, WaitEscalation)
835838
assert interrupt_arg.action == task
836839

840+
@pytest.mark.asyncio
841+
@patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Test/Folder"})
842+
@patch("uipath_langchain.agent.tools.escalation_tool.UiPath")
843+
@patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt")
844+
async def test_creates_task_with_execution_folder_path(
845+
self, mock_interrupt, mock_uipath_class, escalation_resource
846+
):
847+
"""Test that tasks.create_async receives app_folder_path from the execution environment."""
848+
task = _make_mock_task(id=555)
849+
mock_client = MagicMock()
850+
mock_client.tasks.create_async = AsyncMock(return_value=task)
851+
mock_uipath_class.return_value = mock_client
852+
853+
mock_result = MagicMock()
854+
mock_result.id = 555
855+
mock_result.action = "approve"
856+
mock_result.data = {}
857+
mock_result.assigned_to_user = None
858+
mock_result.is_deleted = False
859+
mock_interrupt.return_value = mock_result
860+
861+
tool = create_escalation_tool(escalation_resource)
862+
call = ToolCall(args={}, id="test-call", name=tool.name)
863+
await tool.awrapper(tool, call, {}) # type: ignore[attr-defined]
864+
865+
create_call_kwargs = mock_client.tasks.create_async.call_args[1]
866+
assert create_call_kwargs["app_folder_path"] == "/Test/Folder"
867+
837868
@pytest.mark.asyncio
838869
@patch("uipath_langchain.agent.tools.escalation_tool.UiPath")
839870
async def test_task_creation_failure_propagates(

tests/agent/tools/test_mcp/test_mcp_client.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import logging
5+
import os
56
from typing import Any
67
from unittest.mock import AsyncMock, MagicMock, patch
78

@@ -780,3 +781,38 @@ async def test_list_tools_reuses_session(
780781
assert list_tools_count == 2
781782

782783
await client.dispose()
784+
785+
@pytest.mark.asyncio
786+
@patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Shared/TestFolder"})
787+
@patch("httpx.AsyncClient")
788+
async def test_retrieve_async_uses_execution_folder_path(
789+
self, mock_async_client_class, mcp_resource_config
790+
):
791+
"""Test that retrieve_async is called with folder_path from the execution environment."""
792+
mock_sdk = MagicMock()
793+
mock_server = MagicMock()
794+
mock_server.mcp_url = "https://test.uipath.com/mcp"
795+
mock_sdk.mcp.retrieve_async = AsyncMock(return_value=mock_server)
796+
mock_sdk._config = MagicMock()
797+
mock_sdk._config.secret = "test-secret-token"
798+
799+
method_call_sequence: list[str] = []
800+
initialize_count = [0]
801+
tool_call_count = [0]
802+
803+
MockStreamResponse = self.create_mock_stream_response(
804+
method_call_sequence, initialize_count, tool_call_count
805+
)
806+
mock_http_client = self.create_mock_http_client(MockStreamResponse)
807+
mock_async_client_class.return_value = mock_http_client
808+
809+
session = McpClient(config=mcp_resource_config)
810+
811+
with patch("uipath.platform.UiPath", return_value=mock_sdk):
812+
await session.call_tool("test_tool", {"query": "test"})
813+
814+
mock_sdk.mcp.retrieve_async.assert_called_once_with(
815+
slug="test-server", folder_path="/Shared/TestFolder"
816+
)
817+
818+
await session.dispose()

0 commit comments

Comments
 (0)