Skip to content

Commit e7fe287

Browse files
kunaluipathclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 825faa6 commit e7fe287

5 files changed

Lines changed: 462 additions & 2 deletions

File tree

packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,28 @@ async def _assign_task_spec(
233233
}
234234
]
235235
}
236+
elif task_recipient.type == TaskRecipientType.WORKLOAD:
237+
request_spec.json = {
238+
"taskAssignments": [
239+
{
240+
"taskId": task_key,
241+
"assignmentCriteria": "Workload",
242+
"assigneeNamesOrEmails": task_recipient.values
243+
or [recipient_value],
244+
}
245+
]
246+
}
247+
elif task_recipient.type == TaskRecipientType.ROUND_ROBIN:
248+
request_spec.json = {
249+
"taskAssignments": [
250+
{
251+
"taskId": task_key,
252+
"assignmentCriteria": "RoundRobin",
253+
"assigneeNamesOrEmails": task_recipient.values
254+
or [recipient_value],
255+
}
256+
]
257+
}
236258
else:
237259
request_spec.json = {
238260
"taskAssignments": [

packages/uipath-platform/src/uipath/platform/action_center/tasks.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,29 @@ class TaskRecipientType(str, enum.Enum):
2222
GROUP_ID = "GroupId"
2323
EMAIL = "UserEmail"
2424
GROUP_NAME = "GroupName"
25+
WORKLOAD = "Workload"
26+
ROUND_ROBIN = "RoundRobin"
2527

2628

2729
class TaskRecipient(BaseModel):
28-
"""Model representing a task recipient."""
30+
"""Model representing a task recipient.
31+
32+
`value` is the single identifier (group name, group id, user id, email, …).
33+
`values` is the multi-assignee form used by Workload-with-custom-emails
34+
assignments; when set it takes precedence over `value` for the
35+
`assigneeNamesOrEmails` payload.
36+
"""
2937

3038
type: Literal[
3139
TaskRecipientType.USER_ID,
3240
TaskRecipientType.GROUP_ID,
3341
TaskRecipientType.EMAIL,
3442
TaskRecipientType.GROUP_NAME,
43+
TaskRecipientType.WORKLOAD,
44+
TaskRecipientType.ROUND_ROBIN,
3545
] = Field(..., alias="type")
3646
value: str = Field(..., alias="value")
47+
values: Optional[List[str]] = Field(default=None, alias="values")
3748
display_name: Optional[str] = Field(default=None, alias="displayName")
3849

3950

packages/uipath-platform/tests/services/test_actions_service.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
import pytest
44
from pytest_httpx import HTTPXMock
55

6+
import json
7+
68
from uipath.platform import UiPathApiConfig, UiPathExecutionContext
79
from uipath.platform.action_center import Task
810
from uipath.platform.action_center._tasks_service import TasksService
11+
from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType
912
from uipath.platform.common.constants import HEADER_USER_AGENT
1013

1114

@@ -185,6 +188,167 @@ def test_create_with_assignee(
185188
assert action.title == "Test Action"
186189

187190

191+
def _mock_app_lookup_and_create(
192+
httpx_mock: HTTPXMock,
193+
base_url: str,
194+
org: str,
195+
tenant: str,
196+
monkeypatch: pytest.MonkeyPatch,
197+
) -> None:
198+
"""Common httpx mock setup for app lookup + task creation + assign."""
199+
monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id")
200+
httpx_mock.add_response(
201+
url=f"{base_url}{org}/apps_/default/api/v1/default/deployed-action-apps-schemas?search=test-app&filterByDeploymentTitle=true",
202+
status_code=200,
203+
json={
204+
"deployed": [
205+
{
206+
"systemName": "test-app",
207+
"deploymentTitle": "test-app",
208+
"actionSchema": {
209+
"key": "test-key",
210+
"inputs": [],
211+
"outputs": [],
212+
"inOuts": [],
213+
"outcomes": [],
214+
},
215+
"deploymentFolder": {
216+
"fullyQualifiedName": "test-folder-path",
217+
"key": "test-folder-key",
218+
},
219+
}
220+
]
221+
},
222+
)
223+
httpx_mock.add_response(
224+
url=f"{base_url}{org}{tenant}/orchestrator_/tasks/AppTasks/CreateAppTask",
225+
status_code=200,
226+
json={"id": 1, "title": "Test Action"},
227+
)
228+
httpx_mock.add_response(
229+
url=f"{base_url}{org}{tenant}/orchestrator_/odata/Tasks/UiPath.Server.Configuration.OData.AssignTasks",
230+
status_code=200,
231+
json={},
232+
)
233+
234+
235+
def _assign_request_payload(httpx_mock: HTTPXMock) -> dict[str, Any]:
236+
"""Return the parsed JSON body of the last AssignTasks request captured by the mock."""
237+
assign_request = next(
238+
req
239+
for req in reversed(httpx_mock.get_requests())
240+
if "AssignTasks" in str(req.url)
241+
)
242+
return json.loads(assign_request.content)
243+
244+
245+
class TestAssignTaskSpec:
246+
"""Tests for the task-assignment payload built by `_assign_task_spec`."""
247+
248+
def test_assign_workload_recipient_uses_workload_criteria_with_group(
249+
self,
250+
httpx_mock: HTTPXMock,
251+
service: TasksService,
252+
base_url: str,
253+
org: str,
254+
tenant: str,
255+
monkeypatch: pytest.MonkeyPatch,
256+
) -> None:
257+
_mock_app_lookup_and_create(httpx_mock, base_url, org, tenant, monkeypatch)
258+
259+
service.create(
260+
title="Test Action",
261+
app_name="test-app",
262+
data={"x": 1},
263+
recipient=TaskRecipient(
264+
type=TaskRecipientType.WORKLOAD,
265+
value="Support Team",
266+
displayName="Support Team",
267+
),
268+
)
269+
270+
payload = _assign_request_payload(httpx_mock)
271+
assert payload == {
272+
"taskAssignments": [
273+
{
274+
"taskId": 1,
275+
"assignmentCriteria": "Workload",
276+
"assigneeNamesOrEmails": ["Support Team"],
277+
}
278+
]
279+
}
280+
281+
def test_assign_round_robin_recipient_uses_round_robin_criteria(
282+
self,
283+
httpx_mock: HTTPXMock,
284+
service: TasksService,
285+
base_url: str,
286+
org: str,
287+
tenant: str,
288+
monkeypatch: pytest.MonkeyPatch,
289+
) -> None:
290+
_mock_app_lookup_and_create(httpx_mock, base_url, org, tenant, monkeypatch)
291+
292+
service.create(
293+
title="Test Action",
294+
app_name="test-app",
295+
data={"x": 1},
296+
recipient=TaskRecipient(
297+
type=TaskRecipientType.ROUND_ROBIN,
298+
value="Support Team",
299+
displayName="Support Team",
300+
),
301+
)
302+
303+
payload = _assign_request_payload(httpx_mock)
304+
assert payload == {
305+
"taskAssignments": [
306+
{
307+
"taskId": 1,
308+
"assignmentCriteria": "RoundRobin",
309+
"assigneeNamesOrEmails": ["Support Team"],
310+
}
311+
]
312+
}
313+
314+
def test_assign_workload_with_multiple_emails_uses_values_list(
315+
self,
316+
httpx_mock: HTTPXMock,
317+
service: TasksService,
318+
base_url: str,
319+
org: str,
320+
tenant: str,
321+
monkeypatch: pytest.MonkeyPatch,
322+
) -> None:
323+
"""Custom-assignees path: Workload criteria with a list of emails."""
324+
_mock_app_lookup_and_create(httpx_mock, base_url, org, tenant, monkeypatch)
325+
326+
service.create(
327+
title="Test Action",
328+
app_name="test-app",
329+
data={"x": 1},
330+
recipient=TaskRecipient(
331+
type=TaskRecipientType.WORKLOAD,
332+
value="alice@example.com",
333+
values=["alice@example.com", "bob@example.com"],
334+
),
335+
)
336+
337+
payload = _assign_request_payload(httpx_mock)
338+
assert payload == {
339+
"taskAssignments": [
340+
{
341+
"taskId": 1,
342+
"assignmentCriteria": "Workload",
343+
"assigneeNamesOrEmails": [
344+
"alice@example.com",
345+
"bob@example.com",
346+
],
347+
}
348+
]
349+
}
350+
351+
188352
def _make_deployed_app(
189353
name: str,
190354
folder_path: str,

packages/uipath/src/uipath/agent/models/agent.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ class AgentEscalationRecipientType(str, CaseInsensitiveEnum):
140140
ASSET_GROUP_NAME = "AssetGroupName"
141141
ARGUMENT_EMAIL = "ArgumentEmail"
142142
ARGUMENT_GROUP_NAME = "ArgumentGroupName"
143+
WORKLOAD = "Workload"
144+
ROUND_ROBIN = "RoundRobin"
145+
CUSTOM_ASSIGNEES = "CustomAssignees"
143146

144147

145148
class AgentContextRetrievalMode(str, CaseInsensitiveEnum):
@@ -540,6 +543,9 @@ class AgentA2aResourceConfig(BaseAgentResourceConfig):
540543
6: AgentEscalationRecipientType.ASSET_GROUP_NAME,
541544
7: AgentEscalationRecipientType.ARGUMENT_EMAIL,
542545
8: AgentEscalationRecipientType.ARGUMENT_GROUP_NAME,
546+
9: AgentEscalationRecipientType.WORKLOAD,
547+
10: AgentEscalationRecipientType.ROUND_ROBIN,
548+
11: AgentEscalationRecipientType.CUSTOM_ASSIGNEES,
543549
}
544550

545551

@@ -619,14 +625,86 @@ class ArgumentGroupNameRecipient(BaseEscalationRecipient):
619625
argument_path: str = Field(..., alias="argumentName")
620626

621627

628+
class WorkloadRecipient(BaseEscalationRecipient):
629+
"""Workload-based group assignment.
630+
631+
The Action Center distributes tasks to the group member with the lightest workload.
632+
"""
633+
634+
type: Literal[AgentEscalationRecipientType.WORKLOAD,] = Field(..., alias="type")
635+
value: str = Field(..., alias="value")
636+
display_name: str = Field(..., alias="displayName")
637+
638+
639+
class RoundRobinRecipient(BaseEscalationRecipient):
640+
"""Round-robin group assignment.
641+
642+
The Action Center cycles through group members in order on each new task.
643+
"""
644+
645+
type: Literal[AgentEscalationRecipientType.ROUND_ROBIN,] = Field(..., alias="type")
646+
value: str = Field(..., alias="value")
647+
display_name: str = Field(..., alias="displayName")
648+
649+
650+
class CustomAssigneesRecipient(BaseEscalationRecipient):
651+
"""Custom multi-user assignment.
652+
653+
A channel can carry multiple instances, one per assignee email. All are passed
654+
to Action Center together using a Workload assignment criteria.
655+
"""
656+
657+
type: Literal[AgentEscalationRecipientType.CUSTOM_ASSIGNEES,] = Field(
658+
..., alias="type"
659+
)
660+
value: str = Field(..., alias="value")
661+
display_name: Optional[str] = Field(default=None, alias="displayName")
662+
663+
664+
class ToolOutputRecipient(BaseEscalationRecipient):
665+
"""Recipient whose value is resolved at runtime from a named tool's output.
666+
667+
Instead of a literal value entered at design time, this binding points at a
668+
field within a named tool's output. The runtime walks the agent's message
669+
history, finds the most recent ToolMessage matching `tool_name`, parses its
670+
content as JSON, and extracts `output_path` (a top-level field for v1).
671+
672+
Only the assignment-criteria recipient types that accept a runtime-computed
673+
value are supported: USER_ID, GROUP_ID, WORKLOAD, ROUND_ROBIN,
674+
CUSTOM_ASSIGNEES. The asset/static/argument types do not participate in
675+
tool-output binding (they have their own design-time resolution rules).
676+
"""
677+
678+
type: Literal[
679+
AgentEscalationRecipientType.USER_ID,
680+
AgentEscalationRecipientType.GROUP_ID,
681+
AgentEscalationRecipientType.WORKLOAD,
682+
AgentEscalationRecipientType.ROUND_ROBIN,
683+
AgentEscalationRecipientType.CUSTOM_ASSIGNEES,
684+
] = Field(..., alias="type")
685+
source: Literal["toolOutput"] = Field(..., alias="source")
686+
tool_name: str = Field(..., alias="toolName")
687+
output_path: str = Field(..., alias="outputPath")
688+
689+
690+
# Note: order matters in this union — ToolOutputRecipient is listed first so payloads
691+
# carrying `source: "toolOutput"` match it before the literal variants get a chance.
692+
# The literal classes don't define a `source` field, so Pydantic's overlap heuristics
693+
# pick the right class via the presence of required fields (value/displayName vs
694+
# source/toolName/outputPath). A `Field(discriminator="type")` cannot be used here
695+
# because multiple classes share the same `type` literals (literal and tool-output
696+
# variants of the same criteria).
622697
AgentEscalationRecipient = Annotated[
623698
Union[
699+
ToolOutputRecipient,
624700
StandardRecipient,
625701
AssetRecipient,
626702
ArgumentEmailRecipient,
627703
ArgumentGroupNameRecipient,
704+
WorkloadRecipient,
705+
RoundRobinRecipient,
706+
CustomAssigneesRecipient,
628707
],
629-
Field(discriminator="type"),
630708
BeforeValidator(_normalize_recipient_type),
631709
]
632710

0 commit comments

Comments
 (0)