From e520b73c055da6e16746d2ae928dec21f11d6d8a Mon Sep 17 00:00:00 2001 From: "diana.grecu" Date: Wed, 17 Dec 2025 14:31:10 +0200 Subject: [PATCH 1/2] feat: add support for asset recipient in escalations --- .../agent/tools/escalation_tool.py | 35 +++++- tests/agent/tools/test_escalation_tool.py | 107 ++++++++++++++++++ 2 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 tests/agent/tools/test_escalation_tool.py diff --git a/src/uipath_langchain/agent/tools/escalation_tool.py b/src/uipath_langchain/agent/tools/escalation_tool.py index ece076f0..602cd047 100644 --- a/src/uipath_langchain/agent/tools/escalation_tool.py +++ b/src/uipath_langchain/agent/tools/escalation_tool.py @@ -10,10 +10,12 @@ from langgraph.types import Command, interrupt from uipath.agent.models.agent import ( AgentEscalationChannel, + AgentEscalationRecipient, AgentEscalationRecipientType, AgentEscalationResourceConfig, ) from uipath.eval.mocks import mockable +from uipath.platform import UiPath from uipath.platform.common import CreateEscalation from ..react.types import AgentGraphNode, AgentTerminationSource @@ -27,6 +29,34 @@ class EscalationAction(str, Enum): END = "end" +def resolve_recipient_value(recipient: AgentEscalationRecipient) -> str | None: + """Resolve recipient value based on recipient type.""" + if recipient.type == AgentEscalationRecipientType.ASSET_USER_EMAIL: + return resolve_asset(recipient.asset_name, recipient.folder_path) + + # For USER_EMAIL, USER_ID, and GROUP_ID, return the value directly + if hasattr(recipient, "value"): + return recipient.value + + return None + + +def resolve_asset(asset_name: str, folder_path: str) -> str | None: + """Retrieve asset value.""" + try: + client = UiPath() + result = client.assets.retrieve(name=asset_name, folder_path=folder_path) + + if not result or not result.value: + raise ValueError(f"Asset '{asset_name}' has no value configured.") + + return result.value + except Exception as e: + raise ValueError( + f"Failed to resolve asset '{asset_name}' in folder '{folder_path}': {str(e)}" + ) from e + + def create_escalation_tool(resource: AgentEscalationResourceConfig) -> StructuredTool: """Uses interrupt() for Action Center human-in-the-loop.""" @@ -37,10 +67,7 @@ def create_escalation_tool(resource: AgentEscalationResourceConfig) -> Structure output_model: Any = create_model(channel.output_schema) assignee: str | None = ( - channel.recipients[0].value - if channel.recipients - and channel.recipients[0].type == AgentEscalationRecipientType.USER_EMAIL - else None + resolve_recipient_value(channel.recipients[0]) if channel.recipients else None ) @mockable( diff --git a/tests/agent/tools/test_escalation_tool.py b/tests/agent/tools/test_escalation_tool.py new file mode 100644 index 00000000..eeaefea4 --- /dev/null +++ b/tests/agent/tools/test_escalation_tool.py @@ -0,0 +1,107 @@ +"""Tests for escalation_tool.py module.""" + +import pytest +from unittest.mock import Mock, patch + +from uipath.agent.models.agent import ( + AgentEscalationRecipientType, + StandardRecipient, + AssetRecipient, +) +from uipath_langchain.agent.tools.escalation_tool import ( + resolve_recipient_value, + resolve_asset, +) + + +class TestResolveAsset: + """Tests for resolve_asset function.""" + + @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + def test_resolve_asset_success(self, mock_uipath): + """Test successful asset resolution.""" + # Mock the asset retrieval + mock_client = Mock() + mock_result = Mock() + mock_result.value = "test@example.com" + mock_client.assets.retrieve.return_value = mock_result + mock_uipath.return_value = mock_client + + result = resolve_asset("email_asset", "Shared") + + assert result == "test@example.com" + mock_client.assets.retrieve.assert_called_once_with( + name="email_asset", folder_path="Shared" + ) + + @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + def test_resolve_asset_not_found(self, mock_uipath): + """Test asset resolution when asset doesn't exist.""" + mock_client = Mock() + mock_client.assets.retrieve.side_effect = Exception("Asset not found") + mock_uipath.return_value = mock_client + + with pytest.raises(Exception) as exc_info: + resolve_asset("nonexistent_asset", "Shared") + + assert "Asset not found" in str(exc_info.value) + + @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + def test_resolve_asset_raises_error_when_value_is_empty(self, mock_uipath): + """Test asset resolution raises error when asset value is empty.""" + mock_client = Mock() + mock_result = Mock() + mock_result.value = None + mock_client.assets.retrieve.return_value = mock_result + mock_uipath.return_value = mock_client + + with pytest.raises(ValueError) as exc_info: + resolve_asset("empty_asset", "Shared") + + assert "has no value" in str(exc_info.value) + + +class TestResolveRecipientValue: + """Tests for resolve_recipient_value function.""" + + def test_resolve_recipient_value_returns_email_for_user_email_type(self): + """Test resolving StandardRecipient with USER_EMAIL.""" + recipient = StandardRecipient( + type=AgentEscalationRecipientType.USER_EMAIL, value="user@example.com" + ) + + result = resolve_recipient_value(recipient) + + assert result == "user@example.com" + + @patch("uipath_langchain.agent.tools.escalation_tool.resolve_asset") + def test_resolve_recipient_value_calls_resolve_asset_for_asset_recipient(self, mock_resolve_asset): + """Test resolving AssetRecipient calls resolve_asset.""" + mock_resolve_asset.return_value = "asset@example.com" + + recipient = AssetRecipient( + type=AgentEscalationRecipientType.ASSET_USER_EMAIL, + asset_name="email_asset", + folder_path="Shared", + ) + + result = resolve_recipient_value(recipient) + + assert result == "asset@example.com" + mock_resolve_asset.assert_called_once_with("email_asset", "Shared") + + @patch("uipath_langchain.agent.tools.escalation_tool.resolve_asset") + def test_resolve_recipient_value_propagates_error_when_asset_resolution_fails(self, mock_resolve_asset): + """Test AssetRecipient when asset resolution fails.""" + mock_resolve_asset.side_effect = ValueError("Asset not found") + + recipient = AssetRecipient( + type=AgentEscalationRecipientType.ASSET_USER_EMAIL, + asset_name="nonexistent", + folder_path="Shared", + ) + + with pytest.raises(ValueError) as exc_info: + resolve_recipient_value(recipient) + + assert "Asset not found" in str(exc_info.value) From 12ed0a77d8a05b2e4c8945cd320b65bb8e1a5ada Mon Sep 17 00:00:00 2001 From: "diana.grecu" Date: Wed, 17 Dec 2025 15:42:20 +0200 Subject: [PATCH 2/2] fix: circular dependency --- src/uipath_langchain/agent/tools/escalation_tool.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/uipath_langchain/agent/tools/escalation_tool.py b/src/uipath_langchain/agent/tools/escalation_tool.py index 602cd047..93d23afa 100644 --- a/src/uipath_langchain/agent/tools/escalation_tool.py +++ b/src/uipath_langchain/agent/tools/escalation_tool.py @@ -18,7 +18,6 @@ from uipath.platform import UiPath from uipath.platform.common import CreateEscalation -from ..react.types import AgentGraphNode, AgentTerminationSource from .utils import sanitize_tool_name @@ -79,6 +78,8 @@ def create_escalation_tool(resource: AgentEscalationResourceConfig) -> Structure async def escalation_tool_fn( runtime: ToolRuntime, **kwargs: Any ) -> Command[Any] | Any: + from ..react.types import AgentGraphNode, AgentTerminationSource + task_title = channel.task_title or "Escalation Task" result = interrupt(