Skip to content

Commit 4acba2b

Browse files
feat(escalation): return task_id and assigned_to from escalation tool (#517)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9a5fc2d commit 4acba2b

4 files changed

Lines changed: 149 additions & 19 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.5.28"
3+
version = "0.5.29"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath_langchain/agent/tools/escalation_tool.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from langchain_core.messages.tool import ToolCall
77
from langchain_core.tools import BaseTool, StructuredTool
88
from langgraph.types import interrupt
9-
from pydantic import BaseModel, TypeAdapter
9+
from pydantic import BaseModel
1010
from uipath.agent.models.agent import (
1111
AgentEscalationChannel,
1212
AgentEscalationRecipient,
@@ -98,6 +98,15 @@ def _resolve_task_title(
9898
return "Escalation Task"
9999

100100

101+
def _get_user_email(user: Any) -> str | None:
102+
"""Extract email from user object/dict."""
103+
if user is None:
104+
return None
105+
if isinstance(user, dict):
106+
return user.get("emailAddress")
107+
return getattr(user, "emailAddress", None)
108+
109+
101110
def create_escalation_tool(
102111
resource: AgentEscalationResourceConfig,
103112
) -> StructuredTool:
@@ -134,7 +143,7 @@ async def escalation_tool_fn(**kwargs: Any) -> dict[str, Any]:
134143
example_calls=channel.properties.example_calls,
135144
)
136145
async def escalate():
137-
interrupt(
146+
return interrupt(
138147
CreateEscalation(
139148
title=task_title,
140149
data=kwargs,
@@ -149,11 +158,13 @@ async def escalate():
149158
)
150159

151160
result = await escalate()
152-
if isinstance(result, dict):
153-
result = TypeAdapter(EscalationToolOutput).validate_python(result)
154161

155-
escalation_action = getattr(result, "action", None)
156-
escalation_output = getattr(result, "data", {})
162+
# Extract task info before validation
163+
task_id = result.id
164+
assigned_to = _get_user_email(result.assigned_to_user)
165+
166+
escalation_action = result.action
167+
escalation_output = result.data or {}
157168

158169
outcome_str = (
159170
channel.outcome_mapping.get(escalation_action)
@@ -167,7 +178,9 @@ async def escalate():
167178
return {
168179
"action": outcome,
169180
"output": escalation_output,
170-
"escalation_action": escalation_action,
181+
"outcome": escalation_action,
182+
"task_id": task_id,
183+
"assigned_to": assigned_to,
171184
}
172185

173186
async def escalation_wrapper(
@@ -182,14 +195,17 @@ async def escalation_wrapper(
182195
channel.task_title, sanitize_dict_for_serialization(dict(state))
183196
)
184197

198+
tool.metadata["_call_id"] = call.get("id")
199+
tool.metadata["_call_args"] = dict(call.get("args", {}))
200+
185201
call["args"] = handle_static_args(resource, state, call["args"])
186202
result = await tool.ainvoke(call["args"])
187203

188204
if result["action"] == EscalationAction.END:
189205
output_detail = f"Escalation output: {result['output']}"
190206
termination_title = (
191207
f"Agent run ended based on escalation outcome {result['action']} "
192-
f"with directive {result['escalation_action']}"
208+
f"with directive {result['outcome']}"
193209
)
194210

195211
raise AgentTerminationException(
@@ -198,7 +214,12 @@ async def escalation_wrapper(
198214
detail=output_detail,
199215
)
200216

201-
return result["output"]
217+
return {
218+
**result["output"],
219+
"outcome": result["outcome"],
220+
"task_id": result["task_id"],
221+
"assigned_to": result["assigned_to"],
222+
}
202223

203224
tool = StructuredToolWithArgumentProperties(
204225
name=tool_name,

tests/agent/tools/test_escalation_tool.py

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType
1616

1717
from uipath_langchain.agent.tools.escalation_tool import (
18+
_get_user_email,
1819
create_escalation_tool,
1920
resolve_asset,
2021
resolve_recipient_value,
@@ -509,6 +510,9 @@ async def test_escalation_tool_result_validation(
509510
"""Test that tool properly processes and validates results."""
510511
# Mock interrupt to return a proper result object with action and data
511512
mock_result = MagicMock()
513+
mock_result.id = 123
514+
mock_result.key = None
515+
mock_result.assigned_to_user = None
512516
mock_result.action = "approve"
513517
mock_result.data = {}
514518
mock_interrupt.return_value = mock_result
@@ -519,9 +523,11 @@ async def test_escalation_tool_result_validation(
519523
# Invoke through the wrapper
520524
result = await tool.awrapper(tool, call, {}) # type: ignore[attr-defined]
521525

522-
# Should successfully process the result
526+
# Should successfully process the result with task info
523527
assert isinstance(result, dict)
524-
assert result == {}
528+
assert result["outcome"] == "approve"
529+
assert result["task_id"] == 123
530+
assert result["assigned_to"] is None
525531

526532
@pytest.mark.asyncio
527533
@patch("uipath_langchain.agent.tools.escalation_tool.interrupt")
@@ -546,14 +552,19 @@ async def test_escalation_tool_extracts_action_from_result(
546552

547553
@pytest.mark.asyncio
548554
@patch("uipath_langchain.agent.tools.escalation_tool.interrupt")
549-
async def test_escalation_tool_with_outcome_mapping(self, mock_interrupt):
550-
"""Test escalation tool with outcome mapping for actions."""
555+
async def test_escalation_tool_with_outcome_mapping_end(self, mock_interrupt):
556+
"""Test escalation tool with outcome mapping that ends agent."""
557+
from uipath_langchain.agent.exceptions import AgentTerminationException
558+
551559
mock_result = MagicMock()
560+
mock_result.id = 456
561+
mock_result.key = None
562+
mock_result.assigned_to_user = None
552563
mock_result.action = "approve"
553564
mock_result.data = {"approved": True}
554565
mock_interrupt.return_value = mock_result
555566

556-
# Create resource with outcome mapping
567+
# Create resource with outcome mapping where approve -> end
557568
channel_dict = {
558569
"name": "action_center",
559570
"type": "actionCenter",
@@ -578,8 +589,106 @@ async def test_escalation_tool_with_outcome_mapping(self, mock_interrupt):
578589
tool = create_escalation_tool(resource)
579590
call = ToolCall(args={}, id="test-call", name=tool.name)
580591

581-
# Invoke through the wrapper
582-
await tool.awrapper(tool, call, {}) # type: ignore[attr-defined]
592+
# Invoke through the wrapper - should raise AgentTerminationException
593+
with pytest.raises(AgentTerminationException):
594+
await tool.awrapper(tool, call, {}) # type: ignore[attr-defined]
583595

584-
# Verify interrupt was called with approval action
585596
assert mock_interrupt.called
597+
598+
599+
class TestGetUserEmail:
600+
"""Test the _get_user_email helper function."""
601+
602+
def test_none_returns_none(self):
603+
"""Test that None input returns None."""
604+
assert _get_user_email(None) is None
605+
606+
def test_dict_with_email_address(self):
607+
"""Test extraction from dict with emailAddress field."""
608+
user = {"emailAddress": "test@example.com", "name": "Test"}
609+
assert _get_user_email(user) == "test@example.com"
610+
611+
def test_dict_without_email_address(self):
612+
"""Test dict without emailAddress returns None."""
613+
user = {"name": "Test", "id": 123}
614+
assert _get_user_email(user) is None
615+
616+
def test_object_with_email_address(self):
617+
"""Test extraction from object with emailAddress attribute."""
618+
user = MagicMock(emailAddress="test@example.com")
619+
assert _get_user_email(user) == "test@example.com"
620+
621+
def test_object_without_email_address(self):
622+
"""Test object without emailAddress attribute returns None."""
623+
user = MagicMock(spec=["name", "id"])
624+
assert _get_user_email(user) is None
625+
626+
627+
class TestEscalationToolTaskInfo:
628+
"""Test that escalation tool extracts task_id and assigned_to."""
629+
630+
@pytest.fixture
631+
def escalation_resource(self):
632+
"""Create a minimal escalation tool resource config."""
633+
return AgentEscalationResourceConfig(
634+
name="approval",
635+
description="Request approval",
636+
channels=[
637+
AgentEscalationChannel(
638+
name="action_center",
639+
type="actionCenter",
640+
description="Action Center channel",
641+
input_schema={"type": "object", "properties": {}},
642+
output_schema={"type": "object", "properties": {}},
643+
properties=AgentEscalationChannelProperties(
644+
app_name="ApprovalApp",
645+
app_version=1,
646+
resource_key="test-key",
647+
),
648+
recipients=[],
649+
)
650+
],
651+
)
652+
653+
@pytest.mark.asyncio
654+
@patch("uipath_langchain.agent.tools.escalation_tool.interrupt")
655+
async def test_wrapper_returns_task_id_and_assigned_to(
656+
self, mock_interrupt, escalation_resource
657+
):
658+
"""Test that wrapper result includes task_id and assigned_to from Task."""
659+
mock_result = MagicMock()
660+
mock_result.id = 12345
661+
mock_result.key = None
662+
mock_result.assigned_to_user = {"emailAddress": "user@example.com"}
663+
mock_result.action = "approve"
664+
mock_result.data = {"reason": "looks good"}
665+
mock_interrupt.return_value = mock_result
666+
667+
tool = create_escalation_tool(escalation_resource)
668+
call = ToolCall(args={}, id="test-call", name=tool.name)
669+
result = await tool.awrapper(tool, call, {}) # type: ignore[attr-defined]
670+
671+
assert result["task_id"] == 12345
672+
assert result["assigned_to"] == "user@example.com"
673+
assert result["outcome"] == "approve"
674+
675+
@pytest.mark.asyncio
676+
@patch("uipath_langchain.agent.tools.escalation_tool.interrupt")
677+
async def test_wrapper_handles_missing_assigned_to_user(
678+
self, mock_interrupt, escalation_resource
679+
):
680+
"""Test that wrapper handles None assigned_to_user gracefully."""
681+
mock_result = MagicMock()
682+
mock_result.id = 99999
683+
mock_result.key = None
684+
mock_result.assigned_to_user = None
685+
mock_result.action = "reject"
686+
mock_result.data = {}
687+
mock_interrupt.return_value = mock_result
688+
689+
tool = create_escalation_tool(escalation_resource)
690+
call = ToolCall(args={}, id="test-call", name=tool.name)
691+
result = await tool.awrapper(tool, call, {}) # type: ignore[attr-defined]
692+
693+
assert result["task_id"] == 99999
694+
assert result["assigned_to"] is None

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)