Skip to content

Commit 07feac2

Browse files
Merge pull request #373 from UiPath/hitl-agent-2
feat: pass and update correct data for agent HITL action
2 parents 731d81b + 2c7a7b2 commit 07feac2

6 files changed

Lines changed: 413 additions & 256 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.1.39"
3+
version = "0.1.40"
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/escalate_action.py

Lines changed: 105 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,20 @@ async def _node(
9090
# PRE_EXECUTION: Only Inputs field from last message
9191
input_content = _extract_escalation_content(
9292
state.messages[-1],
93+
state,
9394
scope,
9495
execution_stage,
9596
guarded_component_name,
9697
)
9798
data["Inputs"] = input_content
9899
else: # POST_EXECUTION
99-
# Extract Inputs from second-to-last message using PRE_EXECUTION logic
100+
if scope == GuardrailScope.AGENT:
101+
input_message = state.messages[1]
102+
else:
103+
input_message = state.messages[-2]
100104
input_content = _extract_escalation_content(
101-
state.messages[-2],
105+
input_message,
106+
state,
102107
scope,
103108
ExecutionStage.PRE_EXECUTION,
104109
guarded_component_name,
@@ -107,6 +112,7 @@ async def _node(
107112
# Extract Outputs from last message using POST_EXECUTION logic
108113
output_content = _extract_escalation_content(
109114
state.messages[-1],
115+
state,
110116
scope,
111117
execution_stage,
112118
guarded_component_name,
@@ -119,7 +125,7 @@ async def _node(
119125
CreateEscalation(
120126
app_name=self.app_name,
121127
app_folder_path=self.app_folder_path,
122-
title=self.app_name,
128+
title="Agents Guardrail Task",
123129
data=data,
124130
assignee=self.assignee,
125131
)
@@ -137,7 +143,7 @@ async def _node(
137143
raise AgentTerminationException(
138144
code=UiPathErrorCode.EXECUTION_ERROR,
139145
title="Escalation rejected",
140-
detail=f"Action was rejected after reviewing the task created by guardrail [{guardrail.name}]. Please contact your administrator.",
146+
detail=f"Please contact your administrator. Action was rejected after reviewing the task created by guardrail [{guardrail.name}], with reason: {escalation_result.data['Reason']}",
141147
)
142148

143149
return node_name, _node
@@ -179,8 +185,8 @@ def _validate_message_count(
179185
def _get_node_name(
180186
execution_stage: ExecutionStage, guardrail: BaseGuardrail, scope: GuardrailScope
181187
) -> str:
182-
sanitized = re.sub(r"\W+", "_", guardrail.name).strip("_").lower()
183-
node_name = f"{sanitized}_hitl_{execution_stage.name.lower()}_{scope.lower()}"
188+
raw_node_name = f"{scope.name}_{execution_stage.name}_{guardrail.name}_hitl"
189+
node_name = re.sub(r"\W+", "_", raw_node_name.lower()).strip("_")
184190
return node_name
185191

186192

@@ -200,8 +206,8 @@ def _process_escalation_response(
200206
execution_stage: The execution stage (PRE_EXECUTION or POST_EXECUTION).
201207
202208
Returns:
203-
For LLM/TOOL scope: Command to update messages with reviewed inputs/outputs, or empty dict.
204-
For AGENT scope: Empty dict (no message alteration).
209+
Command updates for the state (e.g., updating messages / tool calls / agent_result),
210+
or an empty dict if no update is needed.
205211
"""
206212
match scope:
207213
case GuardrailScope.LLM:
@@ -213,8 +219,71 @@ def _process_escalation_response(
213219
state, escalation_result, execution_stage, guarded_node_name
214220
)
215221
case GuardrailScope.AGENT:
222+
return _process_agent_escalation_response(
223+
state, escalation_result, execution_stage
224+
)
225+
226+
227+
def _process_agent_escalation_response(
228+
state: AgentGuardrailsGraphState,
229+
escalation_result: Dict[str, Any],
230+
execution_stage: ExecutionStage,
231+
) -> Dict[str, Any] | Command[Any]:
232+
"""Process escalation response for AGENT scope guardrails.
233+
234+
For AGENT scope:
235+
- PRE_EXECUTION: updates the last message content using `ReviewedInputs`
236+
- POST_EXECUTION: updates `agent_result` using `ReviewedOutputs`
237+
238+
Args:
239+
state: The current agent graph state.
240+
escalation_result: The result from the escalation interrupt containing reviewed inputs/outputs.
241+
execution_stage: The execution stage (PRE_EXECUTION or POST_EXECUTION).
242+
243+
Returns:
244+
Command to update state, or empty dict if no updates are needed.
245+
246+
Raises:
247+
AgentTerminationException: If escalation response processing fails.
248+
"""
249+
try:
250+
reviewed_field = get_reviewed_field_name(execution_stage)
251+
if reviewed_field not in escalation_result:
252+
return {}
253+
254+
reviewed_value = escalation_result.get(reviewed_field)
255+
if not reviewed_value:
216256
return {}
217257

258+
try:
259+
parsed = json.loads(reviewed_value)
260+
except json.JSONDecodeError:
261+
parsed = reviewed_value
262+
263+
if execution_stage == ExecutionStage.PRE_EXECUTION:
264+
msgs = state.messages.copy()
265+
if not msgs:
266+
return {}
267+
msgs[-1].content = parsed
268+
return Command(update={"messages": msgs})
269+
270+
# POST_EXECUTION: update agent_result
271+
return Command(update={"agent_result": parsed})
272+
except Exception as e:
273+
raise AgentTerminationException(
274+
code=UiPathErrorCode.EXECUTION_ERROR,
275+
title="Escalation rejected",
276+
detail=str(e),
277+
) from e
278+
279+
280+
def get_reviewed_field_name(execution_stage):
281+
return (
282+
"ReviewedInputs"
283+
if execution_stage == ExecutionStage.PRE_EXECUTION
284+
else "ReviewedOutputs"
285+
)
286+
218287

219288
def _process_llm_escalation_response(
220289
state: AgentGuardrailsGraphState,
@@ -237,11 +306,7 @@ def _process_llm_escalation_response(
237306
AgentTerminationException: If escalation response processing fails.
238307
"""
239308
try:
240-
reviewed_field = (
241-
"ReviewedInputs"
242-
if execution_stage == ExecutionStage.PRE_EXECUTION
243-
else "ReviewedOutputs"
244-
)
309+
reviewed_field = get_reviewed_field_name(execution_stage)
245310

246311
msgs = state.messages.copy()
247312
if not msgs or reviewed_field not in escalation_result:
@@ -342,11 +407,7 @@ def _process_tool_escalation_response(
342407
AgentTerminationException: If escalation response processing fails.
343408
"""
344409
try:
345-
reviewed_field = (
346-
"ReviewedInputs"
347-
if execution_stage == ExecutionStage.PRE_EXECUTION
348-
else "ReviewedOutputs"
349-
)
410+
reviewed_field = get_reviewed_field_name(execution_stage)
350411

351412
msgs = state.messages.copy()
352413
if not msgs or reviewed_field not in escalation_result:
@@ -404,6 +465,7 @@ def _process_tool_escalation_response(
404465

405466
def _extract_escalation_content(
406467
message: BaseMessage,
468+
state: AgentGuardrailsGraphState,
407469
scope: GuardrailScope,
408470
execution_stage: ExecutionStage,
409471
guarded_node_name: str,
@@ -424,13 +486,36 @@ def _extract_escalation_content(
424486
case GuardrailScope.LLM:
425487
return _extract_llm_escalation_content(message, execution_stage)
426488
case GuardrailScope.AGENT:
427-
return _extract_agent_escalation_content(message, execution_stage)
489+
return _extract_agent_escalation_content(message, state, execution_stage)
428490
case GuardrailScope.TOOL:
429491
return _extract_tool_escalation_content(
430492
message, execution_stage, guarded_node_name
431493
)
432494

433495

496+
def _extract_agent_escalation_content(
497+
message: BaseMessage,
498+
state: AgentGuardrailsGraphState,
499+
execution_stage: ExecutionStage,
500+
) -> str | list[str | Dict[str, Any]]:
501+
"""Extract escalation content for AGENT scope guardrails.
502+
503+
Args:
504+
message: The message used to extract the agent input content.
505+
state: The current agent guardrails graph state. Used to read `agent_result` for POST_EXECUTION.
506+
execution_stage: PRE_EXECUTION or POST_EXECUTION.
507+
508+
Returns:
509+
- PRE_EXECUTION: the agent input string (from message content).
510+
- POST_EXECUTION: a JSON-serialized representation of `state.agent_result`.
511+
"""
512+
if execution_stage == ExecutionStage.PRE_EXECUTION:
513+
return get_message_content(cast(AnyMessage, message))
514+
515+
output_content = state.agent_result or ""
516+
return json.dumps(output_content)
517+
518+
434519
def _extract_llm_escalation_content(
435520
message: BaseMessage, execution_stage: ExecutionStage
436521
) -> str | list[str | Dict[str, Any]]:
@@ -449,8 +534,7 @@ def _extract_llm_escalation_content(
449534
if isinstance(message, ToolMessage):
450535
return message.content
451536

452-
content = get_message_content(cast(AnyMessage, message))
453-
return json.dumps(content) if content else ""
537+
return get_message_content(cast(AnyMessage, message))
454538

455539
# For AI messages, process tool calls if present
456540
if isinstance(message, AIMessage):
@@ -470,21 +554,6 @@ def _extract_llm_escalation_content(
470554
return get_message_content(cast(AnyMessage, message))
471555

472556

473-
def _extract_agent_escalation_content(
474-
message: BaseMessage, execution_stage: ExecutionStage
475-
) -> str | list[str | Dict[str, Any]]:
476-
"""Extract escalation content for AGENT scope guardrails.
477-
478-
Args:
479-
message: The message to extract content from.
480-
execution_stage: The execution stage (PRE_EXECUTION or POST_EXECUTION).
481-
482-
Returns:
483-
str: Empty string (AGENT scope guardrails do not extract escalation content).
484-
"""
485-
return ""
486-
487-
488557
def _extract_tool_escalation_content(
489558
message: BaseMessage, execution_stage: ExecutionStage, tool_name: str
490559
) -> str | list[str | Dict[str, Any]]:

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

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,16 @@ def create_llm_guardrails_subgraph(
204204
llm_node: tuple[str, Any],
205205
guardrails: Sequence[tuple[BaseGuardrail, GuardrailAction]] | None,
206206
):
207+
"""Create a guarded LLM node.
208+
209+
Args:
210+
llm_node: Tuple of (node_name, node_callable) for the LLM node.
211+
guardrails: Optional sequence of (guardrail, action) tuples.
212+
213+
Returns:
214+
Either the original node callable (if no applicable guardrails) or a compiled
215+
LangGraph subgraph that enforces the configured guardrails.
216+
"""
207217
applicable_guardrails = [
208218
(guardrail, _)
209219
for (guardrail, _) in (guardrails or [])
@@ -226,8 +236,14 @@ def create_tools_guardrails_subgraph(
226236
tool_nodes: Mapping[str, RunnableCallable],
227237
guardrails: Sequence[tuple[BaseGuardrail, GuardrailAction]] | None,
228238
) -> dict[str, RunnableCallable]:
229-
"""Create tool nodes with guardrails.
239+
"""Create tool nodes with guardrails applied.
230240
Args:
241+
tool_nodes: Mapping of tool name to a LangGraph `ToolNode`.
242+
guardrails: Optional sequence of (guardrail, action) tuples.
243+
244+
Returns:
245+
A mapping of tool name to either the original `ToolNode` or a compiled subgraph
246+
that enforces the matching tool guardrails.
231247
"""
232248
result: dict[str, RunnableCallable] = {}
233249
for tool_name, tool_node in tool_nodes.items():
@@ -243,24 +259,49 @@ def create_tools_guardrails_subgraph(
243259
def create_agent_init_guardrails_subgraph(
244260
init_node: tuple[str, Any],
245261
guardrails: Sequence[tuple[BaseGuardrail, GuardrailAction]] | None,
246-
):
247-
"""Create a subgraph for INIT node that applies guardrails on the state messages."""
262+
) -> Any:
263+
"""Create a subgraph for the INIT node and apply AGENT guardrails after INIT.
264+
265+
This subgraph intentionally **runs the INIT node first** (so it can seed/normalize
266+
the agent state), and then evaluates guardrails as **PRE_EXECUTION**. This lets
267+
guardrails intended to run "before agent execution" validate the post-init state.
268+
269+
Args:
270+
init_node: Tuple of (node_name, node_callable) for the INIT node.
271+
guardrails: Optional sequence of (guardrail, action) tuples.
272+
273+
Returns:
274+
Either the original node callable (if no applicable guardrails) or a compiled
275+
LangGraph subgraph that runs INIT then enforces PRE_EXECUTION AGENT guardrails.
276+
"""
248277
applicable_guardrails = [
249278
(guardrail, _)
250279
for (guardrail, _) in (guardrails or [])
251280
if GuardrailScope.AGENT in guardrail.selector.scopes
252281
and not isinstance(guardrail, DeterministicGuardrail)
253282
]
283+
applicable_guardrails = _filter_guardrails_by_stage(
284+
applicable_guardrails, ExecutionStage.PRE_EXECUTION
285+
)
254286
if applicable_guardrails is None or len(applicable_guardrails) == 0:
255287
return init_node[1]
256288

257-
return _create_guardrails_subgraph(
258-
main_inner_node=init_node,
289+
inner_name, inner_node = init_node
290+
subgraph = StateGraph(AgentGuardrailsGraphState)
291+
subgraph.add_node(inner_name, inner_node)
292+
subgraph.add_edge(START, inner_name)
293+
294+
first_guardrail_node = _build_guardrail_node_chain(
295+
subgraph=subgraph,
259296
guardrails=applicable_guardrails,
260297
scope=GuardrailScope.AGENT,
261-
execution_stages=[ExecutionStage.POST_EXECUTION],
298+
execution_stage=ExecutionStage.PRE_EXECUTION,
262299
node_factory=create_agent_init_guardrail_node,
300+
next_node=END,
301+
guarded_node_name=inner_name,
263302
)
303+
subgraph.add_edge(inner_name, first_guardrail_node)
304+
return subgraph.compile()
264305

265306

266307
def create_agent_terminate_guardrails_subgraph(
@@ -306,6 +347,16 @@ def create_tool_guardrails_subgraph(
306347
tool_node: tuple[str, Any],
307348
guardrails: Sequence[tuple[BaseGuardrail, GuardrailAction]] | None,
308349
):
350+
"""Create a guarded tool node.
351+
352+
Args:
353+
tool_node: Tuple of (tool_name, tool_node_callable).
354+
guardrails: Optional sequence of (guardrail, action) tuples.
355+
356+
Returns:
357+
Either the original tool node callable (if no matching guardrails) or a compiled
358+
LangGraph subgraph that enforces the matching tool guardrails.
359+
"""
309360
tool_name, _ = tool_node
310361
applicable_guardrails = [
311362
(guardrail, action)

0 commit comments

Comments
 (0)