Skip to content

Commit c43467d

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 c43467d

5 files changed

Lines changed: 461 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: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from typing import Any
23

34
import pytest
@@ -6,6 +7,7 @@
67
from uipath.platform import UiPathApiConfig, UiPathExecutionContext
78
from uipath.platform.action_center import Task
89
from uipath.platform.action_center._tasks_service import TasksService
10+
from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType
911
from uipath.platform.common.constants import HEADER_USER_AGENT
1012

1113

@@ -185,6 +187,167 @@ def test_create_with_assignee(
185187
assert action.title == "Test Action"
186188

187189

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