diff --git a/src/uipath_langchain/agent/guardrails/actions/escalate_action.py b/src/uipath_langchain/agent/guardrails/actions/escalate_action.py index ab8d235df..94eecaa01 100644 --- a/src/uipath_langchain/agent/guardrails/actions/escalate_action.py +++ b/src/uipath_langchain/agent/guardrails/actions/escalate_action.py @@ -114,9 +114,9 @@ async def _create_task_node( if existing_task is not None: return {} - # Lazy import to avoid circular dependency with escalation_tool + # Lazy import to avoid circular dependency with escalation tools from ...react.types import AgentGraphState - from ...tools.escalation_tool import resolve_recipient_value + from ...tools.escalation import resolve_recipient_value from ...tools.utils import sanitize_dict_for_serialization internal_fields = set(AgentGraphState.model_fields.keys()) diff --git a/src/uipath_langchain/agent/tools/__init__.py b/src/uipath_langchain/agent/tools/__init__.py index 7c6a7e37e..376bbbf27 100644 --- a/src/uipath_langchain/agent/tools/__init__.py +++ b/src/uipath_langchain/agent/tools/__init__.py @@ -2,10 +2,13 @@ from .a2a import A2aClient, create_a2a_tools_and_clients, open_a2a_tools from .context_tool import create_context_tool -from .escalation_tool import create_escalation_tool +from .escalation import ( + create_escalation_tool, + create_ixp_escalation_tool, + create_quick_form_escalation_tool, +) from .extraction_tool import create_ixp_extraction_tool from .integration_tool import create_integration_tool -from .ixp_escalation_tool import create_ixp_escalation_tool from .mcp import open_mcp_tools from .process_tool import create_process_tool from .tool_factory import ( @@ -32,6 +35,7 @@ "create_escalation_tool", "create_ixp_extraction_tool", "create_ixp_escalation_tool", + "create_quick_form_escalation_tool", "UiPathToolNode", "RunnableCallableWithTool", "ToolWrapperMixin", diff --git a/src/uipath_langchain/agent/tools/escalation/CLAUDE.md b/src/uipath_langchain/agent/tools/escalation/CLAUDE.md new file mode 100644 index 000000000..f387ca3e0 --- /dev/null +++ b/src/uipath_langchain/agent/tools/escalation/CLAUDE.md @@ -0,0 +1,222 @@ +# Escalation Tools Module Guide + +> **CLAUDE: UPDATE THIS DOCUMENT** +> +> When you modify files in this module, you MUST update this document to reflect: +> - New or renamed shared primitives (update `common.py` section) +> - New escalation variants (add a column to the variant table) +> - Changes to the resource discriminator (update Escalation Type Discriminator section) +> - New or removed escalation-memory hooks (update Escalation Memory section) +> +> Keep the variant table and import map in sync with `__init__.py`. + +## Overview + +This module owns LangGraph tools for the three Action Center escalation +variants the Agent Builder runtime supports. All three share one +resource concept (`AgentResourceType.ESCALATION`) and one channel model +(`AgentEscalationChannel`), but materialise the HITL task through +different platform endpoints. + +The variants are discriminated by `escalation_type` on the resource +config: + +| `escalation_type` | Resource config | Module | Endpoint | +| ----------------- | -------------------------------------------- | --------------- | ----------------------------------------------------------------------- | +| `0` | `AgentEscalationResourceConfig` | `app_task.py` | `tasks.create_async` (app-bound task; optional escalation memory) | +| `1` | `AgentIxpVsEscalationResourceConfig` | `ixp_vs.py` | `documents.create_validate_extraction_action_async` (DU validation) | +| `2` | `AgentQuickFormEscalationResourceConfig` | `quick_form.py` | `tasks.create_quickform_async` (FormLib schema task) | + +## Module Structure + +``` +src/uipath_langchain/agent/tools/escalation/ +├── __init__.py # Public exports: 3 factories + EscalationAction + recipient/asset resolvers +├── common.py # Shared primitives (the seam) +├── app_task.py # escalationType=0 — Action Center app task + escalation memory +├── quick_form.py # escalationType=2 — FormLib schema task +├── ixp_vs.py # escalationType=1 — DU validation action +└── memory.py # Escalation memory cache lookup + ingest (only used by app_task today) +``` + +### Public Exports (`__init__.py`) + +```python +from .app_task import create_escalation_tool +from .common import EscalationAction, resolve_asset, resolve_recipient_value +from .ixp_vs import create_ixp_escalation_tool +from .quick_form import create_quick_form_escalation_tool +``` + +`resolve_recipient_value` is re-exported because +`guardrails/actions/escalate_action.py` reaches into this package +(lazy import) to resolve recipients for escalation actions. + +## Architecture + +### common.py — The Seam + +`common.py` is what makes `app_task.py` and `quick_form.py` thin. It owns: + +| Primitive | Purpose | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `EscalationAction` | Outcome enum: `CONTINUE` / `END`. | +| `resolve_recipient_value` | Dispatches over `AgentEscalationRecipient` variants → `TaskRecipient`. | +| `resolve_asset` | Asset-name → asset-value lookup via the SDK. | +| `_parse_task_data` | Strips/keeps fields based on input/output JSON schemas. | +| `_resolve_escalation_action` | Looks up the channel's `outcome_mapping`; defaults to `CONTINUE`. | +| `make_escalation_tool_output(M)` | Builds the `EscalationToolOutput` pydantic model (`action`, `data: M`, `is_deleted`) for the mockable. | +| `EscalationInvocationCtx` | Dataclass: `agent_input`, `recipient`, `folder_path`, `task_title`, `serialized_data`. | +| `build_invocation_ctx` | Assembles the preamble every variant runs before opening the durable interrupt. | +| `finalize_escalation_result` | Post-processes the resolved task: handles `is_deleted`, parses outputs, resolves the action. | +| `make_escalation_wrapper(channel)` | Returns the LangGraph tool wrapper: resolves task title, captures call metadata, maps `END` → exception. | + +### Per-variant Factories + +Each factory follows the same skeleton: + +```python +def create_*_escalation_tool(resource, ...): + channel = resource.channels[0] + input_model = create_model(channel.input_schema) + output_model = create_model(channel.output_schema) + EscalationToolOutput = make_escalation_tool_output(output_model) + + async def tool_fn(**kwargs): + ctx = await build_invocation_ctx(tool, channel, kwargs, input_model) + + @mockable(...) + async def escalate(**_): + @durable_interrupt + async def create_task(): + # === The only meaningful difference per variant: === + # which platform call to make and what to pass it. + ... + return WaitEscalation(...) # or WaitDocumentExtractionValidation(...) + return await create_task() + + result = await escalate(**kwargs) + return finalize_escalation_result(result, input_model=..., output_model=..., outcome_mapping=...) + + tool = StructuredToolWithArgumentProperties(...) + tool.set_tool_wrappers(awrapper=make_escalation_wrapper(channel)) + return tool +``` + +`app_task.py` is the same skeleton plus the escalation-memory cache check +before the mockable, and the escalation-memory ingest after the result is +finalised (skipped when `result.is_deleted` to mirror the early-return +shape of the pre-refactor code). + +`ixp_vs.py` does not use `build_invocation_ctx` / +`finalize_escalation_result` / `make_escalation_wrapper`: it suspends on +`WaitDocumentExtractionValidation` (not `WaitEscalation`), reads its +input from `tools_storage` rather than the tool's args, and detects +rejection through `documentRejectionDetails` instead of an outcome +mapping. Its wrapper still uses `resolve_task_title` from +`tools/utils.py`. + +## Escalation Memory + +Memory lives in `memory.py` (moved wholesale from +`agent/tools/escalation_memory.py`). It is owned by `app_task.py` +today; quick-form and ixp-vs do not call it. + +**Lifecycle inside `create_escalation_tool`:** + +1. Before the mockable: `_check_escalation_memory_cache(...)` returns a + prior outcome if the input matches. If hit, the tool short-circuits + and returns the cached `{action, output, outcome}`. +2. After the mockable resolves (and only if `not result.is_deleted`): + `_ingest_escalation_memory(...)` persists the outcome along with + span/trace IDs so future agents can recall it. + +The span/trace IDs come from `tool.metadata["_span_context"]` (set by +the LLMOps tool instrumentor in `uipath-agents`) and fall back to +`get_current_span_and_trace_ids()` / `UIPATH_TRACE_ID`. + +To add memory to a new variant (e.g. quick-form), import the same +helpers from `.memory`, call them at the same two points, and skip +ingest when `result.is_deleted`. + +## Cross-package Dependencies + +``` +agent/tools/escalation/ (this module) +├── common.py → langchain_core, uipath.{agent,platform,runtime}, +│ uipath_langchain._utils.get_execution_folder_path, +│ ...exceptions, ...react.types, ..tool_node, ..utils +├── memory.py → uipath.platform.memory, uipath_langchain._utils, +│ OTel +├── app_task.py → .common, .memory, uipath.platform (UiPath, Task, +│ WaitEscalation), uipath_langchain._utils +├── quick_form.py → .common, uipath.platform (same) +└── ixp_vs.py → uipath.platform.documents, ...exceptions, + ...react.types, ..structured_tool_with_output_type, + ..tool_node, ..utils (no .common — different shape) + +Consumers +───────── +agent/tools/__init__.py — re-exports the three factories +agent/tools/tool_factory.py — dispatches on resource type → factory +agent/guardrails/actions/ — lazy-imports resolve_recipient_value + escalate_action.py from .escalation +``` + +## Tests + +| Test file | Surface under test | +| ---------------------------------------------------------- | ------------------------------------------- | +| `tests/agent/tools/test_escalation_tool.py` | App-task flow, common primitives, memory | +| `tests/agent/tools/test_escalation_memory.py` | Memory cache + ingest internals | +| `tests/agent/tools/test_ixp_escalation_tool.py` | IXP-VS extraction validation flow | +| `tests/cli/test_agent_with_guardrails.py` | End-to-end escalation guardrails | +| `tests/agent/guardrails/actions/test_escalate_action.py` | Recipient resolution from guardrail action | + +### Patch path conventions + +Tests patch SDK calls at the module that performs the lookup: + +- `escalation.common.UiPath` — when testing `resolve_asset`. +- `escalation.common.resolve_asset` — when testing `resolve_recipient_value`. +- `escalation.app_task.UiPath` — when testing the app-task creation flow. +- `escalation.app_task._check_escalation_memory_cache` / `._ingest_escalation_memory` + / `._resolve_user_id` — when testing memory hooks (these are imported into + `app_task.py` from `.memory`). +- `escalation.memory.UiPath` / `escalation.memory.UiPathConfig` — when testing + memory internals (cache lookup, ingest request building). +- `escalation.ixp_vs.UiPath` — when testing the IXP validation flow. + +## Guidelines for Changes + +### Adding a new escalation variant + +1. Add a `Literal[]` discriminator on a new + `Agent*EscalationResourceConfig` in `uipath-python`. +2. Add a new module under this package (e.g. `escalation/foo.py`) + following the skeleton above. +3. If the variant maps to the standard `WaitEscalation` → + `{action, output, outcome}` shape, reuse `build_invocation_ctx`, + `finalize_escalation_result`, and `make_escalation_wrapper` from + `common.py`. If it diverges (like `ixp_vs.py`), write the + minimum bespoke wrapper and keep `resolve_task_title` from + `tools/utils.py`. +4. Re-export the factory from `__init__.py` and add an + `isinstance(...)` branch in `tools/tool_factory.py`. +5. Add a row to the variant table at the top of this file. + +### Adding a new shared primitive + +1. Put it in `common.py`. +2. Re-export from `__init__.py` only if it has consumers outside the + subpackage (today: `EscalationAction`, `resolve_asset`, + `resolve_recipient_value`). +3. Update the "common.py — The Seam" table above. + +### Touching escalation memory + +1. Edit `memory.py` directly. +2. If you change a public name imported by `app_task.py`, update both + the import and the `Patch path conventions` table above. +3. Memory writes must remain idempotent w.r.t. `result.is_deleted` — + never ingest for a deleted task. diff --git a/src/uipath_langchain/agent/tools/escalation/__init__.py b/src/uipath_langchain/agent/tools/escalation/__init__.py new file mode 100644 index 000000000..4f3ea5405 --- /dev/null +++ b/src/uipath_langchain/agent/tools/escalation/__init__.py @@ -0,0 +1,33 @@ +"""Action Center escalation tools. + +Three escalation variants share a single resource concept +(``AgentResourceType.ESCALATION``) but differ in how the HITL task is +materialised: + +* :func:`create_escalation_tool` — ``escalationType=0``, Action Center + app task (with optional escalation memory). +* :func:`create_ixp_escalation_tool` — ``escalationType=1``, Document + Understanding validation action. +* :func:`create_quick_form_escalation_tool` — ``escalationType=2``, + schema-first FormLib task. + +All three are assembled from shared primitives in :mod:`.common`. +""" + +from .app_task import create_escalation_tool +from .common import ( + EscalationAction, + resolve_asset, + resolve_recipient_value, +) +from .ixp_vs import create_ixp_escalation_tool +from .quick_form import create_quick_form_escalation_tool + +__all__ = [ + "EscalationAction", + "create_escalation_tool", + "create_ixp_escalation_tool", + "create_quick_form_escalation_tool", + "resolve_asset", + "resolve_recipient_value", +] diff --git a/src/uipath_langchain/agent/tools/escalation/app_task.py b/src/uipath_langchain/agent/tools/escalation/app_task.py new file mode 100644 index 000000000..80486214e --- /dev/null +++ b/src/uipath_langchain/agent/tools/escalation/app_task.py @@ -0,0 +1,284 @@ +"""Action Center *app* escalation (``escalationType=0``). + +Creates an Action Center task against an app, suspends execution via +``durable_interrupt`` until the task is completed, and optionally +records the outcome to escalation memory. +""" + +import json +import logging +import os +from typing import Any + +from langchain_core.tools import StructuredTool +from uipath.agent.models.agent import ( + AgentEscalationChannel, + AgentEscalationResourceConfig, + LowCodeAgentDefinition, +) +from uipath.eval.mocks import mockable +from uipath.platform import UiPath +from uipath.platform.action_center.tasks import Task +from uipath.platform.common import WaitEscalation + +from uipath_langchain._utils import get_current_span_and_trace_ids +from uipath_langchain._utils.durable_interrupt import durable_interrupt +from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model +from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( + StructuredToolWithArgumentProperties, +) + +from ..utils import sanitize_tool_name +from .common import ( + _resolve_escalation_action, + build_invocation_ctx, + finalize_escalation_result, + make_escalation_tool_output, + make_escalation_wrapper, +) +from .memory import ( + EscalationMemorySettings, + _check_escalation_memory_cache, + _get_escalation_memory_folder_path, + _get_escalation_memory_settings, + _get_escalation_memory_space_id, + _get_escalation_memory_space_name, + _ingest_escalation_memory, + _resolve_user_id, +) + +_escalation_logger = logging.getLogger(__name__) + + +def _build_escalation_memory_payload( + serialized_input: dict[str, Any], + escalation_output: dict[str, Any], + outcome: str | None, +) -> tuple[dict[str, Any], dict[str, Any]]: + answer = {"output": escalation_output, "outcome": outcome} + attributes = {"arguments": serialized_input} + return answer, attributes + + +def _pop_escalation_memory_span_context( + metadata: dict[str, Any] | None, +) -> tuple[str | None, str | None]: + span_context = (metadata or {}).get("_span_context") + if not isinstance(span_context, dict): + _escalation_logger.debug( + "Escalation memory span context missing _span_context metadata" + ) + return None, None + + parent_span_id = _format_otel_id(span_context.pop("parent_span_id", None), 16) + trace_id = _format_otel_id(span_context.pop("trace_id", None), 32) + _escalation_logger.debug( + "Escalation memory span context: %s", + json.dumps( + { + "parentSpanId": parent_span_id, + "traceId": trace_id, + "remainingContext": span_context, + }, + default=str, + ), + ) + return parent_span_id, trace_id + + +def _format_otel_id(value: Any, width: int) -> str | None: + if value in (None, ""): + return None + if isinstance(value, int): + return f"{value:0{width}x}" + return str(value) + + +def _normalize_trace_id(value: str) -> str: + normalized = value.replace("-", "").lower() + if len(normalized) != 32: + raise ValueError(f"Invalid trace ID format: {value}") + return normalized + + +def _get_exported_trace_id(trace_id: str | None) -> str | None: + trace_id_override = os.environ.get("UIPATH_TRACE_ID") + if trace_id_override: + try: + return _normalize_trace_id(trace_id_override) + except ValueError: + _escalation_logger.warning( + "Ignoring invalid UIPATH_TRACE_ID override: %s", + trace_id_override, + ) + + return trace_id + + +def create_escalation_tool( + resource: AgentEscalationResourceConfig, + agent: LowCodeAgentDefinition | None = None, +) -> StructuredTool: + """Action Center app-task escalation (``escalationType=0``). + + Uses ``durable_interrupt`` for Action Center human-in-the-loop and + optionally writes the outcome to escalation memory. + """ + + tool_name: str = f"escalate_{sanitize_tool_name(resource.name)}" + channel: AgentEscalationChannel = resource.channels[0] + + input_model: Any = create_model(channel.input_schema) + output_model: Any = create_model(channel.output_schema) + EscalationToolOutput = make_escalation_tool_output(output_model) + + _span_context: dict[str, Any] = {} + _bts_context: dict[str, Any] = {} + _memory_space_id: str | None = _get_escalation_memory_space_id(resource, agent) + _memory_folder_path: str | None = _get_escalation_memory_folder_path( + resource, agent + ) + _memory_space_name: str | None = _get_escalation_memory_space_name(resource, agent) + _memory_settings: EscalationMemorySettings | None = _get_escalation_memory_settings( + resource + ) + + async def escalation_tool_fn(**kwargs: Any) -> dict[str, Any]: + ctx = await build_invocation_ctx(tool, channel, kwargs, input_model) + + # --- Escalation memory: check cache before creating HITL task --- + if _memory_space_id: + cached_result = await _check_escalation_memory_cache( + _memory_space_id, + ctx.serialized_data, + folder_path=_memory_folder_path or ctx.folder_path, + memory_settings=_memory_settings, + memory_space_name=_memory_space_name, + ) + if cached_result is not None: + return { + "action": _resolve_escalation_action( + cached_result.outcome, + channel.outcome_mapping, + ), + "output": cached_result.output, + "outcome": cached_result.outcome, + } + + @mockable( + name=tool_name.lower(), + description=resource.description, + input_schema=input_model.model_json_schema(), + output_schema=EscalationToolOutput.model_json_schema(), + example_calls=channel.properties.example_calls, + ) + async def escalate(**_tool_kwargs: Any): + @durable_interrupt + async def create_escalation_task(): + client = UiPath() + created_task = await client.tasks.create_async( + title=ctx.task_title, + data=ctx.serialized_data, + app_name=channel.properties.app_name, + app_folder_path=ctx.folder_path, + recipient=ctx.recipient, + priority=channel.priority, + labels=channel.labels, + is_actionable_message_enabled=channel.properties.is_actionable_message_enabled, + actionable_message_metadata=channel.properties.actionable_message_meta_data, + ) + + if created_task.id is not None: + _bts_context["task_key"] = str(created_task.id) + + return WaitEscalation( + action=created_task, + app_folder_path=ctx.folder_path, + app_name=channel.properties.app_name, + recipient=ctx.recipient, + ) + + return await create_escalation_task() + + result = await escalate(**kwargs) + if isinstance(result, dict): + result = Task.model_validate(result) + + finalized = finalize_escalation_result( + result, + input_model=input_model, + output_model=output_model, + outcome_mapping=channel.outcome_mapping, + ) + + # --- Escalation memory: persist outcome for future recall --- + if _memory_space_id and not result.is_deleted: + user_id = await _resolve_user_id(result.completed_by_user) + parent_span_id, trace_id = _pop_escalation_memory_span_context( + tool.metadata + ) + if not parent_span_id or not trace_id: + fallback_span_id, fallback_trace_id = get_current_span_and_trace_ids() + _escalation_logger.debug( + "Escalation memory span context fallback: %s", + json.dumps( + { + "fallbackSpanId": fallback_span_id, + "fallbackTraceId": fallback_trace_id, + "hadParentSpanId": bool(parent_span_id), + "hadTraceId": bool(trace_id), + }, + default=str, + ), + ) + parent_span_id = parent_span_id or fallback_span_id + trace_id = trace_id or _get_exported_trace_id(fallback_trace_id) + if not parent_span_id or not trace_id: + _escalation_logger.warning( + "Skipping escalation memory ingest because span provenance is incomplete" + ) + return finalized + + answer_payload, attributes_payload = _build_escalation_memory_payload( + ctx.serialized_data, + finalized["output"], + finalized["outcome"], + ) + await _ingest_escalation_memory( + _memory_space_id, + answer=json.dumps(answer_payload), + attributes=json.dumps(attributes_payload), + parent_span_id=parent_span_id, + trace_id=trace_id, + user_id=user_id, + folder_path=_memory_folder_path or ctx.folder_path, + ) + if user_id is None: + _escalation_logger.info( + "Ingested escalation memory without reviewer user ID " + "because the completed user could not be resolved" + ) + + return finalized + + tool = StructuredToolWithArgumentProperties( + name=tool_name, + description=resource.description, + args_schema=input_model, + output_type=output_model, + coroutine=escalation_tool_fn, + argument_properties=channel.argument_properties, + metadata={ + "tool_type": "escalation", + "display_name": channel.properties.app_name, + "channel_type": channel.type, + "recipient": None, + "args_schema": input_model, + "output_schema": output_model, + "_span_context": _span_context, + "_bts_context": _bts_context, + }, + ) + tool.set_tool_wrappers(awrapper=make_escalation_wrapper(channel)) + + return tool diff --git a/src/uipath_langchain/agent/tools/escalation/common.py b/src/uipath_langchain/agent/tools/escalation/common.py new file mode 100644 index 000000000..e199b9ba0 --- /dev/null +++ b/src/uipath_langchain/agent/tools/escalation/common.py @@ -0,0 +1,333 @@ +"""Shared primitives for Action Center escalation tools. + +This module is the seam between the per-variant escalation factories +(``app_task.py``, ``quick_form.py``, ``ixp_vs.py``) and the tool layer. +It owns: + +* The escalation outcome model (:class:`EscalationAction`, + :func:`make_escalation_tool_output`). +* Recipient and asset resolution. +* Output post-processing (:func:`_parse_task_data`, + :func:`_resolve_escalation_action`). +* The invocation preamble shared by every escalation variant + (:class:`EscalationInvocationCtx`, :func:`build_invocation_ctx`). +* The post-interrupt finaliser (:func:`finalize_escalation_result`). +* The LangGraph tool wrapper factory (:func:`make_escalation_wrapper`). + +The escalation factories assemble these primitives — they no longer +duplicate the scaffolding. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Any, Literal + +from langchain_core.messages.tool import ToolCall +from langchain_core.tools import BaseTool +from pydantic import BaseModel +from pydantic import create_model as pydantic_create_model +from uipath.agent.models.agent import ( + AgentEscalationChannel, + AgentEscalationRecipient, + AgentEscalationRecipientType, + ArgumentEmailRecipient, + ArgumentGroupNameRecipient, + AssetRecipient, + StandardRecipient, +) +from uipath.agent.utils.text_tokens import safe_get_nested +from uipath.platform import UiPath +from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType +from uipath.runtime.errors import UiPathErrorCategory + +from uipath_langchain._utils import get_execution_folder_path + +from ...exceptions import AgentRuntimeError, AgentRuntimeErrorCode +from ...react.types import AgentGraphState +from ..tool_node import ToolWrapperReturnType +from ..utils import resolve_task_title, sanitize_dict_for_serialization + + +class EscalationAction(str, Enum): + """Actions that can be taken after an escalation completes.""" + + CONTINUE = "continue" + END = "end" + + +async def resolve_recipient_value( + recipient: AgentEscalationRecipient, + input_args: dict[str, Any] | None = None, +) -> TaskRecipient | None: + """Resolve recipient value based on recipient type.""" + if isinstance(recipient, AssetRecipient): + value = await resolve_asset(recipient.asset_name, get_execution_folder_path()) + type = None + if recipient.type == AgentEscalationRecipientType.ASSET_USER_EMAIL: + type = TaskRecipientType.EMAIL + elif recipient.type == AgentEscalationRecipientType.ASSET_GROUP_NAME: + type = TaskRecipientType.GROUP_NAME + return TaskRecipient(value=value, type=type, displayName=value) + + if isinstance(recipient, ArgumentEmailRecipient): + value = safe_get_nested(input_args or {}, recipient.argument_path) + if value is None: + raise ValueError( + f"Argument '{recipient.argument_path}' has no value in agent input." + ) + return TaskRecipient( + value=value, type=TaskRecipientType.EMAIL, displayName=value + ) + + if isinstance(recipient, ArgumentGroupNameRecipient): + value = safe_get_nested(input_args or {}, recipient.argument_path) + if value is None: + raise ValueError( + f"Argument '{recipient.argument_path}' has no value in agent input." + ) + return TaskRecipient( + value=value, type=TaskRecipientType.GROUP_NAME, displayName=value + ) + + if isinstance(recipient, StandardRecipient): + type = TaskRecipientType(recipient.type) + if recipient.type == AgentEscalationRecipientType.USER_EMAIL: + type = TaskRecipientType.EMAIL + return TaskRecipient( + value=recipient.value, type=type, displayName=recipient.value + ) + + return None + + +async def resolve_asset(asset_name: str, folder_path: str | None) -> str | None: + """Retrieve asset value.""" + try: + client = UiPath() + result = await client.assets.retrieve_async( + name=asset_name, folder_path=folder_path + ) + + if not result or not result.value: + raise ValueError(f"Asset '{asset_name}' has no value configured.") + + return result.value + except Exception as e: + raise ValueError( + f"Failed to resolve asset '{asset_name}' in folder '{folder_path}': {str(e)}" + ) from e + + +def _parse_task_data( + data: dict[str, Any], + input_schema: dict[str, Any], + output_schema: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Filter action center task data based on input/output schemas. + + When output_schema is None, returns only fields not present in input_schema. + When output_schema is provided, returns only fields defined in output_schema. + """ + filtered_fields: dict[str, Any] = {} + + if output_schema is None: + input_field_names = set() + if "properties" in input_schema: + input_field_names = set(input_schema["properties"].keys()) + + for field_name, field_value in data.items(): + if field_name not in input_field_names: + filtered_fields[field_name] = field_value + + else: + output_field_names = set() + if "properties" in output_schema: + output_field_names = set(output_schema["properties"].keys()) + + for field_name, field_value in data.items(): + if field_name in output_field_names: + filtered_fields[field_name] = field_value + + return filtered_fields + + +def _resolve_escalation_action( + outcome: str | None, + outcome_mapping: dict[str, str] | None, +) -> EscalationAction: + outcome_action = ( + outcome_mapping.get(outcome) if outcome_mapping and outcome else None + ) + return ( + EscalationAction(outcome_action) + if outcome_action + else EscalationAction.CONTINUE + ) + + +def make_escalation_tool_output(output_model: Any) -> type[BaseModel]: + """Build the escalation tool output schema for a given output model. + + Every escalation variant returns ``{action, data, is_deleted}`` to the + mockable layer. The ``data`` field is parameterised by the channel's + output model. + """ + return pydantic_create_model( + "EscalationToolOutput", + action=(Literal["approve", "reject"], ...), + data=(output_model, ...), + is_deleted=(bool, False), + ) + + +@dataclass +class EscalationInvocationCtx: + """Per-invocation data assembled before opening the durable interrupt.""" + + agent_input: dict[str, Any] + recipient: TaskRecipient | None + folder_path: str | None + task_title: str + serialized_data: dict[str, Any] + + +async def build_invocation_ctx( + tool: BaseTool, + channel: AgentEscalationChannel, + kwargs: dict[str, Any], + input_model: Any, + *, + default_title: str = "Escalation Task", +) -> EscalationInvocationCtx: + """Assemble the preamble every escalation variant runs. + + Resolves the recipient, captures the execution folder, picks up + the wrapper-resolved task title from ``tool.metadata``, and + validates the input payload into a JSON-mode dict. + """ + agent_input: dict[str, Any] = ( + tool.metadata.get("agent_input") if tool.metadata else None + ) or {} + recipient: TaskRecipient | None = ( + await resolve_recipient_value(channel.recipients[0], input_args=agent_input) + if channel.recipients + else None + ) + folder_path = get_execution_folder_path() + + task_title = default_title + if tool.metadata is not None: + # The wrapper resolves recipient and title; persist them so the + # nested durable_interrupt closure can read them back. + tool.metadata["recipient"] = recipient + task_title = tool.metadata.get("task_title") or default_title + + serialized_data = input_model.model_validate(kwargs).model_dump(mode="json") + return EscalationInvocationCtx( + agent_input=agent_input, + recipient=recipient, + folder_path=folder_path, + task_title=task_title, + serialized_data=serialized_data, + ) + + +def finalize_escalation_result( + result: Any, + *, + input_model: Any, + output_model: Any, + outcome_mapping: dict[str, str] | None, +) -> dict[str, Any]: + """Post-process the action center task into the tool's response shape. + + Returns ``{action, output, outcome}`` where ``action`` is an + :class:`EscalationAction`. Handles the deleted-task short-circuit + so callers do not have to repeat it. + """ + if result.is_deleted: + return { + "action": EscalationAction.END, + "output": None, + "outcome": "The escalation task was deleted", + } + + outcome = result.action + raw_data = ( + result.data.model_dump() + if isinstance(result.data, BaseModel) + else (result.data or {}) + ) + escalation_output = _parse_task_data( + raw_data, + input_schema=input_model.model_json_schema(), + output_schema=output_model.model_json_schema(), + ) + escalation_action = _resolve_escalation_action(outcome, outcome_mapping) + return { + "action": escalation_action, + "output": escalation_output, + "outcome": outcome, + } + + +def make_escalation_wrapper( + channel: AgentEscalationChannel, + *, + default_title: str = "Escalation Task", +): + """Build the LangGraph tool wrapper for an escalation channel. + + The wrapper resolves the task title from agent state, captures the + call's id and args into ``tool.metadata`` for downstream readers + (escalation memory, observability), invokes the tool, and raises + :class:`AgentRuntimeError` with code + ``TERMINATION_ESCALATION_REJECTED`` when the tool resolves to + :attr:`EscalationAction.END`. + """ + + async def escalation_wrapper( + tool: BaseTool, + call: ToolCall, + state: AgentGraphState, + ) -> ToolWrapperReturnType: + if tool.metadata is None: + raise RuntimeError("Tool metadata is required for task_title resolution") + + state_dict = sanitize_dict_for_serialization(dict(state)) + tool.metadata["task_title"] = resolve_task_title( + channel.task_title, + state_dict, + default_title=default_title, + ) + internal_fields = set(AgentGraphState.model_fields.keys()) + tool.metadata["agent_input"] = { + k: v for k, v in state_dict.items() if k not in internal_fields + } + + tool.metadata["_call_id"] = call.get("id") + tool.metadata["_call_args"] = dict(call.get("args", {})) + + result = await tool.ainvoke(call["args"]) + + if result["action"] == EscalationAction.END: + output_detail = f"Escalation output: {result['output']}" + termination_title = ( + f"Agent run ended based on escalation outcome {result['action']} " + f"with directive {result['outcome']}" + ) + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.TERMINATION_ESCALATION_REJECTED, + title=termination_title, + detail=output_detail, + category=UiPathErrorCategory.USER, + ) + + return { + "output": result["output"], + "outcome": result["outcome"], + "task_id": result.get("task_id"), + "assigned_to": result.get("assigned_to"), + } + + return escalation_wrapper diff --git a/src/uipath_langchain/agent/tools/ixp_escalation_tool.py b/src/uipath_langchain/agent/tools/escalation/ixp_vs.py similarity index 91% rename from src/uipath_langchain/agent/tools/ixp_escalation_tool.py rename to src/uipath_langchain/agent/tools/escalation/ixp_vs.py index ceb5c5441..bb6f08e41 100644 --- a/src/uipath_langchain/agent/tools/ixp_escalation_tool.py +++ b/src/uipath_langchain/agent/tools/escalation/ixp_vs.py @@ -1,4 +1,10 @@ -"""Ixp escalation tool.""" +"""IXP-VS escalation (``escalationType=1``). + +Unlike the app-task and quick-form variants, this one routes through +the Document Understanding extraction validation endpoint. It reads +an extraction result that was stored by a sibling IXP extraction tool +and suspends until validation completes. +""" from typing import Any @@ -20,14 +26,11 @@ from uipath_langchain._utils.durable_interrupt import durable_interrupt from uipath_langchain.agent.react.types import AgentGraphState -from uipath_langchain.agent.tools.tool_node import ( - ToolWrapperMixin, - ToolWrapperReturnType, -) -from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode -from .structured_tool_with_output_type import StructuredToolWithOutputType -from .utils import ( +from ...exceptions import AgentRuntimeError, AgentRuntimeErrorCode +from ..structured_tool_with_output_type import StructuredToolWithOutputType +from ..tool_node import ToolWrapperMixin, ToolWrapperReturnType +from ..utils import ( resolve_task_title, sanitize_dict_for_serialization, sanitize_tool_name, diff --git a/src/uipath_langchain/agent/tools/escalation_memory.py b/src/uipath_langchain/agent/tools/escalation/memory.py similarity index 100% rename from src/uipath_langchain/agent/tools/escalation_memory.py rename to src/uipath_langchain/agent/tools/escalation/memory.py diff --git a/src/uipath_langchain/agent/tools/escalation/quick_form.py b/src/uipath_langchain/agent/tools/escalation/quick_form.py new file mode 100644 index 000000000..a33f42642 --- /dev/null +++ b/src/uipath_langchain/agent/tools/escalation/quick_form.py @@ -0,0 +1,148 @@ +"""Quick-form escalation (``escalationType=2``). + +Quick-form escalations render a schema-first task in Action Center via +FormLib instead of dispatching to an Action Center app. The HITL schema +and its key live on the channel (``AgentEscalationChannel.schema`` / +``schema_id``) and are forwarded inline to Orchestrator's +``GenericTasks/CreateTask`` endpoint via +:meth:`uipath.platform.action_center.tasks.TasksService.create_quickform_async`. +""" + +from typing import Any + +from langchain_core.tools import StructuredTool +from uipath.agent.models.agent import ( + AgentEscalationChannel, + AgentQuickFormEscalationResourceConfig, + LowCodeAgentDefinition, +) +from uipath.eval.mocks import mockable +from uipath.platform import UiPath +from uipath.platform.action_center.tasks import Task +from uipath.platform.common import WaitEscalation + +from uipath_langchain._utils.durable_interrupt import durable_interrupt +from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model +from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( + StructuredToolWithArgumentProperties, +) + +from ..utils import sanitize_tool_name +from .common import ( + build_invocation_ctx, + finalize_escalation_result, + make_escalation_tool_output, + make_escalation_wrapper, +) + + +def create_quick_form_escalation_tool( + resource: AgentQuickFormEscalationResourceConfig, + agent: LowCodeAgentDefinition | None = None, +) -> StructuredTool: + """Create a structured tool that opens a quick-form HITL task. + + The returned tool suspends graph execution via ``durable_interrupt`` + until the form is completed, then resolves the configured outcome + mapping into a continue/end action (mirroring + :func:`create_escalation_tool`). + + Args: + resource: The quick-form escalation resource (``escalationType=2``). + agent: Optional parent agent definition; reserved for parity with + :func:`create_escalation_tool` and future agent-scoped + settings (e.g. escalation memory). + + Returns: + A langchain ``StructuredTool`` representing the quick-form + escalation. + """ + del agent + + tool_name: str = f"escalate_{sanitize_tool_name(resource.name)}" + channel: AgentEscalationChannel = resource.channels[0] + + # Orchestrator upserts the form schema by schemaId on every task creation, + # so both schemaId and the inline schema are required for QuickForm. + if not channel.schema_id or not channel.schema: + raise ValueError( + f"Quick-form escalation '{resource.name}' is missing 'schemaId' " + "or 'schema' on its channel; both are required to create the " + "QuickForm task." + ) + + task_schema_key: str = channel.schema_id + task_schema_body: dict[str, Any] = channel.schema + + input_model: Any = create_model(channel.input_schema) + output_model: Any = create_model(channel.output_schema) + QuickFormEscalationToolOutput = make_escalation_tool_output(output_model) + + async def quick_form_escalation_tool_fn(**kwargs: Any) -> dict[str, Any]: + ctx = await build_invocation_ctx(tool, channel, kwargs, input_model) + + @mockable( + name=tool_name.lower(), + description=resource.description, + input_schema=input_model.model_json_schema(), + output_schema=QuickFormEscalationToolOutput.model_json_schema(), + example_calls=channel.properties.example_calls, + ) + async def escalate(**_: Any): + @durable_interrupt + async def create_quick_form_task(): + client = UiPath() + created_task = await client.tasks.create_quickform_async( + title=ctx.task_title, + task_schema_key=task_schema_key, + schema=task_schema_body, + data=ctx.serialized_data, + folder_path=ctx.folder_path, + recipient=ctx.recipient, + priority=channel.priority, + labels=channel.labels, + is_actionable_message_enabled=channel.properties.is_actionable_message_enabled, + actionable_message_metadata=channel.properties.actionable_message_meta_data, + ) + + return WaitEscalation( + action=created_task, + app_folder_path=ctx.folder_path, + app_name=channel.properties.app_name, + recipient=ctx.recipient, + ) + + return await create_quick_form_task() + + result = await escalate(**kwargs) + if isinstance(result, dict): + result = Task.model_validate(result) + + return finalize_escalation_result( + result, + input_model=input_model, + output_model=output_model, + outcome_mapping=channel.outcome_mapping, + ) + + tool = StructuredToolWithArgumentProperties( + name=tool_name, + description=resource.description, + args_schema=input_model, + output_type=output_model, + coroutine=quick_form_escalation_tool_fn, + argument_properties=channel.argument_properties, + metadata={ + "tool_type": "escalation", + "escalation_subtype": "quick_form", + "display_name": channel.properties.app_name, + "channel_type": channel.type, + "recipient": None, + "args_schema": input_model, + "output_schema": output_model, + "schema_id": task_schema_key, + }, + ) + tool.set_tool_wrappers(awrapper=make_escalation_wrapper(channel)) + + return tool diff --git a/src/uipath_langchain/agent/tools/escalation_tool.py b/src/uipath_langchain/agent/tools/escalation_tool.py deleted file mode 100644 index 72aebeece..000000000 --- a/src/uipath_langchain/agent/tools/escalation_tool.py +++ /dev/null @@ -1,501 +0,0 @@ -"""Escalation tool creation for Action Center integration.""" - -import json -import logging -import os -from enum import Enum -from typing import Any, Literal - -from langchain_core.messages.tool import ToolCall -from langchain_core.tools import BaseTool, StructuredTool -from pydantic import BaseModel -from uipath.agent.models.agent import ( - AgentEscalationChannel, - AgentEscalationRecipient, - AgentEscalationRecipientType, - AgentEscalationResourceConfig, - ArgumentEmailRecipient, - ArgumentGroupNameRecipient, - AssetRecipient, - LowCodeAgentDefinition, - StandardRecipient, -) -from uipath.agent.utils.text_tokens import safe_get_nested -from uipath.eval.mocks import mockable -from uipath.platform import UiPath -from uipath.platform.action_center.tasks import Task, TaskRecipient, TaskRecipientType -from uipath.platform.common import WaitEscalation -from uipath.runtime.errors import UiPathErrorCategory - -from uipath_langchain._utils import ( - get_current_span_and_trace_ids, - get_execution_folder_path, -) -from uipath_langchain._utils.durable_interrupt import durable_interrupt -from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model -from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( - StructuredToolWithArgumentProperties, -) - -from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode -from ..react.types import AgentGraphState -from .escalation_memory import ( - EscalationMemorySettings, - _check_escalation_memory_cache, - _get_escalation_memory_folder_path, - _get_escalation_memory_settings, - _get_escalation_memory_space_id, - _get_escalation_memory_space_name, - _ingest_escalation_memory, - _resolve_user_id, -) -from .tool_node import ToolWrapperReturnType -from .utils import ( - resolve_task_title, - sanitize_dict_for_serialization, - sanitize_tool_name, -) - -_escalation_logger = logging.getLogger(__name__) - - -class EscalationAction(str, Enum): - """Actions that can be taken after an escalation completes.""" - - CONTINUE = "continue" - END = "end" - - -async def resolve_recipient_value( - recipient: AgentEscalationRecipient, - input_args: dict[str, Any] | None = None, -) -> TaskRecipient | None: - """Resolve recipient value based on recipient type.""" - if isinstance(recipient, AssetRecipient): - value = await resolve_asset(recipient.asset_name, get_execution_folder_path()) - type = None - if recipient.type == AgentEscalationRecipientType.ASSET_USER_EMAIL: - type = TaskRecipientType.EMAIL - elif recipient.type == AgentEscalationRecipientType.ASSET_GROUP_NAME: - type = TaskRecipientType.GROUP_NAME - return TaskRecipient(value=value, type=type, displayName=value) - - if isinstance(recipient, ArgumentEmailRecipient): - value = safe_get_nested(input_args or {}, recipient.argument_path) - if value is None: - raise ValueError( - f"Argument '{recipient.argument_path}' has no value in agent input." - ) - return TaskRecipient( - value=value, type=TaskRecipientType.EMAIL, displayName=value - ) - - if isinstance(recipient, ArgumentGroupNameRecipient): - value = safe_get_nested(input_args or {}, recipient.argument_path) - if value is None: - raise ValueError( - f"Argument '{recipient.argument_path}' has no value in agent input." - ) - return TaskRecipient( - value=value, type=TaskRecipientType.GROUP_NAME, displayName=value - ) - - if isinstance(recipient, StandardRecipient): - type = TaskRecipientType(recipient.type) - if recipient.type == AgentEscalationRecipientType.USER_EMAIL: - type = TaskRecipientType.EMAIL - return TaskRecipient( - value=recipient.value, type=type, displayName=recipient.value - ) - - return None - - -async def resolve_asset(asset_name: str, folder_path: str | None) -> str | None: - """Retrieve asset value.""" - try: - client = UiPath() - result = await client.assets.retrieve_async( - name=asset_name, folder_path=folder_path - ) - - if not result or not result.value: - raise ValueError(f"Asset '{asset_name}' has no value configured.") - - return result.value - except Exception as e: - raise ValueError( - f"Failed to resolve asset '{asset_name}' in folder '{folder_path}': {str(e)}" - ) from e - - -def _parse_task_data( - data: dict[str, Any], - input_schema: dict[str, Any], - output_schema: dict[str, Any] | None = None, -) -> dict[str, Any]: - """ - Filter action center task data based on input/output schemas. - - When output_schema is None, returns only fields not present in input_schema. - When output_schema is provided, returns only fields defined in output_schema. - - Args: - data: Raw task data from action center - input_schema: JSON schema defining the input fields - output_schema: Optional JSON schema defining expected output fields - - Returns: - Filtered dictionary containing only relevant output fields - """ - filtered_fields: dict[str, Any] = {} - - if output_schema is None: - input_field_names = set() - if "properties" in input_schema: - input_field_names = set(input_schema["properties"].keys()) - - for field_name, field_value in data.items(): - if field_name not in input_field_names: - filtered_fields[field_name] = field_value - - else: - output_field_names = set() - if "properties" in output_schema: - output_field_names = set(output_schema["properties"].keys()) - - for field_name, field_value in data.items(): - if field_name in output_field_names: - filtered_fields[field_name] = field_value - - return filtered_fields - - -def _resolve_escalation_action( - outcome: str | None, - outcome_mapping: dict[str, str] | None, -) -> EscalationAction: - outcome_action = ( - outcome_mapping.get(outcome) if outcome_mapping and outcome else None - ) - return ( - EscalationAction(outcome_action) - if outcome_action - else EscalationAction.CONTINUE - ) - - -def _build_escalation_memory_payload( - serialized_input: dict[str, Any], - escalation_output: dict[str, Any], - outcome: str | None, -) -> tuple[dict[str, Any], dict[str, Any]]: - answer = {"output": escalation_output, "outcome": outcome} - attributes = {"arguments": serialized_input} - return answer, attributes - - -def _pop_escalation_memory_span_context( - metadata: dict[str, Any] | None, -) -> tuple[str | None, str | None]: - span_context = (metadata or {}).get("_span_context") - if not isinstance(span_context, dict): - _escalation_logger.debug( - "Escalation memory span context missing _span_context metadata" - ) - return None, None - - parent_span_id = _format_otel_id(span_context.pop("parent_span_id", None), 16) - trace_id = _format_otel_id(span_context.pop("trace_id", None), 32) - _escalation_logger.debug( - "Escalation memory span context: %s", - json.dumps( - { - "parentSpanId": parent_span_id, - "traceId": trace_id, - "remainingContext": span_context, - }, - default=str, - ), - ) - return parent_span_id, trace_id - - -def _format_otel_id(value: Any, width: int) -> str | None: - if value in (None, ""): - return None - if isinstance(value, int): - return f"{value:0{width}x}" - return str(value) - - -def _normalize_trace_id(value: str) -> str: - normalized = value.replace("-", "").lower() - if len(normalized) != 32: - raise ValueError(f"Invalid trace ID format: {value}") - return normalized - - -def _get_exported_trace_id(trace_id: str | None) -> str | None: - trace_id_override = os.environ.get("UIPATH_TRACE_ID") - if trace_id_override: - try: - return _normalize_trace_id(trace_id_override) - except ValueError: - _escalation_logger.warning( - "Ignoring invalid UIPATH_TRACE_ID override: %s", - trace_id_override, - ) - - return trace_id - - -def create_escalation_tool( - resource: AgentEscalationResourceConfig, - agent: LowCodeAgentDefinition | None = None, -) -> StructuredTool: - """Uses interrupt() for Action Center human-in-the-loop.""" - - tool_name: str = f"escalate_{sanitize_tool_name(resource.name)}" - channel: AgentEscalationChannel = resource.channels[0] - - input_model: Any = create_model(channel.input_schema) - output_model: Any = create_model(channel.output_schema) - - class EscalationToolOutput(BaseModel): - action: Literal["approve", "reject"] - data: output_model - is_deleted: bool = False - - _span_context: dict[str, Any] = {} - _bts_context: dict[str, Any] = {} - _memory_space_id: str | None = _get_escalation_memory_space_id(resource, agent) - _memory_folder_path: str | None = _get_escalation_memory_folder_path( - resource, agent - ) - _memory_space_name: str | None = _get_escalation_memory_space_name(resource, agent) - _memory_settings: EscalationMemorySettings | None = _get_escalation_memory_settings( - resource - ) - - async def escalation_tool_fn(**kwargs: Any) -> dict[str, Any]: - agent_input: dict[str, Any] = ( - tool.metadata.get("agent_input") if tool.metadata else None - ) or {} - recipient: TaskRecipient | None = ( - await resolve_recipient_value(channel.recipients[0], input_args=agent_input) - if channel.recipients - else None - ) - folder_path = get_execution_folder_path() - - task_title = "Escalation Task" - if tool.metadata is not None: - # Recipient requires runtime resolution, store in metadata after resolving - tool.metadata["recipient"] = recipient - task_title = tool.metadata.get("task_title") or task_title - - serialized_data = input_model.model_validate(kwargs).model_dump(mode="json") - - # --- Escalation memory: check cache before creating HITL task --- - if _memory_space_id: - cached_result = await _check_escalation_memory_cache( - _memory_space_id, - serialized_data, - folder_path=_memory_folder_path or folder_path, - memory_settings=_memory_settings, - memory_space_name=_memory_space_name, - ) - if cached_result is not None: - return { - "action": _resolve_escalation_action( - cached_result.outcome, - channel.outcome_mapping, - ), - "output": cached_result.output, - "outcome": cached_result.outcome, - } - - @mockable( - name=tool_name.lower(), - description=resource.description, - input_schema=input_model.model_json_schema(), - output_schema=EscalationToolOutput.model_json_schema(), - example_calls=channel.properties.example_calls, - ) - async def escalate(**_tool_kwargs: Any): - @durable_interrupt - async def create_escalation_task(): - client = UiPath() - created_task = await client.tasks.create_async( - title=task_title, - data=serialized_data, - app_name=channel.properties.app_name, - app_folder_path=folder_path, - recipient=recipient, - priority=channel.priority, - labels=channel.labels, - is_actionable_message_enabled=channel.properties.is_actionable_message_enabled, - actionable_message_metadata=channel.properties.actionable_message_meta_data, - ) - - if created_task.id is not None: - _bts_context["task_key"] = str(created_task.id) - - return WaitEscalation( - action=created_task, - app_folder_path=folder_path, - app_name=channel.properties.app_name, - recipient=recipient, - ) - - return await create_escalation_task() - - result = await escalate(**kwargs) - if isinstance(result, dict): - result = Task.model_validate(result) - - if result.is_deleted: - return { - "action": EscalationAction.END, - "output": None, - "outcome": "The escalation task was deleted", - } - - outcome = result.action - escalation_output = _parse_task_data( - result.data.model_dump() - if isinstance(result.data, BaseModel) - else result.data, - input_schema=input_model.model_json_schema(), - output_schema=output_model.model_json_schema(), - ) - - escalation_action = _resolve_escalation_action( - outcome, - channel.outcome_mapping, - ) - - # --- Escalation memory: persist outcome for future recall --- - if _memory_space_id: - user_id = await _resolve_user_id(result.completed_by_user) - parent_span_id, trace_id = _pop_escalation_memory_span_context( - tool.metadata - ) - if not parent_span_id or not trace_id: - fallback_span_id, fallback_trace_id = get_current_span_and_trace_ids() - _escalation_logger.debug( - "Escalation memory span context fallback: %s", - json.dumps( - { - "fallbackSpanId": fallback_span_id, - "fallbackTraceId": fallback_trace_id, - "hadParentSpanId": bool(parent_span_id), - "hadTraceId": bool(trace_id), - }, - default=str, - ), - ) - parent_span_id = parent_span_id or fallback_span_id - trace_id = trace_id or _get_exported_trace_id(fallback_trace_id) - if not parent_span_id or not trace_id: - _escalation_logger.warning( - "Skipping escalation memory ingest because span provenance is incomplete" - ) - return { - "action": escalation_action, - "output": escalation_output, - "outcome": outcome, - } - answer_payload, attributes_payload = _build_escalation_memory_payload( - serialized_data, - escalation_output, - outcome, - ) - await _ingest_escalation_memory( - _memory_space_id, - answer=json.dumps(answer_payload), - attributes=json.dumps(attributes_payload), - parent_span_id=parent_span_id, - trace_id=trace_id, - user_id=user_id, - folder_path=_memory_folder_path or folder_path, - ) - if user_id is None: - _escalation_logger.info( - "Ingested escalation memory without reviewer user ID " - "because the completed user could not be resolved" - ) - - return { - "action": escalation_action, - "output": escalation_output, - "outcome": outcome, - } - - async def escalation_wrapper( - tool: BaseTool, - call: ToolCall, - state: AgentGraphState, - ) -> ToolWrapperReturnType: - if tool.metadata is None: - raise RuntimeError("Tool metadata is required for task_title resolution") - - state_dict = sanitize_dict_for_serialization(dict(state)) - tool.metadata["task_title"] = resolve_task_title( - channel.task_title, - state_dict, - default_title="Escalation Task", - ) - internal_fields = set(AgentGraphState.model_fields.keys()) - tool.metadata["agent_input"] = { - k: v for k, v in state_dict.items() if k not in internal_fields - } - - tool.metadata["_call_id"] = call.get("id") - tool.metadata["_call_args"] = dict(call.get("args", {})) - - result = await tool.ainvoke(call["args"]) - - if result["action"] == EscalationAction.END: - output_detail = f"Escalation output: {result['output']}" - termination_title = ( - f"Agent run ended based on escalation outcome {result['action']} " - f"with directive {result['outcome']}" - ) - - raise AgentRuntimeError( - code=AgentRuntimeErrorCode.TERMINATION_ESCALATION_REJECTED, - title=termination_title, - detail=output_detail, - category=UiPathErrorCategory.USER, - ) - - return { - "output": result["output"], - "outcome": result["outcome"], - "task_id": result.get("task_id"), - "assigned_to": result.get("assigned_to"), - } - - tool = StructuredToolWithArgumentProperties( - name=tool_name, - description=resource.description, - args_schema=input_model, - output_type=output_model, - coroutine=escalation_tool_fn, - argument_properties=channel.argument_properties, - metadata={ - "tool_type": "escalation", - "display_name": channel.properties.app_name, - "channel_type": channel.type, - "recipient": None, - "args_schema": input_model, - "output_schema": output_model, - "_span_context": _span_context, - "_bts_context": _bts_context, - }, - ) - tool.set_tool_wrappers(awrapper=escalation_wrapper) - - return tool diff --git a/src/uipath_langchain/agent/tools/tool_factory.py b/src/uipath_langchain/agent/tools/tool_factory.py index 0cbb0135e..5114d0581 100644 --- a/src/uipath_langchain/agent/tools/tool_factory.py +++ b/src/uipath_langchain/agent/tools/tool_factory.py @@ -12,6 +12,7 @@ AgentIxpExtractionResourceConfig, AgentIxpVsEscalationResourceConfig, AgentProcessToolResourceConfig, + AgentQuickFormEscalationResourceConfig, BaseAgentResourceConfig, LowCodeAgentDefinition, ) @@ -19,11 +20,14 @@ from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION from .context_tool import create_context_tool -from .escalation_tool import create_escalation_tool +from .escalation import ( + create_escalation_tool, + create_ixp_escalation_tool, + create_quick_form_escalation_tool, +) from .extraction_tool import create_ixp_extraction_tool from .integration_tool import create_integration_tool from .internal_tools import create_internal_tool -from .ixp_escalation_tool import create_ixp_escalation_tool from .process_tool import create_process_tool logger = getLogger(__name__) @@ -120,4 +124,7 @@ async def _build_tool_for_resource( elif isinstance(resource, AgentIxpVsEscalationResourceConfig): return create_ixp_escalation_tool(resource) + elif isinstance(resource, AgentQuickFormEscalationResourceConfig): + return create_quick_form_escalation_tool(resource, agent=agent) + return None diff --git a/tests/agent/guardrails/actions/test_escalate_action.py b/tests/agent/guardrails/actions/test_escalate_action.py index ac4432424..283036f08 100644 --- a/tests/agent/guardrails/actions/test_escalate_action.py +++ b/tests/agent/guardrails/actions/test_escalate_action.py @@ -249,7 +249,7 @@ async def test_node_names( ) @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPathConfig") @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPath") - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value") + @patch("uipath_langchain.agent.tools.escalation.resolve_recipient_value") async def test_create_task_node_sends_correct_data( self, mock_resolve_recipient, @@ -327,7 +327,7 @@ async def test_create_task_node_sends_correct_data( @pytest.mark.asyncio @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPathConfig") @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPath") - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value") + @patch("uipath_langchain.agent.tools.escalation.resolve_recipient_value") async def test_create_task_node_post_agent_with_agent_result( self, mock_resolve_recipient, @@ -401,7 +401,7 @@ async def test_create_task_node_skips_if_task_already_exists(self) -> None: GuardrailScope.TOOL, ], ) - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value") + @patch("uipath_langchain.agent.tools.escalation.resolve_recipient_value") async def test_create_task_node_post_execution_single_message_raises_error( self, mock_resolve_recipient, scope: GuardrailScope ): @@ -434,7 +434,7 @@ async def test_create_task_node_post_execution_single_message_raises_error( @pytest.mark.asyncio @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPathConfig") @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPath") - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value") + @patch("uipath_langchain.agent.tools.escalation.resolve_recipient_value") async def test_create_task_node_tool_pre_execution_extracts_tool_args( self, mock_resolve_recipient, @@ -494,7 +494,7 @@ async def test_create_task_node_tool_pre_execution_extracts_tool_args( @pytest.mark.asyncio @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPathConfig") @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPath") - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value") + @patch("uipath_langchain.agent.tools.escalation.resolve_recipient_value") async def test_create_task_node_tool_post_execution_extracts_tool_content( self, mock_resolve_recipient, @@ -556,7 +556,7 @@ async def test_create_task_node_tool_post_execution_extracts_tool_content( @pytest.mark.asyncio @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPathConfig") @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPath") - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value") + @patch("uipath_langchain.agent.tools.escalation.resolve_recipient_value") async def test_create_task_node_post_execution_ai_message_with_tool_calls( self, mock_resolve_recipient, @@ -1726,7 +1726,7 @@ async def test_validate_message_count_empty_messages_raises_exception(self): ) @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPathConfig") @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPath") - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value") + @patch("uipath_langchain.agent.tools.escalation.resolve_recipient_value") async def test_create_task_resolves_recipient_correctly( self, mock_resolve_recipient, @@ -1768,7 +1768,7 @@ async def test_create_task_resolves_recipient_correctly( assert call_kwargs["recipient"] == expected_value @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value") + @patch("uipath_langchain.agent.tools.escalation.resolve_recipient_value") async def test_create_task_with_asset_recipient_resolution_failure( self, mock_resolve_recipient ) -> None: @@ -1846,7 +1846,7 @@ async def test_metadata_has_escalation_data_with_recipient_type(self): @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPathConfig") @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPath") @patch("uipath_langchain.agent.guardrails.actions.escalate_action.interrupt") - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value") + @patch("uipath_langchain.agent.tools.escalation.resolve_recipient_value") async def test_standard_recipient_assigned_to_uses_value( self, mock_resolve_recipient, mock_interrupt, mock_uipath_class, mock_config ): @@ -1888,7 +1888,7 @@ async def test_standard_recipient_assigned_to_uses_value( @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPathConfig") @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPath") @patch("uipath_langchain.agent.guardrails.actions.escalate_action.interrupt") - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value") + @patch("uipath_langchain.agent.tools.escalation.resolve_recipient_value") async def test_standard_recipient_assigned_to_uses_display_name( self, mock_resolve_recipient, mock_interrupt, mock_uipath_class, mock_config ): @@ -1930,7 +1930,7 @@ async def test_standard_recipient_assigned_to_uses_display_name( @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPathConfig") @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPath") @patch("uipath_langchain.agent.guardrails.actions.escalate_action.interrupt") - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value") + @patch("uipath_langchain.agent.tools.escalation.resolve_recipient_value") async def test_asset_recipient_assigned_to_uses_resolved_value( self, mock_resolve_recipient, mock_interrupt, mock_uipath_class, mock_config ): @@ -1972,7 +1972,7 @@ async def test_asset_recipient_assigned_to_uses_resolved_value( @pytest.mark.asyncio @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPathConfig") @patch("uipath_langchain.agent.guardrails.actions.escalate_action.UiPath") - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value") + @patch("uipath_langchain.agent.tools.escalation.resolve_recipient_value") async def test_create_task_node_sets_metadata_node_type( self, mock_resolve_recipient, mock_uipath_class, mock_config ): diff --git a/tests/agent/tools/test_escalation_memory.py b/tests/agent/tools/test_escalation_memory.py index 248711e01..47eb04d37 100644 --- a/tests/agent/tools/test_escalation_memory.py +++ b/tests/agent/tools/test_escalation_memory.py @@ -15,7 +15,7 @@ ) from uipath.platform.memory import EscalationMemorySearchResponse, SearchMode -from uipath_langchain.agent.tools.escalation_memory import ( +from uipath_langchain.agent.tools.escalation.memory import ( ESCALATION_MEMORY_STRATEGY, MEMORY_CACHE_HIT_METRIC, MEMORY_CACHE_MISS_METRIC, @@ -130,8 +130,8 @@ def test_returns_space_id_when_escalation_memory_enabled_in_properties( _get_escalation_memory_space_id(resource) == "space-from-memory-properties" ) - @patch("uipath_langchain.agent.tools.escalation_memory.get_execution_folder_path") - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory.get_execution_folder_path") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") def test_resolves_space_id_from_agent_memory_feature( self, mock_uipath_cls: MagicMock, @@ -167,7 +167,7 @@ def test_resolves_space_id_from_agent_memory_feature( folder_path="/My Workspace", ) - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") def test_resolves_agent_memory_feature_with_resource_overwrite( self, mock_uipath_cls: MagicMock, @@ -285,7 +285,7 @@ def test_returns_memory_space_name_from_agent_memory_feature(self) -> None: assert _get_escalation_memory_space_name(resource, agent) == "MemorySpace" - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") def test_space_name_resolution_returns_none_when_lookup_fails( self, mock_uipath_cls: MagicMock, @@ -298,7 +298,7 @@ def test_space_name_resolution_returns_none_when_lookup_fails( assert result is None - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") def test_space_name_resolution_returns_none_when_lookup_is_empty( self, mock_uipath_cls: MagicMock, @@ -448,8 +448,8 @@ async def test_returns_existing_user_id_without_api_call(self) -> None: assert await _resolve_user_id({"identifier": USER_GUID}) == USER_GUID @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory.UiPathConfig") - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPathConfig") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") async def test_resolves_email_to_guid_identifier( self, mock_uipath_cls: MagicMock, @@ -480,8 +480,8 @@ async def test_resolves_email_to_guid_identifier( ) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory.UiPathConfig") - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPathConfig") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") async def test_ignores_directory_identifier_that_is_not_guid( self, mock_uipath_cls: MagicMock, @@ -504,8 +504,8 @@ async def test_ignores_directory_identifier_that_is_not_guid( assert result is None @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory.UiPathConfig") - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPathConfig") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") async def test_skips_directory_entries_for_different_email( self, mock_uipath_cls: MagicMock, @@ -529,7 +529,7 @@ async def test_returns_none_when_user_has_no_email(self) -> None: assert await _resolve_user_id({"displayName": "Reviewer"}) is None @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory.UiPathConfig") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPathConfig") async def test_returns_none_when_organization_id_is_missing( self, mock_config: MagicMock, @@ -541,8 +541,8 @@ async def test_returns_none_when_organization_id_is_missing( assert result is None @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory.UiPathConfig") - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPathConfig") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") async def test_returns_none_when_directory_lookup_fails( self, mock_uipath_cls: MagicMock, @@ -562,8 +562,8 @@ async def test_returns_none_when_directory_lookup_fails( class TestCheckEscalationMemoryCache: @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory._record_custom_metric") - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory._record_custom_metric") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") async def test_returns_cached_answer( self, mock_uipath_cls: MagicMock, mock_record_metric: MagicMock ) -> None: @@ -637,7 +637,7 @@ async def test_adds_memory_lookup_spans( ) -> None: from opentelemetry import trace - from uipath_langchain.agent.tools import escalation_memory + from uipath_langchain.agent.tools.escalation import memory as escalation_memory fake_tracer = _FakeTracer() tracer_names: list[str] = [] @@ -734,7 +734,7 @@ async def test_adds_memory_lookup_spans_on_cache_miss( ) -> None: from opentelemetry import trace - from uipath_langchain.agent.tools import escalation_memory + from uipath_langchain.agent.tools.escalation import memory as escalation_memory fake_tracer = _FakeTracer() tracer_names: list[str] = [] @@ -808,8 +808,8 @@ async def test_sets_memory_lookup_spans_to_error_on_search_failure( assert apply_memory_span.statuses == [expected_status] @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory._record_custom_metric") - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory._record_custom_metric") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") async def test_returns_cached_answer_when_sdk_response_has_string_answer( self, mock_uipath_cls: MagicMock, mock_record_metric: MagicMock ) -> None: @@ -858,8 +858,8 @@ async def test_returns_cached_answer_when_sdk_response_has_string_answer( mock_record_metric.assert_called_once_with(MEMORY_CACHE_HIT_METRIC, "space-123") @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory._record_custom_metric") - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory._record_custom_metric") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") async def test_returns_none_on_empty_results( self, mock_uipath_cls: MagicMock, mock_record_metric: MagicMock ) -> None: @@ -876,7 +876,7 @@ async def test_returns_none_on_empty_results( ) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") async def test_returns_none_on_failure(self, mock_uipath_cls: MagicMock) -> None: mock_sdk = MagicMock() mock_uipath_cls.return_value = mock_sdk @@ -888,7 +888,7 @@ async def test_returns_none_on_failure(self, mock_uipath_cls: MagicMock) -> None assert result is None @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory._record_custom_metric") + @patch("uipath_langchain.agent.tools.escalation.memory._record_custom_metric") async def test_treats_empty_search_fields_as_cache_miss( self, mock_record_metric: MagicMock ) -> None: @@ -900,7 +900,7 @@ async def test_treats_empty_search_fields_as_cache_miss( ) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory._record_custom_metric") + @patch("uipath_langchain.agent.tools.escalation.memory._record_custom_metric") async def test_treats_unmatched_configured_fields_as_cache_miss( self, mock_record_metric: MagicMock ) -> None: @@ -986,7 +986,7 @@ def test_filters_empty_and_unconfigured_fields(self) -> None: class TestIngestEscalationMemory: @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") async def test_calls_ingest(self, mock_uipath_cls: MagicMock) -> None: mock_sdk = MagicMock() mock_uipath_cls.return_value = mock_sdk @@ -1008,7 +1008,7 @@ async def test_calls_ingest(self, mock_uipath_cls: MagicMock) -> None: assert request.user_id == USER_GUID @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") async def test_calls_ingest_without_user_id( self, mock_uipath_cls: MagicMock ) -> None: @@ -1028,7 +1028,7 @@ async def test_calls_ingest_without_user_id( assert request.user_id is None @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") async def test_calls_ingest_without_invalid_user_id( self, mock_uipath_cls: MagicMock ) -> None: @@ -1049,7 +1049,7 @@ async def test_calls_ingest_without_invalid_user_id( assert request.user_id is None @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_memory.UiPath") + @patch("uipath_langchain.agent.tools.escalation.memory.UiPath") async def test_graceful_on_failure(self, mock_uipath_cls: MagicMock) -> None: mock_sdk = MagicMock() mock_uipath_cls.return_value = mock_sdk @@ -1120,7 +1120,7 @@ def add_event(self, name: str, attributes: dict[str, object]) -> None: monkeypatch.setattr(metrics, "get_meter", lambda _name: meter) monkeypatch.setattr(trace, "get_current_span", lambda: Span()) - from uipath_langchain.agent.tools import escalation_memory + from uipath_langchain.agent.tools.escalation import memory as escalation_memory escalation_memory._metric_counters.clear() _record_custom_metric(MEMORY_CACHE_HIT_METRIC, "space-123") @@ -1159,7 +1159,7 @@ def test_record_custom_metric_is_best_effort(self, monkeypatch) -> None: MagicMock(side_effect=RuntimeError("metrics unavailable")), ) - from uipath_langchain.agent.tools import escalation_memory + from uipath_langchain.agent.tools.escalation import memory as escalation_memory escalation_memory._metric_counters.clear() _record_custom_metric(MEMORY_CACHE_MISS_METRIC, "space-123") diff --git a/tests/agent/tools/test_escalation_tool.py b/tests/agent/tools/test_escalation_tool.py index 78b685c8c..9765490bc 100644 --- a/tests/agent/tools/test_escalation_tool.py +++ b/tests/agent/tools/test_escalation_tool.py @@ -15,17 +15,19 @@ ) from uipath.platform.action_center.tasks import Task, TaskRecipient, TaskRecipientType -from uipath_langchain.agent.tools.escalation_memory import ( - EscalationMemoryCachedResult, - _get_user_email, -) -from uipath_langchain.agent.tools.escalation_tool import ( +from uipath_langchain.agent.tools.escalation.app_task import ( _build_escalation_memory_payload, - _parse_task_data, create_escalation_tool, +) +from uipath_langchain.agent.tools.escalation.common import ( + _parse_task_data, resolve_asset, resolve_recipient_value, ) +from uipath_langchain.agent.tools.escalation.memory import ( + EscalationMemoryCachedResult, + _get_user_email, +) def _make_mock_task(**overrides): @@ -39,7 +41,7 @@ class TestResolveAsset: """Test the resolve_asset function.""" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.common.UiPath") async def test_resolve_asset_success(self, mock_uipath_class): """Test successful asset retrieval.""" # Setup mock @@ -59,7 +61,7 @@ async def test_resolve_asset_success(self, mock_uipath_class): ) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.common.UiPath") async def test_resolve_asset_no_value(self, mock_uipath_class): """Test asset with no value raises ValueError.""" # Setup mock @@ -76,7 +78,7 @@ async def test_resolve_asset_no_value(self, mock_uipath_class): assert "Asset 'empty_asset' has no value configured" in str(exc_info.value) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.common.UiPath") async def test_resolve_asset_not_found(self, mock_uipath_class): """Test asset not found raises ValueError.""" # Setup mock @@ -91,7 +93,7 @@ async def test_resolve_asset_not_found(self, mock_uipath_class): assert "Asset 'missing_asset' has no value configured" in str(exc_info.value) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.common.UiPath") async def test_resolve_asset_retrieval_exception(self, mock_uipath_class): """Test exception during asset retrieval raises ValueError with context.""" # Setup mock @@ -117,7 +119,7 @@ class TestResolveRecipientValue: @pytest.mark.asyncio @patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Test/Folder"}) - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_asset") + @patch("uipath_langchain.agent.tools.escalation.common.resolve_asset") async def test_resolve_recipient_asset_user_email(self, mock_resolve_asset): """Test ASSET_USER_EMAIL type calls resolve_asset.""" mock_resolve_asset.return_value = "resolved@example.com" @@ -139,7 +141,7 @@ async def test_resolve_recipient_asset_user_email(self, mock_resolve_asset): @pytest.mark.asyncio @patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Test/Folder"}) - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_asset") + @patch("uipath_langchain.agent.tools.escalation.common.resolve_asset") async def test_resolve_recipient_asset_group_name(self, mock_resolve_asset): """Test ASSET_GROUP_NAME type calls resolve_asset.""" mock_resolve_asset.return_value = "ResolvedGroup" @@ -176,7 +178,7 @@ async def test_resolve_recipient_user_email(self): ) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.resolve_asset") + @patch("uipath_langchain.agent.tools.escalation.common.resolve_asset") async def test_resolve_recipient_propagates_error_when_asset_resolution_fails( self, mock_resolve_asset ): @@ -299,7 +301,7 @@ async def test_escalation_tool_metadata_has_span_context(self, escalation_resour assert isinstance(tool.metadata["_span_context"], dict) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_metadata_has_recipient( self, mock_interrupt, mock_uipath_class, escalation_resource @@ -328,7 +330,7 @@ async def test_escalation_tool_metadata_has_recipient( ) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_metadata_recipient_none_when_no_recipients( self, mock_interrupt, mock_uipath_class, escalation_resource_no_recipient @@ -353,7 +355,7 @@ async def test_escalation_tool_metadata_recipient_none_when_no_recipients( assert tool.metadata["recipient"] is None @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_with_string_task_title( self, mock_interrupt, mock_uipath_class @@ -403,7 +405,7 @@ async def test_escalation_tool_with_string_task_title( assert create_call[1]["title"] == "Static Task Title" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_with_text_builder_task_title( self, mock_interrupt, mock_uipath_class @@ -461,7 +463,7 @@ async def test_escalation_tool_with_text_builder_task_title( assert create_call[1]["title"] == "Approve request for John Doe" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_with_empty_task_title_defaults_to_escalation_task( self, mock_interrupt, mock_uipath_class @@ -559,7 +561,7 @@ async def test_escalation_tool_output_schema_has_action_field( assert args_schema is not None @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_result_validation( self, mock_interrupt, mock_uipath_class, escalation_resource @@ -587,7 +589,7 @@ async def test_escalation_tool_result_validation( assert result["outcome"] == "approve" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_extracts_action_from_result( self, mock_interrupt, mock_uipath_class, escalation_resource @@ -611,7 +613,7 @@ async def test_escalation_tool_extracts_action_from_result( assert mock_interrupt.called @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_raises_when_task_is_deleted( self, mock_interrupt, mock_uipath_class, escalation_resource @@ -636,7 +638,7 @@ async def test_escalation_tool_raises_when_task_is_deleted( await tool.awrapper(tool, call, {}) # type: ignore[attr-defined] @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_dict_result_without_is_deleted_defaults_to_false( self, mock_interrupt, mock_uipath_class, escalation_resource @@ -661,7 +663,7 @@ async def test_escalation_tool_dict_result_without_is_deleted_defaults_to_false( assert result["output"] == {"approved": True, "reason": "looks good"} @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_escalation_tool_with_outcome_mapping_end( self, mock_interrupt, mock_uipath_class @@ -714,7 +716,7 @@ async def test_escalation_tool_with_outcome_mapping_end( @pytest.mark.asyncio @patch( - "uipath_langchain.agent.tools.escalation_tool._check_escalation_memory_cache" + "uipath_langchain.agent.tools.escalation.app_task._check_escalation_memory_cache" ) async def test_cached_escalation_uses_outcome_mapping( self, mock_check_memory_cache: AsyncMock @@ -757,9 +759,9 @@ async def test_cached_escalation_uses_outcome_mapping( await tool.awrapper(tool, call, {}) # type: ignore[attr-defined] @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.get_execution_folder_path") + @patch("uipath_langchain.agent.tools.escalation.common.get_execution_folder_path") @patch( - "uipath_langchain.agent.tools.escalation_tool._check_escalation_memory_cache" + "uipath_langchain.agent.tools.escalation.app_task._check_escalation_memory_cache" ) async def test_cache_lookup_uses_memory_folder_path( self, @@ -951,7 +953,7 @@ def escalation_resource(self): ) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_creates_task_then_interrupts_with_wait_escalation( self, mock_interrupt, mock_uipath_class, escalation_resource @@ -986,7 +988,7 @@ async def test_creates_task_then_interrupts_with_wait_escalation( @pytest.mark.asyncio @patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Test/Folder"}) - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_creates_task_with_execution_folder_path( self, mock_interrupt, mock_uipath_class, escalation_resource @@ -1013,7 +1015,7 @@ async def test_creates_task_with_execution_folder_path( assert create_call_kwargs["app_folder_path"] == "/Test/Folder" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") async def test_task_creation_failure_propagates( self, mock_uipath_class, escalation_resource ): @@ -1030,14 +1032,14 @@ async def test_task_creation_failure_propagates( @pytest.mark.asyncio @patch( - "uipath_langchain.agent.tools.escalation_tool.get_current_span_and_trace_ids" + "uipath_langchain.agent.tools.escalation.app_task.get_current_span_and_trace_ids" ) - @patch("uipath_langchain.agent.tools.escalation_tool._ingest_escalation_memory") - @patch("uipath_langchain.agent.tools.escalation_tool._resolve_user_id") + @patch("uipath_langchain.agent.tools.escalation.app_task._ingest_escalation_memory") + @patch("uipath_langchain.agent.tools.escalation.app_task._resolve_user_id") @patch( - "uipath_langchain.agent.tools.escalation_tool._check_escalation_memory_cache" + "uipath_langchain.agent.tools.escalation.app_task._check_escalation_memory_cache" ) - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_memory_ingest_uses_traced_escalation_span_context( self, @@ -1110,14 +1112,14 @@ async def test_memory_ingest_uses_traced_escalation_span_context( @pytest.mark.asyncio @patch( - "uipath_langchain.agent.tools.escalation_tool.get_current_span_and_trace_ids" + "uipath_langchain.agent.tools.escalation.app_task.get_current_span_and_trace_ids" ) - @patch("uipath_langchain.agent.tools.escalation_tool._ingest_escalation_memory") - @patch("uipath_langchain.agent.tools.escalation_tool._resolve_user_id") + @patch("uipath_langchain.agent.tools.escalation.app_task._ingest_escalation_memory") + @patch("uipath_langchain.agent.tools.escalation.app_task._resolve_user_id") @patch( - "uipath_langchain.agent.tools.escalation_tool._check_escalation_memory_cache" + "uipath_langchain.agent.tools.escalation.app_task._check_escalation_memory_cache" ) - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_memory_ingest_falls_back_to_current_span_context( self, @@ -1184,14 +1186,14 @@ async def test_memory_ingest_falls_back_to_current_span_context( @pytest.mark.asyncio @patch( - "uipath_langchain.agent.tools.escalation_tool.get_current_span_and_trace_ids" + "uipath_langchain.agent.tools.escalation.app_task.get_current_span_and_trace_ids" ) - @patch("uipath_langchain.agent.tools.escalation_tool._ingest_escalation_memory") - @patch("uipath_langchain.agent.tools.escalation_tool._resolve_user_id") + @patch("uipath_langchain.agent.tools.escalation.app_task._ingest_escalation_memory") + @patch("uipath_langchain.agent.tools.escalation.app_task._resolve_user_id") @patch( - "uipath_langchain.agent.tools.escalation_tool._check_escalation_memory_cache" + "uipath_langchain.agent.tools.escalation.app_task._check_escalation_memory_cache" ) - @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.app_task.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_memory_ingest_skips_when_span_context_is_unavailable( self, diff --git a/tests/agent/tools/test_ixp_escalation_tool.py b/tests/agent/tools/test_ixp_escalation_tool.py index 51377991d..a569be45e 100644 --- a/tests/agent/tools/test_ixp_escalation_tool.py +++ b/tests/agent/tools/test_ixp_escalation_tool.py @@ -20,7 +20,7 @@ from uipath_langchain.agent.exceptions import AgentRuntimeError from uipath_langchain.agent.react.types import AgentGraphState, InnerAgentGraphState -from uipath_langchain.agent.tools.ixp_escalation_tool import create_ixp_escalation_tool +from uipath_langchain.agent.tools.escalation.ixp_vs import create_ixp_escalation_tool def _passthrough_task(fn): @@ -185,7 +185,7 @@ def mock_state_without_extraction(self): ) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.ixp_vs.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_wrapper_retrieves_extraction_from_state( self, @@ -285,7 +285,7 @@ async def test_wrapper_looks_for_correct_ixp_tool_id( ) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.ixp_vs.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_wrapper_raises_on_document_rejection( self, @@ -366,7 +366,7 @@ def mock_extraction_response(self): ) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.ixp_vs.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_tool_calls_interrupt_with_correct_params( self, @@ -407,7 +407,7 @@ async def test_tool_calls_interrupt_with_correct_params( assert validation_arg.task_url == "https://example.com/actions_/tasks/123" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.ixp_vs.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_tool_uses_default_action_title_when_not_provided( self, mock_interrupt, mock_uipath_cls, mock_extraction_response @@ -460,7 +460,7 @@ async def test_tool_uses_default_action_title_when_not_provided( assert sdk_kwargs.kwargs["action_title"] == "VS Escalation Task" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.ixp_vs.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_tool_uses_default_priority_when_not_provided( self, mock_interrupt, mock_uipath_cls, mock_extraction_response @@ -513,7 +513,7 @@ async def test_tool_uses_default_priority_when_not_provided( assert sdk_kwargs.kwargs["action_priority"] == ActionPriority.MEDIUM @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.ixp_vs.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_tool_returns_data_projection_as_dict( self, @@ -541,7 +541,7 @@ async def test_tool_returns_data_projection_as_dict( assert "data" in result @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") + @patch("uipath_langchain.agent.tools.escalation.ixp_vs.UiPath") @patch("uipath_langchain._utils.durable_interrupt.decorator.interrupt") async def test_tool_stores_validation_response_in_metadata( self, diff --git a/tests/cli/test_agent_with_guardrails.py b/tests/cli/test_agent_with_guardrails.py index bc8bfbf0f..8fab1def4 100644 --- a/tests/cli/test_agent_with_guardrails.py +++ b/tests/cli/test_agent_with_guardrails.py @@ -1168,7 +1168,7 @@ def mock_interrupt(value): return_value=mock_uipath_instance, ), patch( - "uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value", + "uipath_langchain.agent.tools.escalation.resolve_recipient_value", mock_resolve, ), ): @@ -1378,7 +1378,7 @@ def mock_interrupt(value): return_value=mock_uipath_instance, ), patch( - "uipath_langchain.agent.tools.escalation_tool.resolve_recipient_value", + "uipath_langchain.agent.tools.escalation.resolve_recipient_value", mock_resolve, ), ):