Skip to content

Commit b612392

Browse files
valentinabojanValentina Bojan
andauthored
feat(guardrails): create new node to create hitl task in order to capture the task url (#544)
Co-authored-by: Valentina Bojan <valentina.bojan@uipath.com>
1 parent 156906b commit b612392

10 files changed

Lines changed: 1350 additions & 825 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.40"
3+
version = "0.5.41"
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/guardrails/actions/base_action.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
from abc import ABC, abstractmethod
2-
from typing import Any
2+
from typing import Any, Sequence, Union
33

44
from uipath.platform.guardrails import BaseGuardrail, GuardrailScope
55

66
from uipath_langchain.agent.guardrails.types import ExecutionStage
77

88
GuardrailActionNode = tuple[str, Any]
99

10+
# A single node or an ordered sequence of nodes that will be chained with edges.
11+
# When a sequence is returned the first node is the entry-point (the one the
12+
# guardrail evaluation node routes to on failure) and the **last** node gets the
13+
# outgoing edge to `next_node`.
14+
GuardrailActionNodes = Union[GuardrailActionNode, Sequence[GuardrailActionNode]]
15+
1016

1117
class GuardrailAction(ABC):
1218
"""Extensible action interface producing a node to enforce the action on guardrail validation failure."""
@@ -25,6 +31,11 @@ def action_node(
2531
scope: GuardrailScope,
2632
execution_stage: ExecutionStage,
2733
guarded_component_name: str,
28-
) -> GuardrailActionNode:
29-
"""Create and return the Action node to execute on validation failure."""
34+
) -> GuardrailActionNodes:
35+
"""Create and return the Action node(s) to execute on validation failure.
36+
37+
May return a single ``(name, callable)`` tuple or an ordered sequence of
38+
such tuples. When a sequence is returned the nodes will be chained
39+
together with edges in the subgraph (first → second → … → next_node).
40+
"""
3041
...

src/uipath_langchain/agent/guardrails/actions/block_action.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ async def _node(_state: AgentGuardrailsGraphState):
3939
raise AgentTerminationException(
4040
code=UiPathErrorCode.EXECUTION_ERROR,
4141
title="Guardrail violation",
42-
detail=self.reason,
42+
detail=f"Execution was blocked by guardrail [{guardrail.name}], with reason: {self.reason}",
4343
category=UiPathErrorCategory.USER,
4444
)
4545

src/uipath_langchain/agent/guardrails/actions/escalate_action.py

Lines changed: 110 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
AssetRecipient,
1919
StandardRecipient,
2020
)
21-
from uipath.platform.common import CreateEscalation, UiPathConfig
21+
from uipath.platform import UiPath
22+
from uipath.platform.action_center.tasks import Task, TaskRecipient
23+
from uipath.platform.common import UiPathConfig, WaitEscalation
2224
from uipath.platform.guardrails import (
2325
BaseGuardrail,
2426
GuardrailScope,
@@ -31,7 +33,7 @@
3133
from ...react.utils import extract_current_tool_call_index, find_latest_ai_message
3234
from ..types import ExecutionStage
3335
from ..utils import _extract_tool_args_from_message, get_message_content
34-
from .base_action import GuardrailAction, GuardrailActionNode
36+
from .base_action import GuardrailAction, GuardrailActionNodes
3537

3638

3739
class EscalateAction(GuardrailAction):
@@ -73,19 +75,22 @@ def action_node(
7375
scope: GuardrailScope,
7476
execution_stage: ExecutionStage,
7577
guarded_component_name: str,
76-
) -> GuardrailActionNode:
77-
"""Create a HITL escalation node for the guardrail.
78-
79-
Args:
80-
guardrail: The guardrail that triggered this escalation action.
81-
scope: The guardrail scope (LLM/AGENT/TOOL).
82-
execution_stage: The execution stage (PRE_EXECUTION or POST_EXECUTION).
83-
84-
Returns:
85-
A tuple of (node_name, node_function) where the node function triggers
86-
a HITL interruption and processes the escalation response.
78+
) -> GuardrailActionNodes:
79+
"""Create two HITL escalation nodes for the guardrail.
80+
81+
Returns an ordered pair of nodes that the subgraph builder will chain:
82+
83+
1. **create-task node** – creates the Action Center task and persists
84+
the task id in ``inner_state.hitl_task_info`` so it survives across
85+
graph super-steps. Because this node completes normally its state
86+
update *is* committed to the checkpoint.
87+
2. **interrupt node** – calls ``interrupt(WaitEscalation(…))`` to pause
88+
execution. On resume, it reads the stored task data from state
89+
(skipping re-creation) and processes the escalation response.
8790
"""
88-
node_name = _get_node_name(execution_stage, guardrail, scope)
91+
base_name = _get_node_name(execution_stage, guardrail, scope)
92+
create_task_node_name = f"{base_name}_create_task"
93+
interrupt_node_name = base_name
8994

9095
metadata: Dict[str, Any] = {
9196
"guardrail": guardrail,
@@ -98,10 +103,18 @@ def action_node(
98103
},
99104
}
100105

101-
async def _node(
106+
# Node 1: create the escalation task
107+
async def _create_task_node(
102108
state: AgentGuardrailsGraphState,
103-
) -> Dict[str, Any] | Command[Any]:
104-
# Import here to avoid circular dependency
109+
) -> Dict[str, Any]:
110+
# If the task was already created (e.g. subgraph replayed this
111+
# node on resume), skip creation and return as-is.
112+
hitl_info = state.inner_state.hitl_task_info
113+
existing_task = hitl_info.get(create_task_node_name) if hitl_info else None
114+
if existing_task is not None:
115+
return {}
116+
117+
# Lazy import to avoid circular dependency with escalation_tool
105118
from ...tools.escalation_tool import resolve_recipient_value
106119

107120
# Resolve recipient value (handles both StandardRecipient and AssetRecipient)
@@ -179,12 +192,60 @@ async def _node(
179192
data["ToolInputs"] = input_content
180193
data["ToolOutputs"] = output_content
181194

195+
# Create the escalation task via API
196+
client = UiPath()
197+
created_task = await client.tasks.create_async(
198+
title="Agents Guardrail Task",
199+
data=data,
200+
app_name=self.app_name,
201+
app_folder_path=self.app_folder_path,
202+
recipient=task_recipient,
203+
)
204+
205+
# Store task URL in metadata for observability — before interrupt
206+
task_id = created_task.id
207+
task_url = f"{UiPathConfig.base_url}/actions_/tasks/{task_id}"
208+
metadata["escalation_data"]["task_url"] = task_url
209+
metadata["node_type"] = "create_hitl_task"
210+
211+
# Persist task info in state so the interrupt node can read it,
212+
# and so that on resume this node can detect it already ran.
213+
return {
214+
"inner_state": {
215+
"hitl_task_info": {
216+
create_task_node_name: created_task.model_dump(mode="json")
217+
},
218+
}
219+
}
220+
221+
_create_task_node.__metadata__ = metadata # type: ignore[attr-defined]
222+
223+
# ── Node 2: interrupt and wait for escalation result ─────────────
224+
async def _interrupt_node(
225+
state: AgentGuardrailsGraphState,
226+
) -> Dict[str, Any] | Command[Any]:
227+
hitl_info = state.inner_state.hitl_task_info
228+
task_info = hitl_info.get(create_task_node_name) if hitl_info else None
229+
if task_info is None:
230+
raise AgentTerminationException(
231+
code=UiPathErrorCode.EXECUTION_ERROR,
232+
title="Escalation task not found",
233+
detail="The escalation task was not found in state. "
234+
"The create-task node must run before the interrupt node.",
235+
)
236+
237+
created_task = Task.model_validate(task_info)
238+
task_recipient = (
239+
TaskRecipient.model_validate(task_info["recipient"])
240+
if task_info.get("recipient")
241+
else None
242+
)
243+
182244
escalation_result = interrupt(
183-
CreateEscalation(
245+
WaitEscalation(
246+
action=created_task,
184247
app_name=self.app_name,
185248
app_folder_path=self.app_folder_path,
186-
title="Agents Guardrail Task",
187-
data=data,
188249
recipient=task_recipient,
189250
)
190251
)
@@ -214,24 +275,49 @@ async def _node(
214275
if reviewed_by:
215276
metadata["escalation_data"]["reviewed_by"] = reviewed_by
216277

278+
# Clear the stored task data
279+
cleanup_update: Dict[str, Any] = {
280+
"inner_state": {"hitl_task_info": {create_task_node_name: None}},
281+
}
282+
217283
if escalation_result.action == "Approve":
218-
return _process_escalation_response(
284+
result = _process_escalation_response(
219285
state,
220286
escalation_result.data,
221287
scope,
222288
execution_stage,
223289
guarded_component_name,
224290
)
291+
# Merge the cleanup into the response
292+
if isinstance(result, Command) and result.update:
293+
_deep_merge(result.update, cleanup_update)
294+
elif isinstance(result, dict):
295+
_deep_merge(result, cleanup_update)
296+
else:
297+
result = cleanup_update
298+
return result
225299

226300
raise AgentTerminationException(
227301
code=UiPathErrorCode.EXECUTION_ERROR,
228302
title="Escalation rejected",
229-
detail=f"Please contact your administrator. Action was rejected after reviewing the task created by guardrail [{guardrail.name}], with reason: {escalation_result.data.get('Reason', None)}",
303+
detail=f"Action was rejected after reviewing the task created by guardrail [{guardrail.name}], with reason: {escalation_result.data.get('Reason', None)}",
230304
)
231305

232-
_node.__metadata__ = metadata # type: ignore[attr-defined]
306+
_interrupt_node.__metadata__ = metadata # type: ignore[attr-defined]
233307

234-
return node_name, _node
308+
return [
309+
(create_task_node_name, _create_task_node),
310+
(interrupt_node_name, _interrupt_node),
311+
]
312+
313+
314+
def _deep_merge(target: Dict[str, Any], source: Dict[str, Any]) -> None:
315+
"""Recursively merge *source* into *target* in place (dict values are merged, others overwritten)."""
316+
for key, value in source.items():
317+
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
318+
_deep_merge(target[key], value)
319+
else:
320+
target[key] = value
235321

236322

237323
def _parse_reviewed_data(value: Any) -> Any:

src/uipath_langchain/agent/react/guardrails/guardrails_subgraph.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from uipath_langchain.agent.guardrails.actions.base_action import (
1616
GuardrailAction,
1717
GuardrailActionNode,
18+
GuardrailActionNodes,
1819
)
1920
from uipath_langchain.agent.guardrails.guardrail_nodes import (
2021
create_agent_init_guardrail_node,
@@ -172,16 +173,25 @@ def _build_guardrail_node_chain(
172173
guardrail, action = guardrails[-1]
173174
remaining_guardrails = guardrails[:-1]
174175

175-
fail_node_name, fail_node = action.action_node(
176+
action_nodes: GuardrailActionNodes = action.action_node(
176177
guardrail=guardrail,
177178
scope=scope,
178179
execution_stage=execution_stage,
179180
guarded_component_name=guarded_node_name,
180181
)
181182

183+
# Normalize to a list: single tuple → list of one tuple
184+
if isinstance(action_nodes, tuple):
185+
action_nodes = [action_nodes]
186+
187+
# The first node is where the guardrail-eval routes on failure;
188+
# the last node gets the outgoing edge to ``next_node``.
189+
first_fail_node_name = action_nodes[0][0]
190+
last_fail_node_name = action_nodes[-1][0]
191+
182192
# Create the guardrail evaluation node.
183193
guardrail_node_name, guardrail_node = node_factory(
184-
guardrail, execution_stage, next_node, fail_node_name
194+
guardrail, execution_stage, next_node, first_fail_node_name
185195
)
186196

187197
guardrail_node_metadata = getattr(guardrail_node, "__metadata__", None) or {}
@@ -191,21 +201,27 @@ def _build_guardrail_node_chain(
191201
"node_type": "guardrail_evaluation",
192202
}
193203

194-
fail_node_metadata = getattr(fail_node, "__metadata__", None) or {}
195-
fail_node_metadata = {
196-
**fail_node_metadata,
197-
"action_type": action.action_type,
198-
"node_type": "guardrail_action",
199-
"tool_type": guardrail_node_metadata.get("tool_type"),
200-
}
201-
202204
subgraph.add_node(
203205
guardrail_node_name, guardrail_node, metadata={**guardrail_node_metadata}
204206
)
205-
subgraph.add_node(fail_node_name, fail_node, metadata={**fail_node_metadata})
206207

207-
# Failure path route to the next node
208-
subgraph.add_edge(fail_node_name, next_node)
208+
# Add all action nodes and chain them together
209+
for i, (fail_node_name, fail_node) in enumerate(action_nodes):
210+
fail_node_metadata = getattr(fail_node, "__metadata__", None) or {}
211+
fail_node_metadata = {
212+
**fail_node_metadata,
213+
"action_type": action.action_type,
214+
"node_type": "guardrail_action",
215+
"tool_type": guardrail_node_metadata.get("tool_type"),
216+
}
217+
subgraph.add_node(fail_node_name, fail_node, metadata={**fail_node_metadata})
218+
# Chain consecutive action nodes: node[i] → node[i+1]
219+
if i > 0:
220+
prev_name = action_nodes[i - 1][0]
221+
subgraph.add_edge(prev_name, fail_node_name)
222+
223+
# Last action node routes to the next guardrail / inner node
224+
subgraph.add_edge(last_fail_node_name, next_node)
209225

210226
previous_node_name = _build_guardrail_node_chain(
211227
subgraph,

src/uipath_langchain/agent/react/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class InnerAgentGuardrailsGraphState(InnerAgentGraphState):
3535
guardrail_validation_result: Optional[bool] = None
3636
guardrail_validation_details: Optional[str] = None
3737
agent_result: Optional[dict[str, Any]] = None
38+
hitl_task_info: Optional[Any] = {}
3839

3940

4041
class AgentGraphState(BaseModel):

tests/agent/guardrails/actions/test_block_action.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,7 @@ async def test_node_name_and_exception(
7373
await node(AgentGuardrailsGraphState(messages=[]))
7474

7575
# The exception string is the provided reason
76-
assert str(excinfo.value) == "Sensitive data detected"
76+
assert (
77+
str(excinfo.value)
78+
== "Execution was blocked by guardrail [My Guardrail v1], with reason: Sensitive data detected"
79+
)

0 commit comments

Comments
 (0)