|
| 1 | +"""Quick-form escalation tool creation for schema-first HITL tasks. |
| 2 | +
|
| 3 | +Quick-form escalations (``escalationType=2``) render a schema-first task in |
| 4 | +Action Center via FormLib instead of dispatching to an Action Center app. |
| 5 | +The HITL schema and its key live on the channel |
| 6 | +(``AgentEscalationChannel.schema`` / ``schema_id``) and are forwarded |
| 7 | +inline to Orchestrator's ``GenericTasks/CreateTask`` endpoint via |
| 8 | +:meth:`uipath.platform.action_center.tasks.TasksService.create_quickform_async`. |
| 9 | +""" |
| 10 | + |
| 11 | +from typing import Any, Literal |
| 12 | + |
| 13 | +from langchain_core.messages.tool import ToolCall |
| 14 | +from langchain_core.tools import BaseTool, StructuredTool |
| 15 | +from pydantic import BaseModel |
| 16 | +from uipath.agent.models.agent import ( |
| 17 | + AgentEscalationChannel, |
| 18 | + AgentQuickFormEscalationResourceConfig, |
| 19 | + LowCodeAgentDefinition, |
| 20 | +) |
| 21 | +from uipath.eval.mocks import mockable |
| 22 | +from uipath.platform import UiPath |
| 23 | +from uipath.platform.action_center.tasks import Task, TaskRecipient |
| 24 | +from uipath.platform.common import WaitEscalation |
| 25 | +from uipath.runtime.errors import UiPathErrorCategory |
| 26 | + |
| 27 | +from uipath_langchain._utils import get_execution_folder_path |
| 28 | +from uipath_langchain._utils.durable_interrupt import durable_interrupt |
| 29 | +from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model |
| 30 | +from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( |
| 31 | + StructuredToolWithArgumentProperties, |
| 32 | +) |
| 33 | + |
| 34 | +from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode |
| 35 | +from ..react.types import AgentGraphState |
| 36 | +from .escalation_tool import ( |
| 37 | + EscalationAction, |
| 38 | + _parse_task_data, |
| 39 | + _resolve_escalation_action, |
| 40 | + resolve_recipient_value, |
| 41 | +) |
| 42 | +from .tool_node import ToolWrapperReturnType |
| 43 | +from .utils import ( |
| 44 | + resolve_task_title, |
| 45 | + sanitize_dict_for_serialization, |
| 46 | + sanitize_tool_name, |
| 47 | +) |
| 48 | + |
| 49 | + |
| 50 | +def create_quick_form_escalation_tool( |
| 51 | + resource: AgentQuickFormEscalationResourceConfig, |
| 52 | + agent: LowCodeAgentDefinition | None = None, |
| 53 | +) -> StructuredTool: |
| 54 | + """Create a structured tool that opens a quick-form HITL task. |
| 55 | +
|
| 56 | + The returned tool suspends graph execution via ``durable_interrupt`` |
| 57 | + until the form is completed, then resolves the configured outcome |
| 58 | + mapping into a continue/end action (mirroring |
| 59 | + :func:`create_escalation_tool`). |
| 60 | +
|
| 61 | + Args: |
| 62 | + resource: The quick-form escalation resource (``escalationType=2``). |
| 63 | + agent: Optional parent agent definition; reserved for parity with |
| 64 | + :func:`create_escalation_tool` and future agent-scoped |
| 65 | + settings (e.g. escalation memory). |
| 66 | +
|
| 67 | + Returns: |
| 68 | + A langchain ``StructuredTool`` representing the quick-form |
| 69 | + escalation. |
| 70 | + """ |
| 71 | + del agent |
| 72 | + |
| 73 | + tool_name: str = f"escalate_{sanitize_tool_name(resource.name)}" |
| 74 | + channel: AgentEscalationChannel = resource.channels[0] |
| 75 | + |
| 76 | + # Orchestrator upserts the form schema by schemaId on every task creation, |
| 77 | + # so both schemaId and the inline schema are required for QuickForm. |
| 78 | + if not channel.schema_id or not channel.schema: |
| 79 | + raise ValueError( |
| 80 | + f"Quick-form escalation '{resource.name}' is missing 'schemaId' " |
| 81 | + "or 'schema' on its channel; both are required to create the " |
| 82 | + "QuickForm task." |
| 83 | + ) |
| 84 | + |
| 85 | + task_schema_key: str = channel.schema_id |
| 86 | + task_schema_body: dict[str, Any] = channel.schema |
| 87 | + |
| 88 | + input_model: Any = create_model(channel.input_schema) |
| 89 | + output_model: Any = create_model(channel.output_schema) |
| 90 | + |
| 91 | + class QuickFormEscalationToolOutput(BaseModel): |
| 92 | + action: Literal["approve", "reject"] |
| 93 | + data: output_model |
| 94 | + is_deleted: bool = False |
| 95 | + |
| 96 | + async def quick_form_escalation_tool_fn(**kwargs: Any) -> dict[str, Any]: |
| 97 | + agent_input: dict[str, Any] = ( |
| 98 | + tool.metadata.get("agent_input") if tool.metadata else None |
| 99 | + ) or {} |
| 100 | + recipient: TaskRecipient | None = ( |
| 101 | + await resolve_recipient_value(channel.recipients[0], input_args=agent_input) |
| 102 | + if channel.recipients |
| 103 | + else None |
| 104 | + ) |
| 105 | + folder_path = get_execution_folder_path() |
| 106 | + |
| 107 | + task_title = "Escalation Task" |
| 108 | + if tool.metadata is not None: |
| 109 | + tool.metadata["recipient"] = recipient |
| 110 | + task_title = tool.metadata.get("task_title") or task_title |
| 111 | + |
| 112 | + serialized_data = input_model.model_validate(kwargs).model_dump(mode="json") |
| 113 | + |
| 114 | + @mockable( |
| 115 | + name=tool_name.lower(), |
| 116 | + description=resource.description, |
| 117 | + input_schema=input_model.model_json_schema(), |
| 118 | + output_schema=QuickFormEscalationToolOutput.model_json_schema(), |
| 119 | + example_calls=channel.properties.example_calls, |
| 120 | + ) |
| 121 | + async def escalate(**_: Any): |
| 122 | + @durable_interrupt |
| 123 | + async def create_quick_form_task(): |
| 124 | + client = UiPath() |
| 125 | + created_task = await client.tasks.create_quickform_async( |
| 126 | + title=task_title, |
| 127 | + task_schema_key=task_schema_key, |
| 128 | + schema=task_schema_body, |
| 129 | + data=serialized_data, |
| 130 | + folder_path=folder_path, |
| 131 | + recipient=recipient, |
| 132 | + priority=channel.priority, |
| 133 | + labels=channel.labels, |
| 134 | + is_actionable_message_enabled=channel.properties.is_actionable_message_enabled, |
| 135 | + actionable_message_metadata=channel.properties.actionable_message_meta_data, |
| 136 | + ) |
| 137 | + |
| 138 | + return WaitEscalation( |
| 139 | + action=created_task, |
| 140 | + app_folder_path=folder_path, |
| 141 | + app_name=channel.properties.app_name, |
| 142 | + recipient=recipient, |
| 143 | + ) |
| 144 | + |
| 145 | + return await create_quick_form_task() |
| 146 | + |
| 147 | + result = await escalate(**kwargs) |
| 148 | + if isinstance(result, dict): |
| 149 | + result = Task.model_validate(result) |
| 150 | + |
| 151 | + if result.is_deleted: |
| 152 | + return { |
| 153 | + "action": EscalationAction.END, |
| 154 | + "output": None, |
| 155 | + "outcome": "The escalation task was deleted", |
| 156 | + } |
| 157 | + |
| 158 | + raw_data = ( |
| 159 | + result.data.model_dump() |
| 160 | + if isinstance(result.data, BaseModel) |
| 161 | + else (result.data or {}) |
| 162 | + ) |
| 163 | + escalation_output = _parse_task_data( |
| 164 | + raw_data, |
| 165 | + input_schema=input_model.model_json_schema(), |
| 166 | + output_schema=output_model.model_json_schema(), |
| 167 | + ) |
| 168 | + escalation_action = _resolve_escalation_action( |
| 169 | + result.action, |
| 170 | + channel.outcome_mapping, |
| 171 | + ) |
| 172 | + |
| 173 | + return { |
| 174 | + "action": escalation_action, |
| 175 | + "output": escalation_output, |
| 176 | + "outcome": result.action, |
| 177 | + } |
| 178 | + |
| 179 | + async def quick_form_escalation_wrapper( |
| 180 | + tool: BaseTool, |
| 181 | + call: ToolCall, |
| 182 | + state: AgentGraphState, |
| 183 | + ) -> ToolWrapperReturnType: |
| 184 | + if tool.metadata is None: |
| 185 | + raise RuntimeError("Tool metadata is required for task_title resolution") |
| 186 | + |
| 187 | + state_dict = sanitize_dict_for_serialization(dict(state)) |
| 188 | + tool.metadata["task_title"] = resolve_task_title( |
| 189 | + channel.task_title, |
| 190 | + state_dict, |
| 191 | + default_title="Escalation Task", |
| 192 | + ) |
| 193 | + internal_fields = set(AgentGraphState.model_fields.keys()) |
| 194 | + tool.metadata["agent_input"] = { |
| 195 | + k: v for k, v in state_dict.items() if k not in internal_fields |
| 196 | + } |
| 197 | + |
| 198 | + tool.metadata["_call_id"] = call.get("id") |
| 199 | + tool.metadata["_call_args"] = dict(call.get("args", {})) |
| 200 | + |
| 201 | + result = await tool.ainvoke(call["args"]) |
| 202 | + |
| 203 | + if result["action"] == EscalationAction.END: |
| 204 | + output_detail = f"Escalation output: {result['output']}" |
| 205 | + termination_title = ( |
| 206 | + f"Agent run ended based on escalation outcome {result['action']} " |
| 207 | + f"with directive {result['outcome']}" |
| 208 | + ) |
| 209 | + raise AgentRuntimeError( |
| 210 | + code=AgentRuntimeErrorCode.TERMINATION_ESCALATION_REJECTED, |
| 211 | + title=termination_title, |
| 212 | + detail=output_detail, |
| 213 | + category=UiPathErrorCategory.USER, |
| 214 | + ) |
| 215 | + |
| 216 | + return { |
| 217 | + "output": result["output"], |
| 218 | + "outcome": result["outcome"], |
| 219 | + "task_id": result.get("task_id"), |
| 220 | + "assigned_to": result.get("assigned_to"), |
| 221 | + } |
| 222 | + |
| 223 | + tool = StructuredToolWithArgumentProperties( |
| 224 | + name=tool_name, |
| 225 | + description=resource.description, |
| 226 | + args_schema=input_model, |
| 227 | + output_type=output_model, |
| 228 | + coroutine=quick_form_escalation_tool_fn, |
| 229 | + argument_properties=channel.argument_properties, |
| 230 | + metadata={ |
| 231 | + "tool_type": "escalation", |
| 232 | + "escalation_subtype": "quick_form", |
| 233 | + "display_name": channel.properties.app_name, |
| 234 | + "channel_type": channel.type, |
| 235 | + "recipient": None, |
| 236 | + "args_schema": input_model, |
| 237 | + "output_schema": output_model, |
| 238 | + "schema_id": task_schema_key, |
| 239 | + }, |
| 240 | + ) |
| 241 | + tool.set_tool_wrappers(awrapper=quick_form_escalation_wrapper) |
| 242 | + |
| 243 | + return tool |
0 commit comments