From 451e85646404dace3a012ea1cf59fa58cb93ea06 Mon Sep 17 00:00:00 2001 From: Dushyant Pathak Date: Mon, 22 Jun 2026 09:05:56 +0530 Subject: [PATCH 1/2] feat: generate HITL form schema dynamically via LLM for guardrail escalations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a guardrail fires and triggers an escalation, the agent's own LLM now generates a HitlSchema from the full conversation context — system prompt, user request, and the offending tool call — using `model.with_structured_output(HitlSchema)`. The generated schema travels inline with the QuickForm task request (via `create_quickform_async`). No pre-deployed Action App is required. Implementation: - `EscalateAction` (agent graph path) gains an optional `model` field. When set, schema is generated dynamically; when absent, falls back to the existing static Action App path for backward compatibility. - `create_agent` injects the model into any `EscalateAction` in the guardrails list before wiring subgraphs — no API change for callers. - Middleware `EscalateAction` is unchanged (no state.messages access). Depends on: UiPath/uipath-python#1737 (HitlSchema types + QuickForm dispatch) --- .../guardrails/actions/escalate_action.py | 63 ++++++++++++++++--- src/uipath_langchain/agent/react/agent.py | 8 +++ .../guardrails/escalate_action.py | 1 + 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/uipath_langchain/agent/guardrails/actions/escalate_action.py b/src/uipath_langchain/agent/guardrails/actions/escalate_action.py index 65ae36d2b..2308fad28 100644 --- a/src/uipath_langchain/agent/guardrails/actions/escalate_action.py +++ b/src/uipath_langchain/agent/guardrails/actions/escalate_action.py @@ -1,14 +1,18 @@ from __future__ import annotations import ast +import hashlib import json import re +import uuid from typing import Any, Dict, Literal, cast +from langchain_core.language_models import BaseChatModel from langchain_core.messages import ( AIMessage, AnyMessage, BaseMessage, + SystemMessage, ToolMessage, ) from langgraph.types import Command, interrupt @@ -28,6 +32,7 @@ BaseGuardrail, GuardrailScope, ) +from uipath.platform.hitl import HitlSchema from uipath.runtime.errors import UiPathErrorCategory from ...exceptions import AgentRuntimeError, AgentRuntimeErrorCode @@ -40,6 +45,23 @@ from .base_action import GuardrailAction, GuardrailActionNodes +_SCHEMA_GEN_PROMPT = SystemMessage( + "A guardrail policy was violated during the agent's tool execution. " + "Based on the conversation above — including the agent's purpose, the user's request, " + "and the tool call that triggered the violation — generate a human review form schema. " + "Input fields should show the reviewer the relevant context (read-only). " + "Output fields should capture the reviewer's decision and any corrections. " + "Keep the schema concise and specific to this escalation." +) + + +def _schema_key(schema: HitlSchema) -> str: + """Deterministic UUID from schema content so Orchestrator can upsert rather than duplicate.""" + wire = json.dumps(schema.to_wire_format(), sort_keys=True) + digest = hashlib.sha256(wire.encode()).digest()[:16] + return str(uuid.UUID(bytes=digest)) + + class EscalateAction(GuardrailAction): """Node-producing action that inserts a HITL interruption node into the graph. @@ -54,19 +76,26 @@ def __init__( app_folder_path: str, version: int, recipient: AgentEscalationRecipient, + model: BaseChatModel | None = None, ): """Initialize EscalateAction with escalation app configuration. Args: - app_name: Name of the escalation app. + app_name: Name of the escalation app. Used when *model* is None + (static Action App path). app_folder_path: Folder path where the escalation app is located. version: Version of the escalation app. recipient: Recipient object (StandardRecipient or AssetRecipient). + model: Optional chat model injected by the agent runtime. When set, + the schema for the HITL task is generated dynamically by the LLM + using the full conversation context at escalation time — no + pre-deployed Action App is needed. """ self.app_name = app_name self.app_folder_path = app_folder_path self.version = version self.recipient = recipient + self.model = model @property def action_type(self) -> str: @@ -211,15 +240,31 @@ async def _create_task_node( data["ToolInputs"] = input_content data["ToolOutputs"] = output_content - # Create the escalation task via API + # Create the escalation task via API. + # Dynamic path: LLM generates a schema from the full conversation + # context so the reviewer form is specific to this escalation. + # Static path (fallback): use the pre-deployed Action App. client = UiPath() - created_task = await client.tasks.create_async( - title="Agents Guardrail Task", - data=data, - app_name=self.app_name, - app_folder_path=self.app_folder_path, - recipient=task_recipient, - ) + if self.model is not None: + generated_schema: HitlSchema = await self.model.with_structured_output( + HitlSchema + ).ainvoke(state.messages + [_SCHEMA_GEN_PROMPT]) + created_task = await client.tasks.create_quickform_async( + title="Agents Guardrail Task", + task_schema_key=_schema_key(generated_schema), + schema=generated_schema.to_wire_format(), + data=data, + folder_path=self.app_folder_path, + recipient=task_recipient, + ) + else: + created_task = await client.tasks.create_async( + title="Agents Guardrail Task", + data=data, + app_name=self.app_name, + app_folder_path=self.app_folder_path, + recipient=task_recipient, + ) # Store task URL in metadata for observability — before interrupt task_id = created_task.id diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index 6aa12b5a9..4665e11d3 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -120,6 +120,14 @@ def create_agent( ): node.awrapper = cas_deep_rag_citation_wrapper + # Inject the agent's model into any EscalateAction so it can generate + # HITL form schemas dynamically from the conversation context. + if guardrails: + from ..guardrails.actions.escalate_action import EscalateAction as _EscalateAction + for _, action in guardrails: + if isinstance(action, _EscalateAction) and action.model is None: + action.model = model + tool_nodes_with_guardrails = create_tools_guardrails_subgraph( tool_nodes, guardrails, input_schema=input_schema ) diff --git a/src/uipath_langchain/guardrails/escalate_action.py b/src/uipath_langchain/guardrails/escalate_action.py index a187fb5de..87f3a8f40 100644 --- a/src/uipath_langchain/guardrails/escalate_action.py +++ b/src/uipath_langchain/guardrails/escalate_action.py @@ -58,6 +58,7 @@ GuardrailAction, GuardrailBlockException, ) +from uipath.platform.hitl import HitlSchema from ._action_context import GuardrailActionContext, current_action_context from .enums import GuardrailExecutionStage From bf6c65d803248e648b4f0c2897b08fbc6650a9e7 Mon Sep 17 00:00:00 2001 From: Dushyant Pathak Date: Fri, 17 Jul 2026 00:57:00 +0530 Subject: [PATCH 2/2] feat: support dynamic schema escalation with no pre-deployed Action App MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EscalateAction.__init__: app_name/app_folder_path/version are now optional (default None/0) so callers only need to pass recipient for the dynamic path. - guardrails_factory: when action.app is None (UI selected "Generate dynamically"), creates EscalateAction(recipient=...) — the agent runtime later injects the model and generates the HITL schema via LLM. --- .../guardrails/actions/escalate_action.py | 18 ++++++------ .../agent/guardrails/guardrails_factory.py | 28 ++++++++++++------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/uipath_langchain/agent/guardrails/actions/escalate_action.py b/src/uipath_langchain/agent/guardrails/actions/escalate_action.py index 2308fad28..6ae22e0f6 100644 --- a/src/uipath_langchain/agent/guardrails/actions/escalate_action.py +++ b/src/uipath_langchain/agent/guardrails/actions/escalate_action.py @@ -72,20 +72,20 @@ class EscalateAction(GuardrailAction): def __init__( self, - app_name: str, - app_folder_path: str, - version: int, - recipient: AgentEscalationRecipient, + app_name: str | None = None, + app_folder_path: str | None = None, + version: int = 0, + recipient: AgentEscalationRecipient | None = None, model: BaseChatModel | None = None, ): """Initialize EscalateAction with escalation app configuration. Args: - app_name: Name of the escalation app. Used when *model* is None - (static Action App path). - app_folder_path: Folder path where the escalation app is located. - version: Version of the escalation app. - recipient: Recipient object (StandardRecipient or AssetRecipient). + app_name: Name of the escalation app. Required when *model* is None + (static Action App path); ignored in dynamic-schema mode. + app_folder_path: Folder where the escalation app or QuickForm task lives. + version: Version of the escalation app (static path only). + recipient: Recipient for the HITL task. model: Optional chat model injected by the agent runtime. When set, the schema for the HITL task is generated dynamically by the LLM using the full conversation context at escalation time — no diff --git a/src/uipath_langchain/agent/guardrails/guardrails_factory.py b/src/uipath_langchain/agent/guardrails/guardrails_factory.py index b4464582a..bd750ed4a 100644 --- a/src/uipath_langchain/agent/guardrails/guardrails_factory.py +++ b/src/uipath_langchain/agent/guardrails/guardrails_factory.py @@ -565,17 +565,25 @@ def build_guardrails_with_actions( ) ) elif isinstance(action, AgentGuardrailEscalateAction): - result.append( - ( - converted_guardrail, - EscalateAction( - app_name=action.app.name, - app_folder_path=action.app.folder_name, - version=action.app.version, - recipient=action.recipient, - ), + if action.app is not None: + result.append( + ( + converted_guardrail, + EscalateAction( + app_name=action.app.name, + app_folder_path=action.app.folder_name, + version=action.app.version, + recipient=action.recipient, + ), + ) + ) + else: + result.append( + ( + converted_guardrail, + EscalateAction(recipient=action.recipient), + ) ) - ) elif isinstance(action, AgentGuardrailFilterAction): result.append((converted_guardrail, FilterAction(fields=action.fields))) return result