Skip to content

Commit 9265a24

Browse files
Merge pull request #387 from UiPath/feat/asset-recipient
feat: add asset recipient support for escalations
2 parents 9f5788e + c3ebb21 commit 9265a24

3 files changed

Lines changed: 222 additions & 18 deletions

File tree

src/uipath_langchain/agent/tools/escalation_tool.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
from langgraph.types import Command, interrupt
1010
from uipath.agent.models.agent import (
1111
AgentEscalationChannel,
12-
AgentEscalationRecipientType,
12+
AgentEscalationRecipient,
1313
AgentEscalationResourceConfig,
14+
AssetRecipient,
15+
StandardRecipient,
1416
)
1517
from uipath.eval.mocks import mockable
18+
from uipath.platform import UiPath
1619
from uipath.platform.common import CreateEscalation
1720

1821
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
@@ -29,11 +32,42 @@ class EscalationAction(str, Enum):
2932
END = "end"
3033

3134

35+
async def resolve_recipient_value(recipient: AgentEscalationRecipient) -> str | None:
36+
"""Resolve recipient value based on recipient type."""
37+
if isinstance(recipient, AssetRecipient):
38+
return await resolve_asset(recipient.asset_name, recipient.folder_path)
39+
40+
if isinstance(recipient, StandardRecipient):
41+
return recipient.value
42+
43+
return None
44+
45+
46+
async def resolve_asset(asset_name: str, folder_path: str) -> str | None:
47+
"""Retrieve asset value."""
48+
try:
49+
client = UiPath()
50+
result = await client.assets.retrieve_async(
51+
name=asset_name, folder_path=folder_path
52+
)
53+
54+
if not result or not result.value:
55+
raise ValueError(f"Asset '{asset_name}' has no value configured.")
56+
57+
return result.value
58+
except Exception as e:
59+
raise ValueError(
60+
f"Failed to resolve asset '{asset_name}' in folder '{folder_path}': {str(e)}"
61+
) from e
62+
63+
3264
class StructuredToolWithWrapper(StructuredTool, ToolWrapperMixin):
3365
pass
3466

3567

36-
def create_escalation_tool(resource: AgentEscalationResourceConfig) -> BaseTool:
68+
async def create_escalation_tool(
69+
resource: AgentEscalationResourceConfig,
70+
) -> StructuredTool:
3771
"""Uses interrupt() for Action Center human-in-the-loop."""
3872

3973
tool_name: str = f"escalate_{sanitize_tool_name(resource.name)}"
@@ -43,9 +77,8 @@ def create_escalation_tool(resource: AgentEscalationResourceConfig) -> BaseTool:
4377
output_model: Any = create_model(channel.output_schema)
4478

4579
assignee: str | None = (
46-
channel.recipients[0].value
80+
await resolve_recipient_value(channel.recipients[0])
4781
if channel.recipients
48-
and channel.recipients[0].type == AgentEscalationRecipientType.USER_EMAIL
4982
else None
5083
)
5184

src/uipath_langchain/agent/tools/tool_factory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ async def _build_tool_for_resource(
4242
return create_context_tool(resource)
4343

4444
elif isinstance(resource, AgentEscalationResourceConfig):
45-
return create_escalation_tool(resource)
45+
return await create_escalation_tool(resource)
4646

4747
elif isinstance(resource, AgentIntegrationToolResourceConfig):
4848
return create_integration_tool(resource)

tests/agent/tools/test_escalation_tool.py

Lines changed: 184 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,180 @@
11
"""Tests for escalation_tool.py metadata."""
22

3+
from unittest.mock import AsyncMock, MagicMock, patch
4+
35
import pytest
46
from uipath.agent.models.agent import (
57
AgentEscalationChannel,
68
AgentEscalationChannelProperties,
79
AgentEscalationRecipientType,
810
AgentEscalationResourceConfig,
11+
AssetRecipient,
912
StandardRecipient,
1013
)
1114

12-
from uipath_langchain.agent.tools.escalation_tool import create_escalation_tool
15+
from uipath_langchain.agent.tools.escalation_tool import (
16+
create_escalation_tool,
17+
resolve_asset,
18+
resolve_recipient_value,
19+
)
20+
21+
22+
class TestResolveAsset:
23+
"""Test the resolve_asset function."""
24+
25+
@pytest.mark.asyncio
26+
@patch("uipath_langchain.agent.tools.escalation_tool.UiPath")
27+
async def test_resolve_asset_success(self, mock_uipath_class):
28+
"""Test successful asset retrieval."""
29+
# Setup mock
30+
mock_client = MagicMock()
31+
mock_uipath_class.return_value = mock_client
32+
mock_result = MagicMock()
33+
mock_result.value = "test@example.com"
34+
mock_client.assets.retrieve_async = AsyncMock(return_value=mock_result)
35+
36+
# Execute
37+
result = await resolve_asset("email_asset", "/Test/Folder")
38+
39+
# Assert
40+
assert result == "test@example.com"
41+
mock_client.assets.retrieve_async.assert_called_once_with(
42+
name="email_asset", folder_path="/Test/Folder"
43+
)
44+
45+
@pytest.mark.asyncio
46+
@patch("uipath_langchain.agent.tools.escalation_tool.UiPath")
47+
async def test_resolve_asset_no_value(self, mock_uipath_class):
48+
"""Test asset with no value raises ValueError."""
49+
# Setup mock
50+
mock_client = MagicMock()
51+
mock_uipath_class.return_value = mock_client
52+
mock_result = MagicMock()
53+
mock_result.value = None
54+
mock_client.assets.retrieve_async = AsyncMock(return_value=mock_result)
55+
56+
# Execute and assert
57+
with pytest.raises(ValueError) as exc_info:
58+
await resolve_asset("empty_asset", "/Test/Folder")
59+
60+
assert "Asset 'empty_asset' has no value configured" in str(exc_info.value)
61+
62+
@pytest.mark.asyncio
63+
@patch("uipath_langchain.agent.tools.escalation_tool.UiPath")
64+
async def test_resolve_asset_not_found(self, mock_uipath_class):
65+
"""Test asset not found raises ValueError."""
66+
# Setup mock
67+
mock_client = MagicMock()
68+
mock_uipath_class.return_value = mock_client
69+
mock_client.assets.retrieve_async = AsyncMock(return_value=None)
70+
71+
# Execute and assert
72+
with pytest.raises(ValueError) as exc_info:
73+
await resolve_asset("missing_asset", "/Test/Folder")
74+
75+
assert "Asset 'missing_asset' has no value configured" in str(exc_info.value)
76+
77+
@pytest.mark.asyncio
78+
@patch("uipath_langchain.agent.tools.escalation_tool.UiPath")
79+
async def test_resolve_asset_retrieval_exception(self, mock_uipath_class):
80+
"""Test exception during asset retrieval raises ValueError with context."""
81+
# Setup mock
82+
mock_client = MagicMock()
83+
mock_uipath_class.return_value = mock_client
84+
mock_client.assets.retrieve_async = AsyncMock(
85+
side_effect=Exception("Connection error")
86+
)
87+
88+
# Execute and assert
89+
with pytest.raises(ValueError) as exc_info:
90+
await resolve_asset("problem_asset", "/Test/Folder")
91+
92+
assert (
93+
"Failed to resolve asset 'problem_asset' in folder '/Test/Folder'"
94+
in str(exc_info.value)
95+
)
96+
assert "Connection error" in str(exc_info.value)
97+
98+
99+
class TestResolveRecipientValue:
100+
"""Test the resolve_recipient_value function."""
101+
102+
@pytest.mark.asyncio
103+
@patch("uipath_langchain.agent.tools.escalation_tool.resolve_asset")
104+
async def test_resolve_recipient_asset_user_email(self, mock_resolve_asset):
105+
"""Test ASSET_USER_EMAIL type calls resolve_asset."""
106+
mock_resolve_asset.return_value = "resolved@example.com"
107+
108+
recipient = AssetRecipient(
109+
type=AgentEscalationRecipientType.ASSET_USER_EMAIL,
110+
asset_name="email_asset",
111+
folder_path="/Test/Folder",
112+
)
113+
114+
result = await resolve_recipient_value(recipient)
115+
116+
assert result == "resolved@example.com"
117+
mock_resolve_asset.assert_called_once_with("email_asset", "/Test/Folder")
118+
119+
@pytest.mark.asyncio
120+
@patch("uipath_langchain.agent.tools.escalation_tool.resolve_asset")
121+
async def test_resolve_recipient_asset_group_name(self, mock_resolve_asset):
122+
"""Test ASSET_GROUP_NAME type calls resolve_asset."""
123+
mock_resolve_asset.return_value = "ResolvedGroup"
124+
125+
recipient = AssetRecipient(
126+
type=AgentEscalationRecipientType.ASSET_GROUP_NAME,
127+
asset_name="group_asset",
128+
folder_path="/Test/Folder",
129+
)
130+
131+
result = await resolve_recipient_value(recipient)
132+
133+
assert result == "ResolvedGroup"
134+
mock_resolve_asset.assert_called_once_with("group_asset", "/Test/Folder")
135+
136+
@pytest.mark.asyncio
137+
async def test_resolve_recipient_user_email(self):
138+
"""Test USER_EMAIL type returns value directly."""
139+
recipient = StandardRecipient(
140+
type=AgentEscalationRecipientType.USER_EMAIL,
141+
value="direct@example.com",
142+
)
143+
144+
result = await resolve_recipient_value(recipient)
145+
146+
assert result == "direct@example.com"
147+
148+
@pytest.mark.asyncio
149+
@patch("uipath_langchain.agent.tools.escalation_tool.resolve_asset")
150+
async def test_resolve_recipient_propagates_error_when_asset_resolution_fails(
151+
self, mock_resolve_asset
152+
):
153+
"""Test AssetRecipient when asset resolution fails."""
154+
mock_resolve_asset.side_effect = ValueError("Asset not found")
155+
156+
recipient = AssetRecipient(
157+
type=AgentEscalationRecipientType.ASSET_USER_EMAIL,
158+
asset_name="nonexistent",
159+
folder_path="Shared",
160+
)
161+
162+
with pytest.raises(ValueError) as exc_info:
163+
await resolve_recipient_value(recipient)
164+
165+
assert "Asset not found" in str(exc_info.value)
166+
167+
@pytest.mark.asyncio
168+
async def test_resolve_recipient_no_value(self):
169+
"""Test recipient without value attribute returns None."""
170+
# Create a minimal recipient object without value
171+
recipient = MagicMock()
172+
recipient.type = AgentEscalationRecipientType.USER_EMAIL
173+
del recipient.value # Simulate no value attribute
174+
175+
result = await resolve_recipient_value(recipient)
176+
177+
assert result is None
13178

14179

15180
class TestEscalationToolMetadata:
@@ -66,41 +231,47 @@ def escalation_resource_no_recipient(self):
66231
],
67232
)
68233

69-
def test_escalation_tool_has_metadata(self, escalation_resource):
234+
@pytest.mark.asyncio
235+
async def test_escalation_tool_has_metadata(self, escalation_resource):
70236
"""Test that escalation tool has metadata dict."""
71-
tool = create_escalation_tool(escalation_resource)
237+
tool = await create_escalation_tool(escalation_resource)
72238

73239
assert tool.metadata is not None
74240
assert isinstance(tool.metadata, dict)
75241

76-
def test_escalation_tool_metadata_has_tool_type(self, escalation_resource):
242+
@pytest.mark.asyncio
243+
async def test_escalation_tool_metadata_has_tool_type(self, escalation_resource):
77244
"""Test that metadata contains tool_type for span detection."""
78-
tool = create_escalation_tool(escalation_resource)
245+
tool = await create_escalation_tool(escalation_resource)
79246
assert tool.metadata is not None
80247
assert tool.metadata["tool_type"] == "escalation"
81248

82-
def test_escalation_tool_metadata_has_display_name(self, escalation_resource):
249+
@pytest.mark.asyncio
250+
async def test_escalation_tool_metadata_has_display_name(self, escalation_resource):
83251
"""Test that metadata contains display_name from app_name."""
84-
tool = create_escalation_tool(escalation_resource)
252+
tool = await create_escalation_tool(escalation_resource)
85253
assert tool.metadata is not None
86254
assert tool.metadata["display_name"] == "ApprovalApp"
87255

88-
def test_escalation_tool_metadata_has_channel_type(self, escalation_resource):
256+
@pytest.mark.asyncio
257+
async def test_escalation_tool_metadata_has_channel_type(self, escalation_resource):
89258
"""Test that metadata contains channel_type for span attributes."""
90-
tool = create_escalation_tool(escalation_resource)
259+
tool = await create_escalation_tool(escalation_resource)
91260
assert tool.metadata is not None
92261
assert tool.metadata["channel_type"] == "actionCenter"
93262

94-
def test_escalation_tool_metadata_has_assignee(self, escalation_resource):
263+
@pytest.mark.asyncio
264+
async def test_escalation_tool_metadata_has_assignee(self, escalation_resource):
95265
"""Test that metadata contains assignee when recipient is USER_EMAIL."""
96-
tool = create_escalation_tool(escalation_resource)
266+
tool = await create_escalation_tool(escalation_resource)
97267
assert tool.metadata is not None
98268
assert tool.metadata["assignee"] == "user@example.com"
99269

100-
def test_escalation_tool_metadata_assignee_none_when_no_recipients(
270+
@pytest.mark.asyncio
271+
async def test_escalation_tool_metadata_assignee_none_when_no_recipients(
101272
self, escalation_resource_no_recipient
102273
):
103274
"""Test that assignee is None when no recipients configured."""
104-
tool = create_escalation_tool(escalation_resource_no_recipient)
275+
tool = await create_escalation_tool(escalation_resource_no_recipient)
105276
assert tool.metadata is not None
106277
assert tool.metadata["assignee"] is None

0 commit comments

Comments
 (0)