diff --git a/pyproject.toml b/pyproject.toml index 5939c0154..0ff9d7819 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.4.16" +version = "0.4.17" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath_langchain/agent/guardrails/actions/escalate_action.py b/src/uipath_langchain/agent/guardrails/actions/escalate_action.py index 772bcb20c..0a511eded 100644 --- a/src/uipath_langchain/agent/guardrails/actions/escalate_action.py +++ b/src/uipath_langchain/agent/guardrails/actions/escalate_action.py @@ -8,6 +8,7 @@ from langchain_core.messages import AIMessage, AnyMessage, BaseMessage, ToolMessage from langgraph.types import Command, interrupt from uipath._utils import UiPathUrl +from uipath.agent.models.agent import AgentEscalationRecipient from uipath.platform.common import CreateEscalation, UiPathConfig from uipath.platform.guardrails import ( BaseGuardrail, @@ -18,6 +19,7 @@ from ...exceptions import AgentStateException, AgentTerminationException from ...react.types import AgentGuardrailsGraphState from ...react.utils import extract_current_tool_call_index, find_latest_ai_message +from ...tools.escalation_tool import resolve_recipient_value from ..types import ExecutionStage from ..utils import _extract_tool_args_from_message, get_message_content from .base_action import GuardrailAction, GuardrailActionNode @@ -36,7 +38,7 @@ def __init__( app_name: str, app_folder_path: str, version: int, - assignee: str, + recipient: AgentEscalationRecipient, ): """Initialize EscalateAction with escalation app configuration. @@ -44,12 +46,12 @@ def __init__( app_name: Name of the escalation app. app_folder_path: Folder path where the escalation app is located. version: Version of the escalation app. - assignee: User or role assigned to handle the escalation. + recipient: Recipient object (StandardRecipient or AssetRecipient). """ self.app_name = app_name self.app_folder_path = app_folder_path self.version = version - self.assignee = assignee + self.recipient = recipient def action_node( self, @@ -75,6 +77,9 @@ def action_node( async def _node( state: AgentGuardrailsGraphState, ) -> Dict[str, Any] | Command[Any]: + # Resolve recipient value (handles both StandardRecipient and AssetRecipient) + assignee = await resolve_recipient_value(self.recipient) + # Validate message count based on execution stage _validate_message_count(state, execution_stage) @@ -135,7 +140,7 @@ async def _node( app_folder_path=self.app_folder_path, title="Agents Guardrail Task", data=data, - assignee=self.assignee, + assignee=assignee, ) ) diff --git a/src/uipath_langchain/agent/guardrails/guardrails_factory.py b/src/uipath_langchain/agent/guardrails/guardrails_factory.py index 0b97500a1..da8fa8afe 100644 --- a/src/uipath_langchain/agent/guardrails/guardrails_factory.py +++ b/src/uipath_langchain/agent/guardrails/guardrails_factory.py @@ -20,7 +20,6 @@ AgentUnknownGuardrail, AgentWordOperator, AgentWordRule, - StandardRecipient, ) from uipath.core.guardrails import ( AllFieldsSelector, @@ -507,18 +506,17 @@ def build_guardrails_with_actions( ) ) elif isinstance(action, AgentGuardrailEscalateAction): - if isinstance(action.recipient, StandardRecipient): - result.append( - ( - converted_guardrail, - EscalateAction( - app_name=action.app.name, - app_folder_path=action.app.folder_name, - version=action.app.version, - assignee=action.recipient.value, - ), - ) + result.append( + ( + converted_guardrail, + EscalateAction( + app_name=action.app.name, + app_folder_path=action.app.folder_name, + version=action.app.version, + recipient=action.recipient, + ), ) + ) elif isinstance(action, AgentGuardrailFilterAction): result.append((converted_guardrail, FilterAction(fields=action.fields))) return result diff --git a/tests/agent/guardrails/actions/test_escalate_action.py b/tests/agent/guardrails/actions/test_escalate_action.py index 06309b9ba..9b5dfea62 100644 --- a/tests/agent/guardrails/actions/test_escalate_action.py +++ b/tests/agent/guardrails/actions/test_escalate_action.py @@ -8,6 +8,11 @@ import pytest from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langgraph.types import Command +from uipath.agent.models.agent import ( + AgentEscalationRecipientType, + AssetRecipient, + StandardRecipient, +) from uipath.platform.guardrails import GuardrailScope from uipath.runtime.errors import UiPathErrorCode @@ -21,6 +26,33 @@ InnerAgentGuardrailsGraphState, ) +DEFAULT_RECIPIENT = StandardRecipient( + type=AgentEscalationRecipientType.USER_EMAIL, + value="test@example.com", +) + +STANDARD_USER_EMAIL_RECIPIENT = StandardRecipient( + type=AgentEscalationRecipientType.USER_EMAIL, + value="user@example.com", +) + +STANDARD_GROUP_NAME_RECIPIENT = StandardRecipient( + type=AgentEscalationRecipientType.GROUP_NAME, + value="AdminGroup", +) + +ASSET_USER_EMAIL_RECIPIENT = AssetRecipient( + type=AgentEscalationRecipientType.ASSET_USER_EMAIL, + asset_name="email_asset", + folder_path="/Shared", +) + +ASSET_GROUP_NAME_RECIPIENT = AssetRecipient( + type=AgentEscalationRecipientType.ASSET_GROUP_NAME, + asset_name="group_asset", + folder_path="/Shared", +) + class TestEscalateAction: @pytest.mark.asyncio @@ -67,7 +99,7 @@ async def test_node_name( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "My Guardrail 1" @@ -104,7 +136,7 @@ async def test_node_interrupts_with_correct_message_data( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -176,7 +208,7 @@ async def test_node_post_agent_interrupts_with_correct_agent_result_data( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -264,7 +296,7 @@ async def test_node_approval_returns_command( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -339,7 +371,7 @@ async def test_node_rejection_raises_exception( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -401,7 +433,7 @@ async def test_post_execution_single_message_raises_error( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -450,7 +482,7 @@ async def test_post_execution_ai_message_with_tool_calls_extraction( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -520,7 +552,7 @@ async def test_pre_execution_with_reviewed_inputs( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -560,7 +592,7 @@ async def test_post_execution_human_message_with_reviewed_outputs( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -608,7 +640,7 @@ async def test_post_execution_ai_message_with_reviewed_outputs_and_tool_calls( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -668,7 +700,7 @@ async def test_agent_post_execution_updates_agent_result( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -710,7 +742,7 @@ async def test_missing_reviewed_field_returns_empty_dict(self, mock_interrupt): app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -745,7 +777,7 @@ async def test_json_parsing_error_raises_exception(self, mock_interrupt): app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -786,7 +818,7 @@ async def test_node_interrupts_with_correct_data_pre_tool(self, mock_interrupt): app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -837,7 +869,7 @@ async def test_tool_pre_execution_with_reviewed_inputs(self, mock_interrupt): app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -883,7 +915,7 @@ async def test_tool_post_execution_with_reviewed_outputs(self, mock_interrupt): app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -934,7 +966,7 @@ async def test_tool_post_execution_extraction(self, mock_interrupt): app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -986,7 +1018,7 @@ async def test_tool_pre_execution_non_ai_message_returns_empty( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -1022,7 +1054,7 @@ async def test_tool_post_execution_non_tool_message_returns_empty( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -1061,7 +1093,7 @@ async def test_tool_pre_execution_empty_reviewed_inputs_returns_empty( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -1105,7 +1137,7 @@ async def test_tool_pre_execution_non_dict_reviewed_inputs_raises_exception( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -1152,7 +1184,7 @@ async def test_tool_pre_execution_json_error_raises_exception(self, mock_interru app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -1199,7 +1231,7 @@ async def test_tool_pre_execution_multiple_tool_calls(self, mock_interrupt): app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -1255,7 +1287,7 @@ async def test_tool_pre_execution_fewer_reviewed_args_than_tool_calls( app_name="TestApp", app_folder_path="TestFolder", version=1, - assignee="test@example.com", + recipient=DEFAULT_RECIPIENT, ) guardrail = MagicMock() guardrail.name = "Test Guardrail" @@ -1487,3 +1519,97 @@ async def test_validate_message_count_empty_messages_raises_exception(self): ) assert excinfo.value.error_info.title == "Invalid state for POST_EXECUTION" assert "requires at least 2 messages" in excinfo.value.error_info.detail + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "recipient,expected_value", + [ + (STANDARD_USER_EMAIL_RECIPIENT, "user@example.com"), + (STANDARD_GROUP_NAME_RECIPIENT, "AdminGroup"), + (ASSET_USER_EMAIL_RECIPIENT, "user@example.com"), + (ASSET_GROUP_NAME_RECIPIENT, "AdminGroup"), + ], + ) + @patch( + "uipath_langchain.agent.guardrails.actions.escalate_action.resolve_recipient_value" + ) + @patch("uipath_langchain.agent.guardrails.actions.escalate_action.interrupt") + async def test_node_resolves_recipient_correctly( + self, mock_interrupt, mock_resolve_recipient, recipient, expected_value + ) -> None: + """EscalateAction resolves recipient correctly.""" + mock_resolve_recipient.return_value = expected_value + + action = EscalateAction( + app_name="TestApp", + app_folder_path="TestFolder", + version=1, + recipient=recipient, + ) + guardrail = MagicMock() + guardrail.name = "Test Guardrail" + guardrail.description = "Test description" + + # Mock interrupt to return approved escalation + mock_escalation_result = MagicMock() + mock_escalation_result.action = "Approve" + mock_escalation_result.data = {} + mock_interrupt.return_value = mock_escalation_result + + _, node = action.action_node( + guardrail=guardrail, + scope=GuardrailScope.LLM, + execution_stage=ExecutionStage.PRE_EXECUTION, + guarded_component_name="test_node", + ) + + state = AgentGuardrailsGraphState( + messages=[HumanMessage(content="Test message")], + ) + + await node(state) + + # Verify resolve_recipient_value was called with the recipient object + mock_resolve_recipient.assert_called_once_with(recipient) + + # Verify interrupt was called with the resolved assignee + assert mock_interrupt.called + call_args = mock_interrupt.call_args[0][0] + assert call_args.assignee == expected_value + + @pytest.mark.asyncio + @patch( + "uipath_langchain.agent.guardrails.actions.escalate_action.resolve_recipient_value" + ) + async def test_node_with_asset_recipient_resolution_failure( + self, mock_resolve_recipient + ) -> None: + """EscalateAction with AssetRecipient: propagates asset resolution errors.""" + mock_resolve_recipient.side_effect = ValueError("Asset 'email_asset' not found") + + action = EscalateAction( + app_name="TestApp", + app_folder_path="TestFolder", + version=1, + recipient=ASSET_USER_EMAIL_RECIPIENT, + ) + guardrail = MagicMock() + guardrail.name = "Test Guardrail" + guardrail.description = "Test description" + + _, node = action.action_node( + guardrail=guardrail, + scope=GuardrailScope.LLM, + execution_stage=ExecutionStage.PRE_EXECUTION, + guarded_component_name="test_node", + ) + + state = AgentGuardrailsGraphState( + messages=[HumanMessage(content="Test message")], + ) + + # Should propagate the ValueError from asset resolution + with pytest.raises(ValueError) as excinfo: + await node(state) + + assert "Asset 'email_asset' not found" in str(excinfo.value) diff --git a/tests/agent/guardrails/test_guardrails_factory.py b/tests/agent/guardrails/test_guardrails_factory.py index 0284f7547..c6510d83b 100644 --- a/tests/agent/guardrails/test_guardrails_factory.py +++ b/tests/agent/guardrails/test_guardrails_factory.py @@ -23,6 +23,7 @@ AgentNumberRule, AgentWordOperator, AgentWordRule, + AssetRecipient, FieldReference, StandardRecipient, ) @@ -136,16 +137,55 @@ def test_unknown_actions_are_ignored(self) -> None: assert gr is block_guardrail assert isinstance(action, BlockAction) - def test_escalate_action_is_mapped_with_app_and_recipient(self) -> None: + def test_escalate_action_is_mapped_with_app_and_standard_recipient(self) -> None: """ESCALATE action is mapped to EscalateAction with correct app and recipient.""" + recipient = StandardRecipient( + type=AgentEscalationRecipientType.USER_EMAIL, + value="admin@example.com", + ) app = AgentGuardrailEscalateActionApp( name="EscalationApp", folder_name="/TestFolder", version=2, ) - recipient = StandardRecipient( - type=AgentEscalationRecipientType.USER_EMAIL, - value="admin@example.com", + guardrail = cast( + AgentGuardrailModel, + types.SimpleNamespace( + name="guardrail_escalate", + selector=GuardrailSelector(), + action=AgentGuardrailEscalateAction( + action_type=AgentGuardrailActionType.ESCALATE, + app=app, + recipient=recipient, + ), + ), + ) + + result = build_guardrails_with_actions([guardrail], []) + + assert len(result) == 1 + gr, action = result[0] + assert gr is guardrail + assert isinstance(action, EscalateAction) + assert action.app_name == "EscalationApp" + assert action.app_folder_path == "/TestFolder" + assert action.version == 2 + + assert isinstance(action.recipient, StandardRecipient) + assert action.recipient.value == "admin@example.com" + + def test_escalate_action_is_mapped_with_app_and_asset_recipient(self) -> None: + """ESCALATE action is mapped to EscalateAction with correct app and recipient.""" + recipient = AssetRecipient( + type=AgentEscalationRecipientType.ASSET_USER_EMAIL, + asset_name="email_asset", + folder_path="/Shared", + ) + + app = AgentGuardrailEscalateActionApp( + name="EscalationApp", + folder_name="/TestFolder", + version=2, ) guardrail = cast( AgentGuardrailModel, @@ -169,7 +209,10 @@ def test_escalate_action_is_mapped_with_app_and_recipient(self) -> None: assert action.app_name == "EscalationApp" assert action.app_folder_path == "/TestFolder" assert action.version == 2 - assert action.assignee == "admin@example.com" + + assert isinstance(action.recipient, AssetRecipient) + assert action.recipient.asset_name == "email_asset" + assert action.recipient.folder_path == "/Shared" @pytest.mark.parametrize( "scope,scope_lower", diff --git a/uv.lock b/uv.lock index 4536cadfb..5e056ea95 100644 --- a/uv.lock +++ b/uv.lock @@ -3297,7 +3297,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.4.16" +version = "0.4.17" source = { editable = "." } dependencies = [ { name = "aiosqlite" },