Skip to content

Commit 56cabe0

Browse files
feat(agent/tools): add QuickForm escalation tool (escalationType=2)
Introduces create_quick_form_escalation_tool, a langchain StructuredTool that materialises AgentQuickFormEscalationResourceConfig (escalationType=2) into a HITL task whose form is rendered from the channel's HitlSchema. The tool reads schema_id + schema from AgentEscalationChannel, calls TasksService.create_quickform_async (uipath-platform), suspends inside durable_interrupt on WaitEscalation, and on resume maps the channel's outcome_mapping to CONTINUE / END — matching the existing app-task escalation contract. Helpers (resolve_recipient_value, _parse_task_data, _resolve_escalation_action, EscalationAction) are reused from escalation_tool.py rather than extracting a shared seam; keeping QF and the existing escalation tool side-by-side mirrors how ixp_escalation_tool and escalation_memory live today. Wired into agent/tools/__init__.py (re-export) and tool_factory.py (dispatch on AgentQuickFormEscalationResourceConfig). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6b00fc6 commit 56cabe0

3 files changed

Lines changed: 250 additions & 0 deletions

File tree

src/uipath_langchain/agent/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .ixp_escalation_tool import create_ixp_escalation_tool
99
from .mcp import open_mcp_tools
1010
from .process_tool import create_process_tool
11+
from .quick_form_escalation_tool import create_quick_form_escalation_tool
1112
from .tool_factory import (
1213
create_tools_from_resources,
1314
)
@@ -32,6 +33,7 @@
3233
"create_escalation_tool",
3334
"create_ixp_extraction_tool",
3435
"create_ixp_escalation_tool",
36+
"create_quick_form_escalation_tool",
3537
"UiPathToolNode",
3638
"RunnableCallableWithTool",
3739
"ToolWrapperMixin",
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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

src/uipath_langchain/agent/tools/tool_factory.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
AgentIxpExtractionResourceConfig,
1313
AgentIxpVsEscalationResourceConfig,
1414
AgentProcessToolResourceConfig,
15+
AgentQuickFormEscalationResourceConfig,
1516
BaseAgentResourceConfig,
1617
LowCodeAgentDefinition,
1718
)
@@ -25,6 +26,7 @@
2526
from .internal_tools import create_internal_tool
2627
from .ixp_escalation_tool import create_ixp_escalation_tool
2728
from .process_tool import create_process_tool
29+
from .quick_form_escalation_tool import create_quick_form_escalation_tool
2830

2931
logger = getLogger(__name__)
3032

@@ -120,4 +122,7 @@ async def _build_tool_for_resource(
120122
elif isinstance(resource, AgentIxpVsEscalationResourceConfig):
121123
return create_ixp_escalation_tool(resource)
122124

125+
elif isinstance(resource, AgentQuickFormEscalationResourceConfig):
126+
return create_quick_form_escalation_tool(resource, agent=agent)
127+
123128
return None

0 commit comments

Comments
 (0)