From 3b6310b219b5f67da3ddbc475cf182f5d84d8dcc Mon Sep 17 00:00:00 2001 From: kunaluipath Date: Wed, 20 May 2026 18:35:46 +0530 Subject: [PATCH] feat(agent): add tool-output and V2 escalation recipient types Adds the agent + platform models needed for the V2 HITL escalation recipient flow consumed by the langchain runtime. uipath/agent/models: - New literal recipient classes for Workload (9), RoundRobin (10), and CustomAssignees (11) matching the storage schema v50 surface. - New ToolOutputRecipient class accepting types USER_ID/GROUP_ID/WORKLOAD/ ROUND_ROBIN/CUSTOM_ASSIGNEES with source="toolOutput", toolName, outputPath fields for runtime-resolved assignees. - ToolOutputRecipient listed first in the recipient Union so payloads carrying `source` match before falling through to literal variants. uipath/platform/action_center: - TaskRecipientType gains Workload and RoundRobin members. - TaskRecipient gains an optional `values` list field for the multi-assignee assigneeNamesOrEmails payload. - _tasks_service updated to forward the new fields when present. Backwards-compatible: existing payloads without source/values parse and serialize identically. Co-Authored-By: Claude Opus 4.7 --- .../platform/action_center/_tasks_service.py | 22 +++ .../uipath/platform/action_center/tasks.py | 13 +- .../tests/services/test_actions_service.py | 164 ++++++++++++++++ .../uipath/src/uipath/agent/models/agent.py | 80 +++++++- .../uipath/tests/agent/models/test_agent.py | 179 ++++++++++++++++++ 5 files changed, 456 insertions(+), 2 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py index 662109ce4..6b67b157c 100644 --- a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py +++ b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py @@ -233,6 +233,28 @@ async def _assign_task_spec( } ] } + elif task_recipient.type == TaskRecipientType.WORKLOAD: + request_spec.json = { + "taskAssignments": [ + { + "taskId": task_key, + "assignmentCriteria": "Workload", + "assigneeNamesOrEmails": task_recipient.values + or [recipient_value], + } + ] + } + elif task_recipient.type == TaskRecipientType.ROUND_ROBIN: + request_spec.json = { + "taskAssignments": [ + { + "taskId": task_key, + "assignmentCriteria": "RoundRobin", + "assigneeNamesOrEmails": task_recipient.values + or [recipient_value], + } + ] + } else: request_spec.json = { "taskAssignments": [ diff --git a/packages/uipath-platform/src/uipath/platform/action_center/tasks.py b/packages/uipath-platform/src/uipath/platform/action_center/tasks.py index f882cf40f..4c6555891 100644 --- a/packages/uipath-platform/src/uipath/platform/action_center/tasks.py +++ b/packages/uipath-platform/src/uipath/platform/action_center/tasks.py @@ -22,18 +22,29 @@ class TaskRecipientType(str, enum.Enum): GROUP_ID = "GroupId" EMAIL = "UserEmail" GROUP_NAME = "GroupName" + WORKLOAD = "Workload" + ROUND_ROBIN = "RoundRobin" class TaskRecipient(BaseModel): - """Model representing a task recipient.""" + """Model representing a task recipient. + + `value` is the single identifier (group name, group id, user id, email, …). + `values` is the multi-assignee form used by Workload-with-custom-emails + assignments; when set it takes precedence over `value` for the + `assigneeNamesOrEmails` payload. + """ type: Literal[ TaskRecipientType.USER_ID, TaskRecipientType.GROUP_ID, TaskRecipientType.EMAIL, TaskRecipientType.GROUP_NAME, + TaskRecipientType.WORKLOAD, + TaskRecipientType.ROUND_ROBIN, ] = Field(..., alias="type") value: str = Field(..., alias="value") + values: Optional[List[str]] = Field(default=None, alias="values") display_name: Optional[str] = Field(default=None, alias="displayName") diff --git a/packages/uipath-platform/tests/services/test_actions_service.py b/packages/uipath-platform/tests/services/test_actions_service.py index b97d326e8..b0677fd50 100644 --- a/packages/uipath-platform/tests/services/test_actions_service.py +++ b/packages/uipath-platform/tests/services/test_actions_service.py @@ -3,9 +3,12 @@ import pytest from pytest_httpx import HTTPXMock +import json + from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.action_center import Task from uipath.platform.action_center._tasks_service import TasksService +from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType from uipath.platform.common.constants import HEADER_USER_AGENT @@ -185,6 +188,167 @@ def test_create_with_assignee( assert action.title == "Test Action" +def _mock_app_lookup_and_create( + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Common httpx mock setup for app lookup + task creation + assign.""" + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + httpx_mock.add_response( + url=f"{base_url}{org}/apps_/default/api/v1/default/deployed-action-apps-schemas?search=test-app&filterByDeploymentTitle=true", + status_code=200, + json={ + "deployed": [ + { + "systemName": "test-app", + "deploymentTitle": "test-app", + "actionSchema": { + "key": "test-key", + "inputs": [], + "outputs": [], + "inOuts": [], + "outcomes": [], + }, + "deploymentFolder": { + "fullyQualifiedName": "test-folder-path", + "key": "test-folder-key", + }, + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/AppTasks/CreateAppTask", + status_code=200, + json={"id": 1, "title": "Test Action"}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Tasks/UiPath.Server.Configuration.OData.AssignTasks", + status_code=200, + json={}, + ) + + +def _assign_request_payload(httpx_mock: HTTPXMock) -> dict[str, Any]: + """Return the parsed JSON body of the last AssignTasks request captured by the mock.""" + assign_request = next( + req + for req in reversed(httpx_mock.get_requests()) + if "AssignTasks" in str(req.url) + ) + return json.loads(assign_request.content) + + +class TestAssignTaskSpec: + """Tests for the task-assignment payload built by `_assign_task_spec`.""" + + def test_assign_workload_recipient_uses_workload_criteria_with_group( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + _mock_app_lookup_and_create(httpx_mock, base_url, org, tenant, monkeypatch) + + service.create( + title="Test Action", + app_name="test-app", + data={"x": 1}, + recipient=TaskRecipient( + type=TaskRecipientType.WORKLOAD, + value="Support Team", + displayName="Support Team", + ), + ) + + payload = _assign_request_payload(httpx_mock) + assert payload == { + "taskAssignments": [ + { + "taskId": 1, + "assignmentCriteria": "Workload", + "assigneeNamesOrEmails": ["Support Team"], + } + ] + } + + def test_assign_round_robin_recipient_uses_round_robin_criteria( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + _mock_app_lookup_and_create(httpx_mock, base_url, org, tenant, monkeypatch) + + service.create( + title="Test Action", + app_name="test-app", + data={"x": 1}, + recipient=TaskRecipient( + type=TaskRecipientType.ROUND_ROBIN, + value="Support Team", + displayName="Support Team", + ), + ) + + payload = _assign_request_payload(httpx_mock) + assert payload == { + "taskAssignments": [ + { + "taskId": 1, + "assignmentCriteria": "RoundRobin", + "assigneeNamesOrEmails": ["Support Team"], + } + ] + } + + def test_assign_workload_with_multiple_emails_uses_values_list( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Custom-assignees path: Workload criteria with a list of emails.""" + _mock_app_lookup_and_create(httpx_mock, base_url, org, tenant, monkeypatch) + + service.create( + title="Test Action", + app_name="test-app", + data={"x": 1}, + recipient=TaskRecipient( + type=TaskRecipientType.WORKLOAD, + value="alice@example.com", + values=["alice@example.com", "bob@example.com"], + ), + ) + + payload = _assign_request_payload(httpx_mock) + assert payload == { + "taskAssignments": [ + { + "taskId": 1, + "assignmentCriteria": "Workload", + "assigneeNamesOrEmails": [ + "alice@example.com", + "bob@example.com", + ], + } + ] + } + + def _make_deployed_app( name: str, folder_path: str, diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 38bc8a815..8c08be3ea 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -137,6 +137,9 @@ class AgentEscalationRecipientType(str, CaseInsensitiveEnum): ASSET_GROUP_NAME = "AssetGroupName" ARGUMENT_EMAIL = "ArgumentEmail" ARGUMENT_GROUP_NAME = "ArgumentGroupName" + WORKLOAD = "Workload" + ROUND_ROBIN = "RoundRobin" + CUSTOM_ASSIGNEES = "CustomAssignees" class AgentContextRetrievalMode(str, CaseInsensitiveEnum): @@ -491,6 +494,9 @@ class AgentA2aResourceConfig(BaseAgentResourceConfig): 6: AgentEscalationRecipientType.ASSET_GROUP_NAME, 7: AgentEscalationRecipientType.ARGUMENT_EMAIL, 8: AgentEscalationRecipientType.ARGUMENT_GROUP_NAME, + 9: AgentEscalationRecipientType.WORKLOAD, + 10: AgentEscalationRecipientType.ROUND_ROBIN, + 11: AgentEscalationRecipientType.CUSTOM_ASSIGNEES, } @@ -570,14 +576,86 @@ class ArgumentGroupNameRecipient(BaseEscalationRecipient): argument_path: str = Field(..., alias="argumentName") +class WorkloadRecipient(BaseEscalationRecipient): + """Workload-based group assignment. + + The Action Center distributes tasks to the group member with the lightest workload. + """ + + type: Literal[AgentEscalationRecipientType.WORKLOAD,] = Field(..., alias="type") + value: str = Field(..., alias="value") + display_name: str = Field(..., alias="displayName") + + +class RoundRobinRecipient(BaseEscalationRecipient): + """Round-robin group assignment. + + The Action Center cycles through group members in order on each new task. + """ + + type: Literal[AgentEscalationRecipientType.ROUND_ROBIN,] = Field(..., alias="type") + value: str = Field(..., alias="value") + display_name: str = Field(..., alias="displayName") + + +class CustomAssigneesRecipient(BaseEscalationRecipient): + """Custom multi-user assignment. + + A channel can carry multiple instances, one per assignee email. All are passed + to Action Center together using a Workload assignment criteria. + """ + + type: Literal[AgentEscalationRecipientType.CUSTOM_ASSIGNEES,] = Field( + ..., alias="type" + ) + value: str = Field(..., alias="value") + display_name: Optional[str] = Field(default=None, alias="displayName") + + +class ToolOutputRecipient(BaseEscalationRecipient): + """Recipient whose value is resolved at runtime from a named tool's output. + + Instead of a literal value entered at design time, this binding points at a + field within a named tool's output. The runtime walks the agent's message + history, finds the most recent ToolMessage matching `tool_name`, parses its + content as JSON, and extracts `output_path` (a top-level field for v1). + + Only the assignment-criteria recipient types that accept a runtime-computed + value are supported: USER_ID, GROUP_ID, WORKLOAD, ROUND_ROBIN, + CUSTOM_ASSIGNEES. The asset/static/argument types do not participate in + tool-output binding (they have their own design-time resolution rules). + """ + + type: Literal[ + AgentEscalationRecipientType.USER_ID, + AgentEscalationRecipientType.GROUP_ID, + AgentEscalationRecipientType.WORKLOAD, + AgentEscalationRecipientType.ROUND_ROBIN, + AgentEscalationRecipientType.CUSTOM_ASSIGNEES, + ] = Field(..., alias="type") + source: Literal["toolOutput"] = Field(..., alias="source") + tool_name: str = Field(..., alias="toolName") + output_path: str = Field(..., alias="outputPath") + + +# Note: order matters in this union — ToolOutputRecipient is listed first so payloads +# carrying `source: "toolOutput"` match it before the literal variants get a chance. +# The literal classes don't define a `source` field, so Pydantic's overlap heuristics +# pick the right class via the presence of required fields (value/displayName vs +# source/toolName/outputPath). A `Field(discriminator="type")` cannot be used here +# because multiple classes share the same `type` literals (literal and tool-output +# variants of the same criteria). AgentEscalationRecipient = Annotated[ Union[ + ToolOutputRecipient, StandardRecipient, AssetRecipient, ArgumentEmailRecipient, ArgumentGroupNameRecipient, + WorkloadRecipient, + RoundRobinRecipient, + CustomAssigneesRecipient, ], - Field(discriminator="type"), BeforeValidator(_normalize_recipient_type), ] diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 4daae105a..5ce342198 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -49,8 +49,12 @@ BatchTransformFileExtension, BatchTransformWebSearchGrounding, CitationMode, + CustomAssigneesRecipient, DeepRagFileExtension, + RoundRobinRecipient, StandardRecipient, + ToolOutputRecipient, + WorkloadRecipient, TaskTitleType, TextBuilderTaskTitle, TextToken, @@ -3950,3 +3954,178 @@ def test_argument_group_name_recipient_missing_argument_name_raises(self): payload = {"type": 8} with pytest.raises(ValidationError): TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + +class TestCustomAssignmentRecipientDeserialization: + def test_workload_recipient_by_type_int(self): + payload = {"type": 9, "value": "group-1", "displayName": "Support Team"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, WorkloadRecipient) + assert recipient.value == "group-1" + assert recipient.display_name == "Support Team" + assert recipient.type == AgentEscalationRecipientType.WORKLOAD + + def test_workload_recipient_by_type_string(self): + payload = {"type": "Workload", "value": "group-1", "displayName": "Support Team"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, WorkloadRecipient) + assert recipient.value == "group-1" + assert recipient.display_name == "Support Team" + + def test_round_robin_recipient_by_type_int(self): + payload = {"type": 10, "value": "group-1", "displayName": "Support Team"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, RoundRobinRecipient) + assert recipient.value == "group-1" + assert recipient.display_name == "Support Team" + assert recipient.type == AgentEscalationRecipientType.ROUND_ROBIN + + def test_round_robin_recipient_by_type_string(self): + payload = { + "type": "RoundRobin", + "value": "group-1", + "displayName": "Support Team", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, RoundRobinRecipient) + + def test_custom_assignees_recipient_by_type_int(self): + payload = { + "type": 11, + "value": "alice@example.com", + "displayName": "Alice", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, CustomAssigneesRecipient) + assert recipient.value == "alice@example.com" + assert recipient.display_name == "Alice" + assert recipient.type == AgentEscalationRecipientType.CUSTOM_ASSIGNEES + + def test_custom_assignees_recipient_by_type_string(self): + payload = {"type": "CustomAssignees", "value": "alice@example.com"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, CustomAssigneesRecipient) + assert recipient.value == "alice@example.com" + assert recipient.display_name is None + + def test_custom_assignees_recipient_accepts_empty_value_sentinel(self): + payload = {"type": 11, "value": ""} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, CustomAssigneesRecipient) + assert recipient.value == "" + + def test_workload_recipient_missing_value_raises(self): + payload = {"type": 9, "displayName": "Support Team"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_workload_recipient_missing_display_name_raises(self): + payload = {"type": 9, "value": "group-1"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_round_robin_recipient_missing_value_raises(self): + payload = {"type": 10, "displayName": "Support Team"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_custom_assignees_recipient_missing_value_raises(self): + payload = {"type": 11} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + +class TestToolOutputRecipientDeserialization: + @pytest.mark.parametrize( + "recipient_type", + [1, 2, 9, 10, 11], + ) + def test_tool_output_recipient_by_type_int_for_supported_types(self, recipient_type): + payload = { + "type": recipient_type, + "source": "toolOutput", + "toolName": "API workflow A", + "outputPath": "includeEmails", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ToolOutputRecipient) + assert recipient.tool_name == "API workflow A" + assert recipient.output_path == "includeEmails" + assert recipient.source == "toolOutput" + + def test_tool_output_recipient_for_custom_assignees_by_type_string(self): + payload = { + "type": "CustomAssignees", + "source": "toolOutput", + "toolName": "API workflow A", + "outputPath": "includeEmails", + } + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, ToolOutputRecipient) + assert recipient.type == AgentEscalationRecipientType.CUSTOM_ASSIGNEES + + def test_tool_output_recipient_missing_tool_name_raises(self): + payload = {"type": 11, "source": "toolOutput", "outputPath": "emails"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_tool_output_recipient_missing_output_path_raises(self): + payload = {"type": 11, "source": "toolOutput", "toolName": "A"} + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_tool_output_recipient_unknown_source_raises(self): + payload = { + "type": 11, + "source": "magicBox", + "toolName": "A", + "outputPath": "emails", + } + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + @pytest.mark.parametrize( + "recipient_type", + [3, 4, 5, 6, 7, 8], + ) + def test_tool_output_recipient_not_allowed_for_static_asset_argument_types( + self, recipient_type + ): + # Static/asset/argument types (3, 4, 5, 6, 7, 8) are not supported + # for tool-output binding because they have their own design-time + # resolution rules. + payload = { + "type": recipient_type, + "source": "toolOutput", + "toolName": "A", + "outputPath": "emails", + } + with pytest.raises(ValidationError): + TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_literal_recipient_without_source_still_parses_to_literal_class(self): + # Backward compat: a payload without `source` still matches the literal class. + payload = {"type": 11, "value": "alice@example.com", "displayName": "Alice"} + recipient: AgentEscalationRecipient = TypeAdapter( + AgentEscalationRecipient + ).validate_python(payload) + assert isinstance(recipient, CustomAssigneesRecipient) + assert not isinstance(recipient, ToolOutputRecipient)