Skip to content

Commit 48184b8

Browse files
Merge pull request #240 from UiPath/feat/add-escalations-support
feat: add escalation tool for ReAct Agent
2 parents 76a6b0f + 89802b2 commit 48184b8

File tree

7 files changed

+180
-20
lines changed

7 files changed

+180
-20
lines changed

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.1.20"
3+
version = "0.1.21"
44
description = "UiPath Langchain"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath_langchain/agent/react/terminate_node.py

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""Termination node for the Agent graph."""
22

3+
from __future__ import annotations
4+
5+
from typing import Any, NoReturn
6+
37
from langchain_core.messages import AIMessage
48
from pydantic import BaseModel
59
from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL
@@ -9,15 +13,53 @@
913
AgentNodeRoutingException,
1014
AgentTerminationException,
1115
)
12-
from .types import AgentGraphState
16+
from .types import AgentGraphState, AgentTermination
17+
18+
19+
def _handle_end_execution(
20+
args: dict[str, Any], response_schema: type[BaseModel] | None
21+
) -> dict[str, Any]:
22+
"""Handle LLM-initiated termination via END_EXECUTION_TOOL."""
23+
output_schema = response_schema or END_EXECUTION_TOOL.args_schema
24+
validated = output_schema.model_validate(args)
25+
return validated.model_dump()
26+
27+
28+
def _handle_raise_error(args: dict[str, Any]) -> NoReturn:
29+
"""Handle LLM-initiated error via RAISE_ERROR_TOOL."""
30+
error_message = args.get("message", "The LLM did not set the error message")
31+
detail = args.get("details", "")
32+
raise AgentTerminationException(
33+
code=UiPathErrorCode.EXECUTION_ERROR,
34+
title=error_message,
35+
detail=detail,
36+
)
37+
38+
39+
def _handle_agent_termination(termination: AgentTermination) -> NoReturn:
40+
"""Handle Command-based termination."""
41+
raise AgentTerminationException(
42+
code=UiPathErrorCode.EXECUTION_ERROR,
43+
title=termination.title,
44+
detail=termination.detail,
45+
)
1346

1447

1548
def create_terminate_node(
1649
response_schema: type[BaseModel] | None = None,
1750
):
18-
"""Validates and extracts end_execution args to state output field."""
51+
"""Handles Agent Graph termination for multiple sources and output or error propagation to Orchestrator.
52+
53+
Termination scenarios:
54+
1. Command based termination with information in state (e.g: escalation)
55+
2. LLM-initiated termination (END_EXECUTION_TOOL)
56+
3. LLM-initiated error (RAISE_ERROR_TOOL)
57+
"""
1958

2059
def terminate_node(state: AgentGraphState):
60+
if state.termination:
61+
_handle_agent_termination(state.termination)
62+
2163
last_message = state.messages[-1]
2264
if not isinstance(last_message, AIMessage):
2365
raise AgentNodeRoutingException(
@@ -28,21 +70,10 @@ def terminate_node(state: AgentGraphState):
2870
tool_name = tool_call["name"]
2971

3072
if tool_name == END_EXECUTION_TOOL.name:
31-
args = tool_call["args"]
32-
output_schema = response_schema or END_EXECUTION_TOOL.args_schema
33-
validated = output_schema.model_validate(args)
34-
return validated.model_dump()
73+
return _handle_end_execution(tool_call["args"], response_schema)
3574

3675
if tool_name == RAISE_ERROR_TOOL.name:
37-
error_message = tool_call["args"].get(
38-
"message", "The LLM did not set the error message"
39-
)
40-
detail = tool_call["args"].get("details", "")
41-
raise AgentTerminationException(
42-
code=UiPathErrorCode.EXECUTION_ERROR,
43-
title=error_message,
44-
detail=detail,
45-
)
76+
_handle_raise_error(tool_call["args"])
4677

4778
raise AgentNodeRoutingException(
4879
"No control flow tool call found in terminate node. Unexpected state."

src/uipath_langchain/agent/react/types.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,23 @@
66
from pydantic import BaseModel, Field
77

88

9+
class AgentTerminationSource(StrEnum):
10+
ESCALATION = "escalation"
11+
12+
13+
class AgentTermination(BaseModel):
14+
"""Agent Graph Termination model."""
15+
16+
source: AgentTerminationSource
17+
title: str
18+
detail: str = ""
19+
20+
921
class AgentGraphState(BaseModel):
1022
"""Agent Graph state for standard loop execution."""
1123

1224
messages: Annotated[list[AnyMessage], add_messages] = []
25+
termination: AgentTermination | None = None
1326

1427

1528
class AgentGraphNode(StrEnum):

src/uipath_langchain/agent/react/utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@
22

33
from typing import Any, Sequence
44

5-
from jsonschema_pydantic_converter import transform as create_model
65
from langchain_core.messages import AIMessage, BaseMessage
76
from pydantic import BaseModel
87
from uipath.agent.react import END_EXECUTION_TOOL
8+
from uipath.utils.dynamic_schema import jsonschema_to_pydantic
99

1010

1111
def resolve_input_model(
1212
input_schema: dict[str, Any] | None,
1313
) -> type[BaseModel]:
1414
"""Resolve the input model from the input schema."""
1515
if input_schema:
16-
return create_model(input_schema)
16+
return jsonschema_to_pydantic(input_schema)
1717

1818
return BaseModel
1919

@@ -23,7 +23,7 @@ def resolve_output_model(
2323
) -> type[BaseModel]:
2424
"""Fallback to default end_execution tool schema when no agent output schema is provided."""
2525
if output_schema:
26-
return create_model(output_schema)
26+
return jsonschema_to_pydantic(output_schema)
2727

2828
return END_EXECUTION_TOOL.args_schema
2929

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Escalation tool creation for Action Center integration."""
2+
3+
from enum import Enum
4+
from typing import Any
5+
6+
from jsonschema_pydantic_converter import transform as create_model
7+
from langchain.tools import ToolRuntime
8+
from langchain_core.messages import ToolMessage
9+
from langchain_core.tools import StructuredTool
10+
from langgraph.types import Command, interrupt
11+
from uipath.agent.models.agent import (
12+
AgentEscalationChannel,
13+
AgentEscalationRecipientType,
14+
AgentEscalationResourceConfig,
15+
)
16+
from uipath.eval.mocks import mockable
17+
from uipath.platform.common import CreateEscalation
18+
19+
from ..react.types import AgentGraphNode, AgentTerminationSource
20+
from .utils import sanitize_tool_name
21+
22+
23+
class EscalationAction(str, Enum):
24+
"""Actions that can be taken after an escalation completes."""
25+
26+
CONTINUE = "continue"
27+
END = "end"
28+
29+
30+
def create_escalation_tool(resource: AgentEscalationResourceConfig) -> StructuredTool:
31+
"""Uses interrupt() for Action Center human-in-the-loop."""
32+
33+
tool_name: str = f"escalate_{sanitize_tool_name(resource.name)}"
34+
channel: AgentEscalationChannel = resource.channels[0]
35+
36+
input_model: Any = create_model(channel.input_schema)
37+
output_model: Any = create_model(channel.output_schema)
38+
39+
assignee: str | None = (
40+
channel.recipients[0].value
41+
if channel.recipients
42+
and channel.recipients[0].type == AgentEscalationRecipientType.USER_EMAIL
43+
else None
44+
)
45+
46+
@mockable(
47+
name=resource.name,
48+
description=resource.description,
49+
input_schema=input_model.model_json_schema(),
50+
output_schema=output_model.model_json_schema(),
51+
)
52+
async def escalation_tool_fn(
53+
runtime: ToolRuntime, **kwargs: Any
54+
) -> Command[Any] | Any:
55+
task_title = channel.task_title or "Escalation Task"
56+
57+
result = interrupt(
58+
CreateEscalation(
59+
title=task_title,
60+
data=kwargs,
61+
assignee=assignee,
62+
app_name=channel.properties.app_name,
63+
app_folder_path=channel.properties.folder_name,
64+
app_version=channel.properties.app_version,
65+
priority=channel.priority,
66+
labels=channel.labels,
67+
is_actionable_message_enabled=channel.properties.is_actionable_message_enabled,
68+
actionable_message_metadata=channel.properties.actionable_message_meta_data,
69+
)
70+
)
71+
72+
escalation_action = getattr(result, "action", None)
73+
escalation_output = getattr(result, "data", {})
74+
75+
outcome = (
76+
channel.outcome_mapping.get(escalation_action)
77+
if channel.outcome_mapping and escalation_action
78+
else None
79+
)
80+
81+
if outcome == EscalationAction.END:
82+
output_detail = f"Escalation output: {escalation_output}"
83+
termination_title = f"Agent run ended based on escalation outcome {outcome} with directive {escalation_action}"
84+
85+
return Command(
86+
update={
87+
"messages": [
88+
ToolMessage(
89+
content=f"{termination_title}. {output_detail}",
90+
tool_call_id=runtime.tool_call_id,
91+
)
92+
],
93+
"termination": {
94+
"source": AgentTerminationSource.ESCALATION,
95+
"title": termination_title,
96+
"detail": output_detail,
97+
},
98+
},
99+
goto=AgentGraphNode.TERMINATE,
100+
)
101+
102+
return escalation_output
103+
104+
tool = StructuredTool(
105+
name=tool_name,
106+
description=resource.description,
107+
args_schema=input_model,
108+
coroutine=escalation_tool_fn,
109+
)
110+
111+
return tool

src/uipath_langchain/agent/tools/tool_factory.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
from langchain_core.tools import BaseTool, StructuredTool
44
from uipath.agent.models.agent import (
55
AgentContextResourceConfig,
6+
AgentEscalationResourceConfig,
67
AgentIntegrationToolResourceConfig,
78
AgentProcessToolResourceConfig,
89
BaseAgentResourceConfig,
910
LowCodeAgentDefinition,
1011
)
1112

1213
from .context_tool import create_context_tool
14+
from .escalation_tool import create_escalation_tool
1315
from .integration_tool import create_integration_tool
1416
from .process_tool import create_process_tool
1517

@@ -34,6 +36,9 @@ async def _build_tool_for_resource(
3436
elif isinstance(resource, AgentContextResourceConfig):
3537
return create_context_tool(resource)
3638

39+
elif isinstance(resource, AgentEscalationResourceConfig):
40+
return create_escalation_tool(resource)
41+
3742
elif isinstance(resource, AgentIntegrationToolResourceConfig):
3843
return create_integration_tool(resource)
3944

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)