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
2224from uipath .platform .guardrails import (
2325 BaseGuardrail ,
2426 GuardrailScope ,
3133from ...react .utils import extract_current_tool_call_index , find_latest_ai_message
3234from ..types import ExecutionStage
3335from ..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
3739class 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
237323def _parse_reviewed_data (value : Any ) -> Any :
0 commit comments